refactor: unify Telegram delivery and add quality checks
This commit is contained in:
29
.github/workflows/quality.yml
vendored
Normal file
29
.github/workflows/quality.yml
vendored
Normal 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
5
.gitignore
vendored
@@ -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
5
.idea/.gitignore
generated
vendored
@@ -1,5 +0,0 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
10
.idea/KAndrusyak_Bot.iml
generated
10
.idea/KAndrusyak_Bot.iml
generated
@@ -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>
|
||||
6
.idea/inspectionProfiles/profiles_settings.xml
generated
6
.idea/inspectionProfiles/profiles_settings.xml
generated
@@ -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
7
.idea/misc.xml
generated
@@ -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
8
.idea/modules.xml
generated
@@ -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
6
.idea/vcs.xml
generated
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
12
README.md
12
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
|
||||
```
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
try:
|
||||
sent = await bot.send_message(
|
||||
async def send_formatted(text: str) -> Message:
|
||||
return await bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=formatted_text,
|
||||
text=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
16
pyproject.toml
Normal 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
2
requirements-dev.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
coverage[toml]>=7.6,<8
|
||||
ruff>=0.9,<1
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user