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: