From 701cdd35d2ee70169b7eaabdf48551f828637b58 Mon Sep 17 00:00:00 2001 From: kandrusyak Date: Mon, 27 Jul 2026 17:54:25 +0300 Subject: [PATCH] refactor: unify Telegram delivery and add quality checks --- .github/workflows/quality.yml | 29 +++++++ .gitignore | 5 ++ .idea/.gitignore | 5 -- .idea/KAndrusyak_Bot.iml | 10 --- .../inspectionProfiles/profiles_settings.xml | 6 -- .idea/misc.xml | 7 -- .idea/modules.xml | 8 -- .idea/vcs.xml | 6 -- README.md | 12 +++ assistant_bot/handlers.py | 3 +- assistant_bot/telegram_utils.py | 80 ++++++++++--------- pyproject.toml | 16 ++++ requirements-dev.txt | 2 + tests/test_telegram_utils.py | 16 ++++ 14 files changed, 123 insertions(+), 82 deletions(-) create mode 100644 .github/workflows/quality.yml delete mode 100644 .idea/.gitignore delete mode 100644 .idea/KAndrusyak_Bot.iml delete mode 100644 .idea/inspectionProfiles/profiles_settings.xml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/vcs.xml create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..0ea62d4 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,29 @@ +name: quality + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.12"] + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - name: Install test dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements-common.txt -r requirements-dev.txt + - name: Lint + run: python -m ruff check . + - name: Test with coverage + run: | + python -m coverage run -m unittest discover -v + python -m coverage report diff --git a/.gitignore b/.gitignore index a905d6d..ee9cd76 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,10 @@ .env .venv/ +.idea/ __pycache__/ *.py[cod] assistant_data.sqlite3* +.coverage +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index b58b603..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ diff --git a/.idea/KAndrusyak_Bot.iml b/.idea/KAndrusyak_Bot.iml deleted file mode 100644 index 364598c..0000000 --- a/.idea/KAndrusyak_Bot.iml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml deleted file mode 100644 index 105ce2d..0000000 --- a/.idea/inspectionProfiles/profiles_settings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 16ff1e7..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 4117856..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/README.md b/README.md index ebf0634..0048124 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,12 @@ Telegram-ассистент с двумя режимами AI: локальны - `assistant_bot/ollama.py` — HTTP-клиент Ollama; - `assistant_bot/yandex_ai.py` — адаптер Yandex AI Studio SDK; - `assistant_bot/agent.py` — агентный цикл и выполнение внутренних tools; +- `assistant_bot/agent_tools.py` — единый реестр и исполнители agent tools; +- `assistant_bot/services.py` — типизированный контейнер сервисов приложения; - `assistant_bot/handlers.py` — Telegram-команды и сообщения; - `assistant_bot/reminders.py` — разбор времени напоминаний; - `assistant_bot/jobs.py` — фоновые задачи; +- `assistant_bot/migrations.py` — версионируемые миграции SQLite; - `tests/` — модульные тесты ядра. ## Запуск @@ -151,3 +154,12 @@ Ollama должна быть доступна контейнеру по адре conda activate kandrusyak_bot python -m unittest discover -v ``` + +Для локальных проверок качества установите dev-зависимости: + +```powershell +python -m pip install -r requirements-common.txt -r requirements-dev.txt +python -m ruff check . +python -m coverage run -m unittest discover -v +python -m coverage report +``` diff --git a/assistant_bot/handlers.py b/assistant_bot/handlers.py index 7b0516c..9238076 100644 --- a/assistant_bot/handlers.py +++ b/assistant_bot/handlers.py @@ -22,7 +22,6 @@ from .telegram_utils import ( get_tz, get_voice_max_duration, markdown_code, - reply_long, reply_markdown, require_user_id, ) @@ -118,7 +117,7 @@ async def history_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> f"{str(row['content']) if row['role'] == 'assistant' else escape_markdown_text(row['content'])}" for row in rows ) - await reply_long(update.message, text) + await reply_markdown(update.message, text) async def ask_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: diff --git a/assistant_bot/telegram_utils.py b/assistant_bot/telegram_utils.py index 227b457..7f18081 100644 --- a/assistant_bot/telegram_utils.py +++ b/assistant_bot/telegram_utils.py @@ -1,7 +1,7 @@ import asyncio import logging import re -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Awaitable, Callable from contextlib import asynccontextmanager, suppress from uuid import uuid4 from zoneinfo import ZoneInfo @@ -174,6 +174,30 @@ def _with_markdown_notice(markdown_v2: str) -> str: async def reply_markdown(message: Message, markdown: str) -> Message: + async def send_formatted(text: str) -> Message: + return await message.reply_text( + text, + parse_mode=ParseMode.MARKDOWN_V2, + ) + + async def send_plain(text: str) -> Message: + return await message.reply_text(text) + + return await _send_markdown_chunks( + markdown, + send_formatted, + send_plain, + ) + + +MarkdownSender = Callable[[str], Awaitable[Message]] + + +async def _send_markdown_chunks( + markdown: str, + send_formatted: MarkdownSender, + send_plain: MarkdownSender, +) -> Message: chunks, formatting_failed = _prepare_chunks(markdown) first_message: Message | None = None @@ -185,17 +209,14 @@ async def reply_markdown(message: Message, markdown: str) -> Message: else markdown_v2 ) try: - sent = await message.reply_text( - formatted_text, - parse_mode=ParseMode.MARKDOWN_V2, - ) + sent = await send_formatted(formatted_text) except BadRequest: logger.warning("Telegram rejected MarkdownV2; retrying as plain text") formatting_failed = True fallback_text = ( _with_fallback_notice(plain_text) if is_last else plain_text ) - sent = await message.reply_text(fallback_text) + sent = await send_plain(fallback_text) if first_message is None: first_message = sent @@ -204,38 +225,25 @@ async def reply_markdown(message: Message, markdown: str) -> Message: async def edit_markdown(message: Message, markdown: str) -> None: - await edit_or_reply(message, markdown) + await _edit_or_reply(message, markdown) async def send_markdown(bot: Bot, chat_id: int, markdown: str) -> Message: - chunks, formatting_failed = _prepare_chunks(markdown) - first_message: Message | None = None - - for index, (markdown_v2, plain_text) in enumerate(chunks): - is_last = index == len(chunks) - 1 - formatted_text = ( - _with_markdown_notice(markdown_v2) - if is_last and formatting_failed - else markdown_v2 + async def send_formatted(text: str) -> Message: + return await bot.send_message( + chat_id=chat_id, + text=text, + parse_mode=ParseMode.MARKDOWN_V2, ) - try: - sent = await bot.send_message( - chat_id=chat_id, - text=formatted_text, - parse_mode=ParseMode.MARKDOWN_V2, - ) - except BadRequest: - logger.warning("Telegram rejected MarkdownV2; retrying as plain text") - formatting_failed = True - fallback_text = ( - _with_fallback_notice(plain_text) if is_last else plain_text - ) - sent = await bot.send_message(chat_id=chat_id, text=fallback_text) - if first_message is None: - first_message = sent - assert first_message is not None - return first_message + async def send_plain(text: str) -> Message: + return await bot.send_message(chat_id=chat_id, text=text) + + return await _send_markdown_chunks( + markdown, + send_formatted, + send_plain, + ) def make_article( @@ -300,11 +308,7 @@ def require_user_id(update: Update) -> int | None: return None -async def reply_long(message: Message, text: str) -> None: - await reply_markdown(message, text) - - -async def edit_or_reply(message: Message, text: str) -> None: +async def _edit_or_reply(message: Message, text: str) -> None: chunks, formatting_failed = _prepare_chunks(text) for index, (markdown_v2, plain_text) in enumerate(chunks): diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0ae29b9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[tool.ruff] +target-version = "py310" +line-length = 100 +extend-exclude = [".agents"] + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F"] + +[tool.coverage.run] +branch = true +source = ["assistant_bot"] + +[tool.coverage.report] +fail_under = 60 +show_missing = true +skip_covered = true diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..4e30f19 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +coverage[toml]>=7.6,<8 +ruff>=0.9,<1 diff --git a/tests/test_telegram_utils.py b/tests/test_telegram_utils.py index 033a98a..d28305d 100644 --- a/tests/test_telegram_utils.py +++ b/tests/test_telegram_utils.py @@ -12,6 +12,7 @@ from assistant_bot.telegram_utils import ( escape_markdown_text, render_markdown_chunks, reply_markdown, + send_markdown, typing_action, ) @@ -55,6 +56,21 @@ class MarkdownRenderingTests(unittest.TestCase): class MarkdownDeliveryTests(unittest.IsolatedAsyncioTestCase): + async def test_send_markdown_uses_the_shared_formatted_delivery(self) -> None: + sent_message = SimpleNamespace() + bot = SimpleNamespace( + send_message=AsyncMock(return_value=sent_message) + ) + + result = await send_markdown(bot, 42, "**Готово.**") + + self.assertIs(result, sent_message) + bot.send_message.assert_awaited_once_with( + chat_id=42, + text="*Готово\\.*", + parse_mode=ParseMode.MARKDOWN_V2, + ) + async def test_retries_plain_text_with_notice_when_telegram_rejects_markup( self, ) -> None: