refactor: unify Telegram delivery and add quality checks
Some checks failed
quality / test (3.10) (push) Has been cancelled
quality / test (3.12) (push) Has been cancelled

This commit is contained in:
kandrusyak
2026-07-27 17:54:25 +03:00
parent c20d893255
commit 701cdd35d2
14 changed files with 123 additions and 82 deletions

29
.github/workflows/quality.yml vendored Normal file
View File

@@ -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

5
.gitignore vendored
View File

@@ -1,5 +1,10 @@
.env .env
.venv/ .venv/
.idea/
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
assistant_data.sqlite3* assistant_data.sqlite3*
.coverage
.mypy_cache/
.pytest_cache/
.ruff_cache/

5
.idea/.gitignore generated vendored
View File

@@ -1,5 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/

View File

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="kandrusyak_bot" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@@ -1,6 +0,0 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

7
.idea/misc.xml generated
View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="kandrusyak_bot" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="kandrusyak_bot" project-jdk-type="Python SDK" />
</project>

8
.idea/modules.xml generated
View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/KAndrusyak_Bot.iml" filepath="$PROJECT_DIR$/.idea/KAndrusyak_Bot.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@@ -20,9 +20,12 @@ Telegram-ассистент с двумя режимами AI: локальны
- `assistant_bot/ollama.py` — HTTP-клиент Ollama; - `assistant_bot/ollama.py` — HTTP-клиент Ollama;
- `assistant_bot/yandex_ai.py` — адаптер Yandex AI Studio SDK; - `assistant_bot/yandex_ai.py` — адаптер Yandex AI Studio SDK;
- `assistant_bot/agent.py` — агентный цикл и выполнение внутренних tools; - `assistant_bot/agent.py` — агентный цикл и выполнение внутренних tools;
- `assistant_bot/agent_tools.py` — единый реестр и исполнители agent tools;
- `assistant_bot/services.py` — типизированный контейнер сервисов приложения;
- `assistant_bot/handlers.py` — Telegram-команды и сообщения; - `assistant_bot/handlers.py` — Telegram-команды и сообщения;
- `assistant_bot/reminders.py` — разбор времени напоминаний; - `assistant_bot/reminders.py` — разбор времени напоминаний;
- `assistant_bot/jobs.py` — фоновые задачи; - `assistant_bot/jobs.py` — фоновые задачи;
- `assistant_bot/migrations.py` — версионируемые миграции SQLite;
- `tests/` — модульные тесты ядра. - `tests/` — модульные тесты ядра.
## Запуск ## Запуск
@@ -151,3 +154,12 @@ Ollama должна быть доступна контейнеру по адре
conda activate kandrusyak_bot conda activate kandrusyak_bot
python -m unittest discover -v 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
```

View File

@@ -22,7 +22,6 @@ from .telegram_utils import (
get_tz, get_tz,
get_voice_max_duration, get_voice_max_duration,
markdown_code, markdown_code,
reply_long,
reply_markdown, reply_markdown,
require_user_id, 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'])}" f"{str(row['content']) if row['role'] == 'assistant' else escape_markdown_text(row['content'])}"
for row in rows 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: async def ask_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:

View File

@@ -1,7 +1,7 @@
import asyncio import asyncio
import logging import logging
import re import re
from collections.abc import AsyncIterator from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager, suppress from contextlib import asynccontextmanager, suppress
from uuid import uuid4 from uuid import uuid4
from zoneinfo import ZoneInfo 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 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) chunks, formatting_failed = _prepare_chunks(markdown)
first_message: Message | None = None first_message: Message | None = None
@@ -185,17 +209,14 @@ async def reply_markdown(message: Message, markdown: str) -> Message:
else markdown_v2 else markdown_v2
) )
try: try:
sent = await message.reply_text( sent = await send_formatted(formatted_text)
formatted_text,
parse_mode=ParseMode.MARKDOWN_V2,
)
except BadRequest: except BadRequest:
logger.warning("Telegram rejected MarkdownV2; retrying as plain text") logger.warning("Telegram rejected MarkdownV2; retrying as plain text")
formatting_failed = True formatting_failed = True
fallback_text = ( fallback_text = (
_with_fallback_notice(plain_text) if is_last else plain_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: if first_message is None:
first_message = sent 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: 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: async def send_markdown(bot: Bot, chat_id: int, markdown: str) -> Message:
chunks, formatting_failed = _prepare_chunks(markdown) async def send_formatted(text: str) -> Message:
first_message: Message | None = None return await bot.send_message(
chat_id=chat_id,
for index, (markdown_v2, plain_text) in enumerate(chunks): text=text,
is_last = index == len(chunks) - 1 parse_mode=ParseMode.MARKDOWN_V2,
formatted_text = (
_with_markdown_notice(markdown_v2)
if is_last and formatting_failed
else 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 async def send_plain(text: str) -> Message:
return first_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( def make_article(
@@ -300,11 +308,7 @@ def require_user_id(update: Update) -> int | None:
return None return None
async def reply_long(message: Message, text: str) -> None: async def _edit_or_reply(message: Message, text: str) -> None:
await reply_markdown(message, text)
async def edit_or_reply(message: Message, text: str) -> None:
chunks, formatting_failed = _prepare_chunks(text) chunks, formatting_failed = _prepare_chunks(text)
for index, (markdown_v2, plain_text) in enumerate(chunks): for index, (markdown_v2, plain_text) in enumerate(chunks):

16
pyproject.toml Normal file
View File

@@ -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

2
requirements-dev.txt Normal file
View File

@@ -0,0 +1,2 @@
coverage[toml]>=7.6,<8
ruff>=0.9,<1

View File

@@ -12,6 +12,7 @@ from assistant_bot.telegram_utils import (
escape_markdown_text, escape_markdown_text,
render_markdown_chunks, render_markdown_chunks,
reply_markdown, reply_markdown,
send_markdown,
typing_action, typing_action,
) )
@@ -55,6 +56,21 @@ class MarkdownRenderingTests(unittest.TestCase):
class MarkdownDeliveryTests(unittest.IsolatedAsyncioTestCase): 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( async def test_retries_plain_text_with_notice_when_telegram_rejects_markup(
self, self,
) -> None: ) -> None: