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
.venv/
.idea/
__pycache__/
*.py[cod]
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/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
```

View File

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

View File

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

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,
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: