From 694cf1c106699a8dd2dbb6e23467eab358f5690b Mon Sep 17 00:00:00 2001 From: Andrey Date: Sat, 28 Feb 2026 21:30:08 +0300 Subject: [PATCH 1/5] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=BC=D0=B5?= =?UTF-8?q?=D1=82=D0=BE=D0=B4=D1=8B=20=D0=B4=D0=BB=D1=8F=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D1=83=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D0=B8=D1=81=D1=82=D0=B8=D0=BA=D0=B8=20=D0=BF=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D0=B2=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=B9,=20=D0=B8=D0=BD=D1=84?= =?UTF-8?q?=D0=BE=D1=80=D0=BC=D0=B0=D1=86=D0=B8=D0=B8=20=D0=BE=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=81=D0=BB=D0=B5=D0=B4=D0=BD=D0=B8=D1=85=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=85=20=D0=B8=20=D0=BA=D0=BE=D0=BB=D0=B8?= =?UTF-8?q?=D1=87=D0=B5=D1=81=D1=82=D0=B2=D0=B5=20=D0=B1=D0=B0=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2.=20=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D1=8B=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81=D1=8B=20=D0=B2=20?= =?UTF-8?q?=D1=80=D0=B5=D0=BF=D0=BE=D0=B7=D0=B8=D1=82=D0=BE=D1=80=D0=B8?= =?UTF-8?q?=D1=8F=D1=85=20=D0=B4=D0=BB=D1=8F=20=D1=81=D0=BE=D1=80=D1=82?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B8=20=D0=BF=D0=BE=D0=BB=D1=8C?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=B9=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=B4=D0=B0=D1=82=D0=B5=20=D0=B1=D0=B0=D0=BD=D0=B0.?= =?UTF-8?q?=20=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B?= =?UTF-8?q?=20=D0=B2=D1=8B=D0=B7=D0=BE=D0=B2=D1=8B=20=D1=84=D1=83=D0=BD?= =?UTF-8?q?=D0=BA=D1=86=D0=B8=D0=B9=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20=D1=81=D0=BE?= =?UTF-8?q?=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D0=B8=D1=81=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=BE=D0=B2.=20=D0=9E=D0=B1=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D1=82=D0=B5=D1=81=D1=82=D1=8B?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BD=D0=BE=D0=B2=D1=8B=D1=85=20=D1=84=D1=83?= =?UTF-8?q?=D0=BD=D0=BA=D1=86=D0=B8=D0=BE=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B5=D0=B9.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/implementation-plan-features.md | 428 ++++++++++++++++++ .cursor/prompt-stage-4-similar-posts.md | 126 ++++++ database/async_db.py | 31 +- .../blacklist_history_repository.py | 54 ++- database/repositories/blacklist_repository.py | 10 +- database/repositories/post_repository.py | 64 +++ helper_bot/handlers/admin/admin_handlers.py | 14 +- .../handlers/callback/callback_handlers.py | 6 +- helper_bot/handlers/callback/services.py | 14 +- .../handlers/private/private_handlers.py | 25 +- helper_bot/handlers/private/services.py | 173 ++++++- helper_bot/services/scoring/rag_client.py | 183 +++++++- .../services/scoring/scoring_manager.py | 40 ++ helper_bot/utils/helper_func.py | 221 +++++---- tests/test_blacklist_repository.py | 6 +- tests/test_callback_services.py | 10 +- tests/test_refactored_private_handlers.py | 15 +- tests/test_utils.py | 20 +- 18 files changed, 1296 insertions(+), 144 deletions(-) create mode 100644 .cursor/implementation-plan-features.md create mode 100644 .cursor/prompt-stage-4-similar-posts.md diff --git a/.cursor/implementation-plan-features.md b/.cursor/implementation-plan-features.md new file mode 100644 index 0000000..3189d74 --- /dev/null +++ b/.cursor/implementation-plan-features.md @@ -0,0 +1,428 @@ +# План реализации фич Telegram Helper Bot + +> Документ создан: 28 февраля 2025 +> Ветка: `dev-*` +> Статус: План утверждён + +--- + +## Обзор фич + +1. **Пагинация заблокированных пользователей** — сортировка по дате бана, единая логика текста и кнопок +2. **Обогащение сообщений пользователей админам** — данные о пользователе при обращении в поддержку +3. **Причина бана «Последний пост»** — замена «Спам» на «Последний пост» при быстром бане из поста +4. **Похожие посты за 24ч** — проверка на дубликаты через RAG (threshold >0.9) +5. **Авто-публикация/отклонение по RAG** — задел на будущее (>0.8 publish, <0.4 decline) +6. **ML Scoring Статистика** — восстановить полный вывод (модель, примеры, device) вместо fallback (API URL, статус) + +--- + +## Решения по уточняющим вопросам + +| Вопрос | Решение | +|--------|---------| +| Пагинация | Единый источник данных, единый `items_per_page` | +| message_id при forward | Осознанный workaround (n+1). При переходе на send — использовать `returned_message.message_id` | +| RAG similar | Новый endpoint в RAG. Нужна **отдельная коллекция** для submitted-постов (не позитив/негатив) | +| Скор для авто-решений | Только `rag_score` | +| Ветки | `dev-*` | + +--- + +## Проверка: message_id при forward + +**Telegram Bot API:** `forwardMessage` возвращает объект `Message` — это **новое** сообщение в целевом чате со своим `message_id`. Telegram присваивает `message_id` в целевом чате — он не обязан быть `n+1` от исходного. + +Если всё работает — возможно, бот единственный отправитель в `group_for_message`, и id часто идут подряд. Рекомендация: при переходе на `send_message` обязательно сохранять `message_id` из возвращаемого объекта. + +--- + +## RAG: коллекция для похожих постов + +- **Позитив/негатив** — примеры для скоринга модерации +- **Похожие посты** — сравнение с другими submitted-постами за 24ч + +Endpoint `/similar` должен искать по **отдельной коллекции submitted-постов** (с `created_at`), а не по позитиву/негативу. Нужно: +- Новая коллекция в RAG (например, `posts_submitted`) с полями: `text`, `vector`, `created_at`, `post_id` +- Endpoint `POST /similar`: `{"text": "...", "threshold": 0.9, "hours": 24}` → список похожих постов +- При каждом suggest — добавлять пост в эту коллекцию (или вызывать endpoint RAG для индексации) + +--- + +## Пошаговый план реализации + +### Этап 0: Подготовка + +| Шаг | Действие | +|-----|----------| +| 0.1 | Создать ветку `dev-N` (N — следующий номер) от `main` | +| 0.2 | Убедиться, что локально проходит `make code-quality` (или `isort`, `black`, `flake8`, `pytest`) | + +--- + +### Этап 1: Пагинация заблокированных пользователей + +| Шаг | Действие | +|-----|----------| +| 1.1 | В `BlacklistRepository`: добавить `ORDER BY created_at DESC` в `get_all_users` и `get_all_users_no_limit` | +| 1.2 | Ввести единый `items_per_page = 9` в `create_keyboard_with_pagination` и во всех местах пагинации | +| 1.3 | Создать единую функцию `get_banned_users_data(page, items_per_page)` в `AdminService` или `helper_func`, возвращающую `(message_text, buttons_list)` для заданной страницы | +| 1.4 | Обновить `get_banned_users_list` и `get_banned_users_buttons` — использовать общий источник и пагинацию | +| 1.5 | Обновить `admin_handlers.get_banned_users` и `callback_handlers.change_page` — вызывать единую функцию с `page` | +| 1.6 | Прогнать тесты `test_keyboards_and_filters`, `test_callback_handlers`, `test_admin_handlers` | + +**Затронутые файлы:** +- `database/repositories/blacklist_repository.py` +- `helper_bot/keyboards/keyboards.py` +- `helper_bot/utils/helper_func.py` +- `helper_bot/handlers/admin/services.py` +- `helper_bot/handlers/admin/admin_handlers.py` +- `helper_bot/handlers/callback/callback_handlers.py` + +--- + +### Этап 2: Обогащение сообщений пользователей админам + +| Шаг | Действие | +|-----|----------| +| 2.1 | Добавить в `AsyncBotDB`/репозитории методы: `get_posts_count_by_author`, `get_last_post_by_author`, `get_ban_history_count`, `get_last_ban_info`, `get_user_date_added` | +| 2.2 | Добавить в `BlacklistHistoryRepository` методы для истории банов (количество, последний бан) | +| 2.3 | В `UserService` или отдельном сервисе: метод `format_user_message_for_admins(user_id, message_text)` — собирает текст с данными пользователя | +| 2.4 | В `resend_message_in_group_for_message`: заменить `forward` на `send_message` с обогащённым текстом (имя, ник, id, посты, последний пост, баны, дата регистрации) | +| 2.5 | Сохранять `message_id` из результата `send_message` в `user_messages` (вместо `message.message_id + 1`) | +| 2.6 | Проверить, что `get_user_by_message_id` по-прежнему возвращает `user_id` для ответа админа | +| 2.7 | Обновить/добавить тесты для `resend_message_in_group_for_message` и `AdminReplyService` | + +**Данные для обогащения:** +- Количество постов от пользователя +- Последний пост пользователя (текст) +- Количество банов +- Дата и причина последнего бана (если был) +- Дата создания пользователя в БД (первый контакт с ботом) +- Имя, ник, id пользователя (обязательно при send вместо forward) + +**Шаблон итогового сообщения для админов:** + +```text +👤 От: Иван Петров (@ivan_petrov) | ID: 123456789 + +📊 Постов в базе: 5 +📝 Последний пост: "Привет, хочу поделиться мыслями о..." +📅 В боте с: 15.01.2025 + +🚫 Банов: 2 + Последний: 20.02.2025, причина «Спам», истёк 27.02.2025 + +--- +**Сообщение пользователя:** + +**Почему удалили мой пост?** +``` + +**Правила форматирования:** +- Секции «Банов: 0» и «Последний: …» — показывать только если были баны +- «Последний пост» — обрезать до ~80 символов + «…» если длиннее; если постов нет — «Нет постов» +- Даты в формате `DD.MM.YYYY` или `DD.MM.YYYY HH:MM` для разбана +- Разделитель `---` перед текстом сообщения пользователя + +**Для тестов:** использовать этот шаблон как эталон; проверять наличие всех секций, порядок полей, экранирование HTML в имени/нике/тексте. + +**Затронутые файлы:** +- `database/async_db.py` +- `database/repositories/post_repository.py` +- `database/repositories/blacklist_history_repository.py` +- `database/repositories/user_repository.py` +- `helper_bot/handlers/private/private_handlers.py` +- `helper_bot/handlers/private/services.py` +- `helper_bot/handlers/group/services.py` + +--- + +### Этап 3: Причина бана «Последний пост» + +| Шаг | Действие | +|-----|----------| +| 3.1 | В `callback/services.py` в `ban_user_from_post`: заменить `message_for_user="Спам"` на `message_for_user="Последний пост"` | +| 3.2 | Обновить тесты, где ожидается «Спам» | + +**Затронутые файлы:** +- `helper_bot/handlers/callback/services.py` +- `tests/test_callback_services.py` (если есть) + +--- + +### Этап 4: Похожие посты (RAG + бот) + +**Разделение работ:** +- **RAG сервис** — промпт в `.cursor/prompt-stage-4-similar-posts.md` +- **Подключение в боте** — ниже, раздел 4.2 + +#### 4.0. Текущая архитектура RAG сервиса + +**Путь:** `/Users/andrejkatyhin/Work/PycharmProjects/rag-service` + +| Компонент | Описание | +|-----------|----------| +| **VectorStore** (`app/storage/vector_store.py`) | In-memory хранилище векторов. `_positive_vectors` / `_negative_vectors` — для модерации. Персистентность: `positive_embeddings.npy`, `negative_embeddings.npy` или `vectors.npz`. Косинусное сходство через `np.dot` для нормализованных векторов. | +| **RAGService** (`app/services/rag_service.py`) | Модель `sentence-transformers/all-MiniLM-L12-v2` (384 dim). `get_embedding(text)`, `calculate_score(text)`, `add_positive_example`, `add_negative_example`. | +| **API** (`app/api/routes.py`) | `POST /api/v1/score`, `POST /api/v1/examples/positive`, `POST /api/v1/examples/negative`, `GET /api/v1/stats`, `POST /api/v1/warmup`, `GET/PUT /api/v1/scoring/params`. | +| **Config** (`app/config.py`) | `RAG_VECTORS_PATH`, `RAG_MAX_EXAMPLES`, `vector_dim=384`. | + +**Важно:** Позитив/негатив — отдельные коллекции без `created_at`. Для похожих постов нужна **третья** коллекция с временными метками. + +--- + +#### 4.1. Работы в RAG сервисе + +##### 4.1.1. Расширить VectorStore + +**Файл:** `app/storage/vector_store.py` + +Добавить третью коллекцию для submitted-постов: + +```python +# Новые атрибуты (аналогично _positive_vectors) +self._submitted_vectors: list = [] +self._submitted_hashes: list = [] +self._submitted_created_at: list = [] # Unix timestamps +self._submitted_post_ids: list = [] # post_id из бота +self._submitted_texts: list = [] # текст поста (для возврата в similar) +self._submitted_rag_scores: list = [] # rag_score на момент добавления +``` + +**Новые методы:** + +| Метод | Описание | +|-------|----------| +| `add_submitted(vector, text_hash, created_at, post_id=None, text="", rag_score=None)` | Добавить пост в коллекцию submitted. FIFO при превышении `max_submitted` (новый лимит, например 5000). | +| `find_similar_submitted(vector, threshold, hours)` | Возвращает: `List[dict]` с полями `similarity`, `created_at`, `post_id`, `text`, `rag_score`. Фильтрует по `created_at >= now - hours*3600`. Сравнивает с `_submitted_vectors` через `np.dot`. Возвращает только те, где similarity >= threshold. | + +**Персистентность:** Добавить в `save_to_disk` / `_load_from_disk` сохранение submitted-коллекции. Файл `submitted_embeddings.npz` с полями: `vectors`, `hashes`, `created_at`, `post_ids`, `texts`, `rag_scores`. + +**Конфиг:** Добавить `RAG_MAX_SUBMITTED` (default 5000), `RAG_SUBMITTED_PATH` (путь к файлу submitted). + +##### 4.1.2. Расширить RAGService + +**Файл:** `app/services/rag_service.py` + +| Метод | Описание | +|-------|----------| +| `add_submitted_post(text, post_id=None, rag_score=None)` | Очистить текст, получить embedding, `add_submitted` в vector_store. Сохраняет `text` и `rag_score` для возврата в similar. Вызывается при каждом suggest (бот передаёт rag_score из скоринга). | +| `find_similar_posts(text, threshold=0.9, hours=24)` | Получить embedding, вызвать `vector_store.find_similar_submitted`. Вернуть список похожих с полями: `similarity`, `created_at`, `post_id`, `text`, `rag_score`. | + +##### 4.1.3. Добавить API endpoint + +**Файл:** `app/api/routes.py` + +**POST /api/v1/similar** + +Возвращает: количество похожих постов, текст каждого, similarity (косинусное сходство), присвоенный rag_score на момент добавления. + +```python +# Request +class SimilarRequest(BaseModel): + text: str = Field(..., min_length=1) + threshold: float = Field(default=0.9, ge=0.0, le=1.0) + hours: int = Field(default=24, ge=1, le=168) # 1ч–7дней + +# Response +class SimilarPostItem(BaseModel): + similarity: float # косинусное сходство (0.0–1.0) + created_at: int # Unix timestamp + post_id: Optional[int] = None + text: str # текст похожего поста + rag_score: Optional[float] = None # rag_score на момент добавления + +class SimilarResponse(BaseModel): + similar_count: int + similar_posts: List[SimilarPostItem] +``` + +**POST /api/v1/submitted** + +```python +# Request +class SubmittedRequest(BaseModel): + text: str = Field(..., min_length=1) + post_id: Optional[int] = None + rag_score: Optional[float] = None # для возврата в similar + +# Response +class SubmittedResponse(BaseModel): + success: bool + message: str + submitted_count: int +``` + +**Примечание:** `POST /submitted` вызывается ботом при каждом suggest (после сохранения поста в БД). `POST /similar` вызывается ботом **перед** отправкой в группу модерации — чтобы проверить, есть ли похожие посты за последние сутки. + +##### 4.1.4. Схемы и исключения + +**Файл:** `app/schemas.py` — добавить `SimilarRequest`, `SimilarResponse`, `SimilarPostItem`, `SubmittedRequest`, `SubmittedResponse`. + +**Файл:** `app/exceptions.py` — при необходимости добавить `SubmittedStoreError` (если коллекция пуста и т.п.). + +##### 4.1.5. Автоочистка submitted (опционально) + +В `autosave_loop` или отдельно: периодически удалять из `_submitted_*` записи старше N часов (например, 48), чтобы не раздувать память. + +--- + +#### 4.2. Подключение в Telegram Helper Bot + +> RAG сервис реализуется отдельно (промпт: `.cursor/prompt-stage-4-similar-posts.md`). Ниже — интеграция в бота. + +##### 4.2.1. RagApiClient (`helper_bot/services/scoring/rag_client.py`) + +Добавить методы: + +- `find_similar_posts(text, threshold=0.9, hours=24)` — POST на `{api_url}/similar`, body `{"text": text, "threshold": threshold, "hours": hours}`. Вернуть `SimilarResponse` (или dataclass/dict) или `None` при ошибке. +- `add_submitted_post(text, post_id=None, rag_score=None)` — POST на `{api_url}/submitted`, body `{"text": text, "post_id": post_id, "rag_score": rag_score}`. При ошибке — логировать, не падать. + +Оба метода проверяют `self._enabled` и не делают запросы, если RAG отключён. + +##### 4.2.2. PostService (`helper_bot/handlers/private/services.py`) + +В `_process_post_background` и `_process_media_group_background`: + +**Порядок вызовов:** +1. Получить скоры (`_get_scores_with_error_handling`) — уже есть `rag_score`. +2. **Перед** отправкой: вызвать `find_similar_posts(original_raw_text, 0.9, 24)`. Если RAG недоступен или ошибка — не падать, пропустить. +3. Если `similar_count > 0`: добавить в `post_text` строку `\n\n⚠️ Похожий пост за последние 24ч (совпадение {max_similarity:.0%})`. +4. Отправить пост в группу модерации. +5. Сохранить в БД. +6. **После** успешной отправки: вызвать `add_submitted_post(original_raw_text, sent_message.message_id, rag_score)` — в фоне. `rag_score` из шага 1. + +**Важно:** Проверка similar — **до** добавления текущего поста в submitted. + +##### 4.2.3. Доступ к RagApiClient + +`RagApiClient` создаётся через `ScoringManager` или `BaseDependencyFactory`. PostService должен иметь доступ к `rag_client` (или `scoring_manager`). При необходимости добавить методы в `ScoringManager` как прокси к RAG. + +##### 4.2.4. Обработка ошибок + +При недоступности RAG — не падать, не добавлять предупреждение и не индексировать. + +--- + +#### 4.3. Порядок вызовов в боте + +```text +1. Пользователь отправляет пост +2. PostService._process_post_background: + a) Получить скоры (rag_score, confidence, ...) + b) find_similar_posts(text, 0.9, 24) — есть ли похожие? (возвращает count, text, similarity, rag_score) + c) Если да — добавить предупреждение в post_text + d) Отправить пост в группу модерации + e) Сохранить в БД (add_post) + f) add_submitted_post(text, message_id, rag_score) — индексировать в RAG +``` + +**Важно:** Проверка similar делается **до** добавления текущего поста в submitted, иначе пост будет похож сам на себя. + +--- + +#### 4.4. Затронутые файлы + +| Репозиторий | Файлы | +|-------------|-------| +| **rag-service** | `app/storage/vector_store.py`, `app/services/rag_service.py`, `app/api/routes.py`, `app/schemas.py`, `app/config.py`, `app/main.py` (описание в docs) | +| **telegram-helper-bot** | `helper_bot/services/scoring/rag_client.py`, `helper_bot/handlers/private/services.py` | + +--- + +### Этап 5: Авто-публикация/отклонение (задел на будущее) + +| Шаг | Действие | +|-----|----------| +| 5.1 | В `PostService._process_post_background`: после получения `rag_score` проверять пороги | +| 5.2 | Если `rag_score >= 0.8`: не показывать кнопки модерации, сразу публиковать (или вызывать логику publish) | +| 5.3 | Если `rag_score <= 0.4`: сразу отклонять (decline) | +| 5.4 | Добавить флаги в `.env` (например, `AUTO_PUBLISH_ENABLED`, `AUTO_DECLINE_ENABLED`) — по умолчанию `false` | +| 5.5 | Реализацию оформить как выключенную по умолчанию; включение — через конфиг | + +**Затронутые файлы:** +- `helper_bot/handlers/private/services.py` +- `helper_bot/config/` или `.env` + +--- + +### Этап 5.5: ML Scoring Статистика — восстановить полный вывод + +**Проблема:** Раньше «📊 ML Статистика» показывала детали (модель, device, кол-во примеров, размерность). Теперь только API URL и статус. + +**Причина:** Бот использует fallback (`get_stats_sync`) когда `RagApiClient.get_stats()` возвращает пустой результат. Это происходит при: +- 401/403 (ошибка авторизации) +- таймауте или ошибке соединения +- неверном формате ответа от API + +**Задачи:** + +| Шаг | Действие | +|-----|----------| +| 5.5.1 | Проверить, что RAG API `GET /stats` доступен с бота (сеть, CORS, API key). | +| 5.5.2 | Убедиться, что `RagApiClient.get_stats()` передаёт заголовок `X-API-Key` и корректно обрабатывает 200. | +| 5.5.3 | Проверить контракт ответа: RAG возвращает `model_name`, `model_loaded`, `device`, `vector_store` (positive_count, negative_count, total_count, vector_dim, max_examples). | +| 5.5.4 | При ошибке API — логировать причину (status, body) и при необходимости улучшить fallback-сообщение (например, «API недоступен: …»). | +| 5.5.5 | Добавить тесты для `get_ml_stats` с моком API (успешный ответ и fallback). | + +**Затронутые файлы:** +- `helper_bot/handlers/admin/admin_handlers.py` (get_ml_stats) +- `helper_bot/services/scoring/rag_client.py` (get_stats, get_stats_sync) +- `helper_bot/services/scoring/scoring_manager.py` (get_stats) + +--- + +### Этап 6: Тесты и качество кода + +| Шаг | Действие | +|-----|----------| +| 6.1 | Прогнать все тесты: `pytest tests/ -v` | +| 6.2 | `make code-quality` (или `isort`, `black`, `flake8`) | +| 6.3 | При необходимости обновить моки и фикстуры | + +--- + +### Этап 7: Release Notes и деплой + +| Шаг | Действие | +|-----|----------| +| 7.1 | Создать `docs/RELEASE_NOTES_DEV-N.md` по шаблону из `.cursor/rules/release-notes-template.md` | +| 7.2 | Коммиты в формате: `feat:`, `fix:`, `refactor:` | +| 7.3 | Push в `dev-N` → CI (тесты, code quality) | +| 7.4 | Создать/обновить PR в `main` | +| 7.5 | После мержа — деплой по `deploy.yml` (если настроен в prod) | + +--- + +## Зависимости между этапами + +``` +Этап 0 → Этап 1, 2, 3 (можно параллельно) +Этап 1, 2, 3 → Этап 6 +Этап 4 зависит от RAG (отдельный сервис) +Этап 5 можно делать после 4 или независимо +Этап 5.5 — независимо (можно параллельно с 1–3) +``` + +--- + +## Рекомендуемый порядок реализации + +1. Этап 0 — подготовка +2. Этапы 1, 2, 3 — независимо, можно в любом порядке +3. Этап 4 — после готовности RAG +4. Этап 5 — после 4 или параллельно с 1–3 +5. Этап 5.5 — разобраться с ML Scoring Статистикой (можно параллельно) +6. Этап 6 — перед PR +7. Этап 7 — после ревью и мержа + +--- + +## Ссылки на документацию проекта + +- `.cursor/rules/my-custom-rule.mdc` — общие правила +- `.cursor/rules/architecture.md` — архитектура +- `.cursor/rules/handlers-patterns.md` — паттерны handlers +- `.cursor/rules/release-notes-template.md` — шаблон Release Notes +- `prod/.cursor/rules/my-custom-rule.mdc` — CI/CD, ветки, деплой diff --git a/.cursor/prompt-stage-4-similar-posts.md b/.cursor/prompt-stage-4-similar-posts.md new file mode 100644 index 0000000..61b3b66 --- /dev/null +++ b/.cursor/prompt-stage-4-similar-posts.md @@ -0,0 +1,126 @@ +# Промпт: Реализация Этапа 4 — RAG сервис (похожие посты) + +Скопируй этот промпт нейросети для реализации фичи «похожие посты» в RAG сервисе. + +> Подключение к Telegram боту описано в `.cursor/implementation-plan-features.md` (Этап 4, раздел 4.2). + +--- + +## Задача + +Добавить в RAG сервис третью коллекцию для submitted-постов. Endpoints: +- `POST /similar` — поиск похожих постов за N часов (threshold, text) +- `POST /submitted` — добавление поста в коллекцию (для индексации ботом) + +--- + +## Контекст + +**Путь:** `/Users/andrejkatyhin/Work/PycharmProjects/rag-service` + +RAG уже имеет: +- `VectorStore` с `_positive_vectors` / `_negative_vectors` — для модерации (score) +- `RAGService` с `get_embedding`, `calculate_score`, `add_positive_example`, `add_negative_example` +- API: `POST /score`, `POST /examples/positive`, `POST /examples/negative`, `GET /stats` + +Нужно добавить **третью коллекцию** для submitted-постов (с `created_at`, `text`, `rag_score`). + +--- + +## 1. VectorStore (`app/storage/vector_store.py`) + +Добавь коллекцию submitted: + +```python +self._submitted_vectors: list = [] +self._submitted_hashes: list = [] +self._submitted_created_at: list = [] # Unix timestamps +self._submitted_post_ids: list = [] +self._submitted_texts: list = [] +self._submitted_rag_scores: list = [] +``` + +**Методы:** +- `add_submitted(vector, text_hash, created_at, post_id=None, text="", rag_score=None)` — добавить пост. FIFO при превышении `max_submitted` (новый параметр в конструкторе, default 5000). +- `find_similar_submitted(vector, threshold, hours)` — вернуть `List[dict]` с полями `similarity`, `created_at`, `post_id`, `text`, `rag_score`. Фильтр: `created_at >= now - hours*3600`. Сравнение через `np.dot` (как для positive/negative). Только те, где similarity >= threshold. + +**Персистентность:** Сохранять/загружать submitted в отдельный файл (например, `submitted_embeddings.npz`). Поля: `vectors`, `hashes`, `created_at`, `post_ids`, `texts`, `rag_scores`. Используй `np.array(..., dtype=object)` для строк и `allow_pickle=True` при необходимости. + +--- + +## 2. Config (`app/config.py`) + +Добавь: +- `RAG_MAX_SUBMITTED` (default 5000) +- `RAG_SUBMITTED_PATH` (default `data/vectors/submitted.npz`) + +--- + +## 3. RAGService (`app/services/rag_service.py`) + +- `add_submitted_post(text, post_id=None, rag_score=None)` — очистить текст, получить embedding, вызвать `vector_store.add_submitted` с `created_at=int(time.time())`, `text`, `rag_score`. +- `find_similar_posts(text, threshold=0.9, hours=24)` — получить embedding, вызвать `vector_store.find_similar_submitted`, вернуть результат. + +--- + +## 4. Схемы (`app/schemas.py`) + +```python +class SimilarRequest(BaseModel): + text: str = Field(..., min_length=1) + threshold: float = Field(default=0.9, ge=0.0, le=1.0) + hours: int = Field(default=24, ge=1, le=168) + +class SimilarPostItem(BaseModel): + similarity: float + created_at: int + post_id: Optional[int] = None + text: str + rag_score: Optional[float] = None + +class SimilarResponse(BaseModel): + similar_count: int + similar_posts: List[SimilarPostItem] + +class SubmittedRequest(BaseModel): + text: str = Field(..., min_length=1) + post_id: Optional[int] = None + rag_score: Optional[float] = None + +class SubmittedResponse(BaseModel): + success: bool + message: str + submitted_count: int +``` + +--- + +## 5. API (`app/api/routes.py`) + +- `POST /api/v1/similar` — принять `SimilarRequest`, вызвать `service.find_similar_posts`, вернуть `SimilarResponse`. +- `POST /api/v1/submitted` — принять `SubmittedRequest`, вызвать `service.add_submitted_post`, вернуть `SubmittedResponse`. + +--- + +## 6. Автосохранение + +В `autosave_loop` или при `save_vectors` — сохранять submitted-коллекцию. При загрузке — загружать submitted из файла в `_load_from_disk` или отдельном методе. + +--- + +## Требования + +- Не ломать существующий функционал: score, examples, stats работают как раньше. +- Следовать стилю кода проекта (Black, isort, type hints). +- Добавить тесты для новых методов и endpoints. + +--- + +## Файлы для изменения + +- `app/storage/vector_store.py` +- `app/services/rag_service.py` +- `app/api/routes.py` +- `app/schemas.py` +- `app/config.py` +- `app/main.py` (при необходимости — lifespan для autosave submitted) diff --git a/database/async_db.py b/database/async_db.py index 39bdf94..09ae147 100644 --- a/database/async_db.py +++ b/database/async_db.py @@ -279,6 +279,34 @@ class AsyncBotDB: """Получает тексты отклоненных постов для обучения RAG.""" return await self.factory.posts.get_declined_posts_texts(limit) + async def get_user_posts_stats(self, user_id: int) -> Tuple[int, int, int]: + """ + Получает статистику постов пользователя. + + Returns: + Tuple (approved_count, declined_count, suggest_count) + """ + return await self.factory.posts.get_user_posts_stats(user_id) + + async def get_last_post_by_author(self, user_id: int) -> Optional[str]: + """Получает текст последнего поста пользователя.""" + return await self.factory.posts.get_last_post_by_author(user_id) + + async def get_user_ban_count(self, user_id: int) -> int: + """Получает количество банов пользователя за все время.""" + return await self.factory.blacklist_history.get_ban_count(user_id) + + async def get_last_ban_info( + self, user_id: int + ) -> Optional[Tuple[int, str, Optional[int]]]: + """ + Получает информацию о последнем бане пользователя. + + Returns: + Tuple (date_ban, reason, date_unban) или None + """ + return await self.factory.blacklist_history.get_last_ban_info(user_id) + # Методы для работы с черным списком async def set_user_blacklist( self, @@ -361,7 +389,8 @@ class AsyncBotDB: """Возвращает список пользователей в черном списке с учетом смещения и ограничения.""" users = await self.factory.blacklist.get_all_users(offset, limit) return [ - (user.user_id, user.message_for_user, user.date_to_unban) for user in users + (user.user_id, user.message_for_user, user.date_to_unban, user.created_at) + for user in users ] async def get_blacklist_users_by_id(self, user_id: int) -> Optional[tuple]: diff --git a/database/repositories/blacklist_history_repository.py b/database/repositories/blacklist_history_repository.py index 54685ea..6ee809d 100644 --- a/database/repositories/blacklist_history_repository.py +++ b/database/repositories/blacklist_history_repository.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Optional, Tuple from database.base import DatabaseConnection from database.models import BlacklistHistoryRecord @@ -120,3 +120,55 @@ class BlacklistHistoryRepository(DatabaseConnection): f"Ошибка обновления даты разбана в истории для user_id={user_id}: {str(e)}" ) return False + + async def get_ban_count(self, user_id: int) -> int: + """ + Получает количество банов пользователя за все время. + + Args: + user_id: ID пользователя + + Returns: + Количество банов + """ + query = "SELECT COUNT(*) FROM blacklist_history WHERE user_id = ?" + rows = await self._execute_query_with_result(query, (user_id,)) + row = rows[0] if rows else None + + count = row[0] if row else 0 + self.logger.info(f"Количество банов для user_id={user_id}: {count}") + return count + + async def get_last_ban_info( + self, user_id: int + ) -> Optional[Tuple[int, str, Optional[int]]]: + """ + Получает информацию о последнем бане пользователя. + + Args: + user_id: ID пользователя + + Returns: + Tuple (date_ban, reason, date_unban) или None, если банов не было + """ + query = """ + SELECT date_ban, reason, date_unban FROM blacklist_history + WHERE user_id = ? + ORDER BY date_ban DESC + LIMIT 1 + """ + rows = await self._execute_query_with_result(query, (user_id,)) + row = rows[0] if rows else None + + if row: + date_ban = row[0] + reason = row[1] + date_unban = row[2] + self.logger.info( + f"Последний бан для user_id={user_id}: " + f"date_ban={date_ban}, reason={reason}, date_unban={date_unban}" + ) + return (date_ban, reason, date_unban) + + self.logger.info(f"Банов для user_id={user_id} не найдено") + return None diff --git a/database/repositories/blacklist_repository.py b/database/repositories/blacklist_repository.py index f8d275e..97d2957 100644 --- a/database/repositories/blacklist_repository.py +++ b/database/repositories/blacklist_repository.py @@ -87,13 +87,14 @@ class BlacklistRepository(DatabaseConnection): async def get_all_users( self, offset: int = 0, limit: int = 10 ) -> List[BlacklistUser]: - """Возвращает список пользователей в черном списке.""" + """Возвращает список пользователей в черном списке, отсортированных по дате бана (новые первые).""" query = """ SELECT user_id, message_for_user, date_to_unban, created_at, ban_author FROM blacklist - LIMIT ?, ? + ORDER BY created_at DESC + LIMIT ? OFFSET ? """ - rows = await self._execute_query_with_result(query, (offset, limit)) + rows = await self._execute_query_with_result(query, (limit, offset)) users = [] for row in rows: @@ -113,10 +114,11 @@ class BlacklistRepository(DatabaseConnection): return users async def get_all_users_no_limit(self) -> List[BlacklistUser]: - """Возвращает список всех пользователей в черном списке без лимитов.""" + """Возвращает список всех пользователей в черном списке без лимитов, отсортированных по дате бана (новые первые).""" query = """ SELECT user_id, message_for_user, date_to_unban, created_at, ban_author FROM blacklist + ORDER BY created_at DESC """ rows = await self._execute_query_with_result(query) diff --git a/database/repositories/post_repository.py b/database/repositories/post_repository.py index 37cdea0..f645e67 100644 --- a/database/repositories/post_repository.py +++ b/database/repositories/post_repository.py @@ -545,3 +545,67 @@ class PostRepository(DatabaseConnection): texts = [row[0] for row in rows if row[0]] self.logger.info(f"Получено {len(texts)} отклоненных постов для обучения") return texts + + async def get_user_posts_stats(self, user_id: int) -> Tuple[int, int, int]: + """ + Получает статистику постов пользователя. + + Args: + user_id: ID пользователя + + Returns: + Tuple (approved_count, declined_count, suggest_count) + """ + query = """ + SELECT + SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END) as approved, + SUM(CASE WHEN status = 'declined' THEN 1 ELSE 0 END) as declined, + SUM(CASE WHEN status = 'suggest' THEN 1 ELSE 0 END) as suggest + FROM post_from_telegram_suggest + WHERE author_id = ? AND text != '^' + """ + rows = await self._execute_query_with_result(query, (user_id,)) + row = rows[0] if rows else None + + if row: + approved = row[0] or 0 + declined = row[1] or 0 + suggest = row[2] or 0 + self.logger.info( + f"Статистика постов для user_id={user_id}: " + f"approved={approved}, declined={declined}, suggest={suggest}" + ) + return (approved, declined, suggest) + + return (0, 0, 0) + + async def get_last_post_by_author(self, user_id: int) -> Optional[str]: + """ + Получает текст последнего поста пользователя. + + Args: + user_id: ID пользователя + + Returns: + Текст последнего поста или None, если постов нет + """ + query = """ + SELECT text FROM post_from_telegram_suggest + WHERE author_id = ? AND text IS NOT NULL AND text != '' AND text != '^' + ORDER BY created_at DESC + LIMIT 1 + """ + rows = await self._execute_query_with_result(query, (user_id,)) + row = rows[0] if rows else None + + if row: + text = row[0] + self.logger.info( + f"Последний пост для user_id={user_id}: '{text[:50]}...'" + if len(text) > 50 + else f"Последний пост для user_id={user_id}: '{text}'" + ) + return text + + self.logger.info(f"Постов для user_id={user_id} не найдено") + return None diff --git a/helper_bot/handlers/admin/admin_handlers.py b/helper_bot/handlers/admin/admin_handlers.py index 66d0519..c5cd1de 100644 --- a/helper_bot/handlers/admin/admin_handlers.py +++ b/helper_bot/handlers/admin/admin_handlers.py @@ -138,7 +138,7 @@ async def get_banned_users( keyboard = create_keyboard_with_pagination( 1, len(buttons_list), buttons_list, "unlock" ) - await message.answer(text=message_text, reply_markup=keyboard) + await message.answer(text=message_text, reply_markup=keyboard, parse_mode="HTML") else: await message.answer( text="В списке заблокированных пользователей никого нет" @@ -216,9 +216,15 @@ async def get_ml_stats(message: types.Message, state: FSMContext, **kwargs): # Fallback на синхронные данные (если API недоступен) lines.append(f" • API URL: {rag.get('api_url', 'N/A')}") if "enabled" in rag: - lines.append( - f" • Статус: {'✅ Включен' if rag.get('enabled') else '❌ Отключен'}" - ) + if rag.get("enabled"): + lines.append( + f" • Статус: ⚠️ Включен, но API не отвечает" + ) + lines.append( + f" • Проверьте доступность сервиса и API ключ" + ) + else: + lines.append(f" • Статус: ❌ Отключен") lines.append("") diff --git a/helper_bot/handlers/callback/callback_handlers.py b/helper_bot/handlers/callback/callback_handlers.py index d156184..f652ceb 100644 --- a/helper_bot/handlers/callback/callback_handlers.py +++ b/helper_bot/handlers/callback/callback_handlers.py @@ -255,6 +255,8 @@ async def change_page(call: CallbackQuery, bot_db: MagicData("bot_db"), **kwargs logger.info(f"Переход на страницу {page_number}") + items_per_page = 9 + if call.message.text == "Список пользователей которые последними обращались к боту": list_users = await bot_db.get_last_users(30) keyboard = create_keyboard_with_pagination( @@ -266,11 +268,13 @@ async def change_page(call: CallbackQuery, bot_db: MagicData("bot_db"), **kwargs reply_markup=keyboard, ) else: - message_user = await get_banned_users_list(int(page_number) * 7 - 7, bot_db) + offset = (page_number - 1) * items_per_page + message_user = await get_banned_users_list(offset, bot_db) await call.bot.edit_message_text( chat_id=call.message.chat.id, message_id=call.message.message_id, text=message_user, + parse_mode="HTML", ) buttons = await get_banned_users_buttons(bot_db) diff --git a/helper_bot/handlers/callback/services.py b/helper_bot/handlers/callback/services.py index f9aff0d..910d852 100644 --- a/helper_bot/handlers/callback/services.py +++ b/helper_bot/handlers/callback/services.py @@ -8,7 +8,7 @@ from aiogram.types import CallbackQuery from helper_bot.keyboards.keyboards import create_keyboard_for_ban_reason from helper_bot.utils.helper_func import ( delete_user_blacklist, - get_text_message, + get_publish_text, send_audio_message, send_media_group_to_channel, send_photo_message, @@ -137,7 +137,7 @@ class PostPublishService: raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных") # Формируем финальный текст с учетом is_anonymous - formatted_text = get_text_message( + formatted_text = get_publish_text( raw_text, user.first_name, user.username, is_anonymous ) @@ -188,7 +188,7 @@ class PostPublishService: raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных") # Формируем финальный текст с учетом is_anonymous - formatted_text = get_text_message( + formatted_text = get_publish_text( raw_text, user.first_name, user.username, is_anonymous ) @@ -247,7 +247,7 @@ class PostPublishService: raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных") # Формируем финальный текст с учетом is_anonymous - formatted_text = get_text_message( + formatted_text = get_publish_text( raw_text, user.first_name, user.username, is_anonymous ) @@ -340,7 +340,7 @@ class PostPublishService: raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных") # Формируем финальный текст с учетом is_anonymous - formatted_text = get_text_message( + formatted_text = get_publish_text( raw_text, user.first_name, user.username, is_anonymous ) @@ -452,7 +452,7 @@ class PostPublishService: f"Пользователь {author_id} не найден в базе данных" ) - formatted_text = get_text_message( + formatted_text = get_publish_text( raw_text, user.first_name, user.username, is_anonymous ) @@ -838,7 +838,7 @@ class BanService: await self.db.set_user_blacklist( user_id=author_id, user_name=None, - message_for_user="Спам", + message_for_user="Последний пост", date_to_unban=date_to_unban, ban_author=ban_author_id, ) diff --git a/helper_bot/handlers/private/private_handlers.py b/helper_bot/handlers/private/private_handlers.py index c8d0f8b..3f47aa2 100644 --- a/helper_bot/handlers/private/private_handlers.py +++ b/helper_bot/handlers/private/private_handlers.py @@ -291,12 +291,33 @@ class PrivateHandlers: """Handle messages in admin chat states""" # User service operations with metrics await self.user_service.update_user_activity(message.from_user.id) - await message.forward(chat_id=self.settings.group_for_message) + + # Формируем обогащённое сообщение для админов + user_id = message.from_user.id + full_name = message.from_user.full_name + username = message.from_user.username + message_text = message.text or "" + + enriched_message = await self.user_service.format_user_message_for_admins( + user_id=user_id, + full_name=full_name, + username=username, + message_text=message_text, + ) + + # Отправляем обогащённое сообщение вместо forward + sent_message = await message.bot.send_message( + chat_id=self.settings.group_for_message, + text=enriched_message, + parse_mode="HTML", + ) current_date = datetime.now() date = int(current_date.timestamp()) + + # Сохраняем message_id из результата send_message await self.db.add_message( - message.text, message.from_user.id, message.message_id + 1, date + message.text, message.from_user.id, sent_message.message_id, date ) question = messages.get_message(get_first_name(message), "QUESTION") diff --git a/helper_bot/handlers/private/services.py b/helper_bot/handlers/private/services.py index b369484..3a6f1fa 100644 --- a/helper_bot/handlers/private/services.py +++ b/helper_bot/handlers/private/services.py @@ -156,6 +156,92 @@ class UserService: username = message.from_user.username or "Без никнейма" return html.escape(full_name), html.escape(username) + async def format_user_message_for_admins( + self, user_id: int, full_name: str, username: str, message_text: str + ) -> str: + """ + Форматирует сообщение пользователя для отправки админам с обогащёнными данными. + + Args: + user_id: ID пользователя + full_name: Полное имя пользователя + username: Username пользователя (может быть None) + message_text: Текст сообщения пользователя + + Returns: + Отформатированное сообщение для админов + """ + safe_full_name = html.escape(full_name) if full_name else "Неизвестный пользователь" + safe_username = html.escape(username) if username else None + safe_message_text = html.escape(message_text) if message_text else "" + + # Формируем строку с информацией об авторе + if safe_username: + author_info = f"{safe_full_name} (@{safe_username})" + else: + author_info = f"{safe_full_name} (Ник не указан)" + + # Получаем статистику постов + approved, declined, suggest = await self.db.get_user_posts_stats(user_id) + total_posts = approved + declined + suggest + + # Получаем последний пост + last_post = await self.db.get_last_post_by_author(user_id) + if last_post: + if len(last_post) > 80: + last_post_display = f'"{html.escape(last_post[:80])}..."' + else: + last_post_display = f'"{html.escape(last_post)}"' + else: + last_post_display = "Нет постов" + + # Получаем дату регистрации + user_info = await self.db.get_user_by_id(user_id) + if user_info and user_info.date_added: + date_added = datetime.fromtimestamp(user_info.date_added).strftime("%d.%m.%Y") + else: + date_added = "Неизвестно" + + # Получаем информацию о банах + ban_count = await self.db.get_user_ban_count(user_id) + ban_section = "" + if ban_count > 0: + last_ban = await self.db.get_last_ban_info(user_id) + if last_ban: + date_ban, reason, date_unban = last_ban + ban_date_str = datetime.fromtimestamp(date_ban).strftime("%d.%m.%Y") + reason_display = html.escape(reason) if reason else "Не указана" + + if date_unban: + unban_date_str = datetime.fromtimestamp(date_unban).strftime( + "%d.%m.%Y %H:%M" + ) + last_ban_info = ( + f" Последний: {ban_date_str}, причина «{reason_display}», " + f"истёк {unban_date_str}" + ) + else: + last_ban_info = ( + f" Последний: {ban_date_str}, причина «{reason_display}», " + f"активен" + ) + + ban_section = f"\n\n🚫 Банов: {ban_count}\n{last_ban_info}" + + # Формируем итоговое сообщение + formatted_message = ( + f"👤 От: {author_info} | ID: {user_id}\n\n" + f"📊 Постов в базе: {total_posts}\n" + f"📝 Последний пост: {last_post_display}\n" + f"📅 В боте с: {date_added}" + f"{ban_section}\n\n" + f"---\n" + f"Сообщение пользователя:\n\n" + f"{safe_message_text}" + ) + + return formatted_message + class PostService: """Service for post-related operations""" @@ -236,6 +322,18 @@ class PostService: f"PostService: Ошибка сохранения скоров для {message_id}: {e}" ) + async def _add_submitted_post_background( + self, text: str, post_id: int, rag_score: float = None + ) -> None: + """Индексирует пост в RAG submitted collection в фоне.""" + try: + if self.scoring_manager: + await self.scoring_manager.add_submitted_post(text, post_id, rag_score) + except Exception as e: + logger.warning( + f"PostService: Ошибка добавления поста в submitted: {e}" + ) + async def _get_scores_with_error_handling(self, text: str) -> tuple: """ Получает скоры для текста поста с обработкой ошибок. @@ -321,6 +419,37 @@ class PostService: error_message, ) = await self._get_scores_with_error_handling(original_raw_text) + # Проверяем похожие посты (до добавления текущего в submitted) + similar_warning = "" + if self.scoring_manager and original_raw_text and original_raw_text.strip(): + try: + similar_result = await self.scoring_manager.find_similar_posts( + original_raw_text, threshold=0.9, hours=24 + ) + if similar_result and similar_result.similar_count > 0: + # Формируем предупреждение с текстом похожего поста + similar_text = "" + if similar_result.similar_posts: + first_similar = similar_result.similar_posts[0] + if first_similar.text: + truncated_text = first_similar.text[:150] + if len(first_similar.text) > 150: + truncated_text += "..." + similar_text = f'\nТекст поста:\n"{html.escape(truncated_text)}"' + + similar_warning = ( + f"\n\n⚠️ Похожий пост за последние 24ч " + f"(совпадение {similar_result.max_similarity:.0%})" + f"{similar_text}" + ) + logger.info( + f"PostService: Найден похожий пост для message_id={message.message_id}, " + f"similar_count={similar_result.similar_count}, " + f"max_similarity={similar_result.max_similarity:.2%}" + ) + except Exception as e: + logger.warning(f"PostService: Ошибка поиска похожих постов: {e}") + # Формируем текст для поста (с сообщением об ошибке если есть) text_for_post = original_raw_text if error_message: @@ -347,9 +476,11 @@ class PostService: message.from_user.username, deepseek_score=deepseek_score, rag_score=rag_score, - rag_confidence=rag_confidence, - rag_score_pos_only=rag_score_pos_only, + user_id=message.from_user.id, ) + # Добавляем предупреждение о похожем посте + if similar_warning: + post_text += similar_warning # Определяем анонимность по исходному тексту (без сообщения об ошибке) is_anonymous = determine_anonymity(original_raw_text) @@ -401,8 +532,11 @@ class PostService: markup, ) elif content_type == "media_group": + # Добавляем предупреждение о похожем посте в caption медиагруппы + if similar_warning: + post_text += similar_warning # Для медиагруппы используем специальную обработку - # Передаем ml_scores_json для сохранения в БД + # Передаем ml_scores_json и rag_score для сохранения в БД await self._process_media_group_background( message, album, @@ -411,6 +545,7 @@ class PostService: is_anonymous, original_raw_text, ml_scores_json, + rag_score, ) return else: @@ -448,6 +583,14 @@ class PostService: ) ) + # Индексируем пост в RAG submitted collection (после успешной отправки) + if self.scoring_manager and original_raw_text and original_raw_text.strip(): + asyncio.create_task( + self._add_submitted_post_background( + original_raw_text, sent_message.message_id, rag_score + ) + ) + except Exception as e: logger.error( f"PostService: Критическая ошибка в _process_post_background для {content_type}: {e}" @@ -462,6 +605,7 @@ class PostService: is_anonymous: bool, original_raw_text: str, ml_scores_json: str = None, + rag_score: float = None, ) -> None: """Обрабатывает медиагруппу в фоне""" try: @@ -495,6 +639,14 @@ class PostService: self._save_scores_background(main_post_id, ml_scores_json) ) + # Индексируем пост в RAG submitted collection + if self.scoring_manager and original_raw_text and original_raw_text.strip(): + asyncio.create_task( + self._add_submitted_post_background( + original_raw_text, main_post_id, rag_score + ) + ) + for msg_id in media_group_message_ids: await self.db.add_message_link(main_post_id, msg_id) @@ -552,8 +704,7 @@ class PostService: message.from_user.username, deepseek_score=deepseek_score, rag_score=rag_score, - rag_confidence=rag_confidence, - rag_score_pos_only=rag_score_pos_only, + user_id=message.from_user.id, ) markup = get_reply_keyboard_for_post() @@ -611,8 +762,7 @@ class PostService: message.from_user.username, deepseek_score=deepseek_score, rag_score=rag_score, - rag_confidence=rag_confidence, - rag_score_pos_only=rag_score_pos_only, + user_id=message.from_user.id, ) markup = get_reply_keyboard_for_post() @@ -677,8 +827,7 @@ class PostService: message.from_user.username, deepseek_score=deepseek_score, rag_score=rag_score, - rag_confidence=rag_confidence, - rag_score_pos_only=rag_score_pos_only, + user_id=message.from_user.id, ) markup = get_reply_keyboard_for_post() @@ -770,8 +919,7 @@ class PostService: message.from_user.username, deepseek_score=deepseek_score, rag_score=rag_score, - rag_confidence=rag_confidence, - rag_score_pos_only=rag_score_pos_only, + user_id=message.from_user.id, ) markup = get_reply_keyboard_for_post() @@ -869,8 +1017,7 @@ class PostService: message.from_user.username, deepseek_score=deepseek_score, rag_score=rag_score, - rag_confidence=rag_confidence, - rag_score_pos_only=rag_score_pos_only, + user_id=message.from_user.id, ) is_anonymous = determine_anonymity(raw_caption) diff --git a/helper_bot/services/scoring/rag_client.py b/helper_bot/services/scoring/rag_client.py index 513a929..4ebb4ea 100644 --- a/helper_bot/services/scoring/rag_client.py +++ b/helper_bot/services/scoring/rag_client.py @@ -4,7 +4,8 @@ HTTP клиент для взаимодействия с внешним RAG се Использует REST API для получения скоров и отправки примеров. """ -from typing import Any, Dict, Optional +from dataclasses import dataclass +from typing import Any, Dict, List, Optional import httpx @@ -15,6 +16,30 @@ from .base import ScoringResult from .exceptions import InsufficientExamplesError, ScoringError, TextTooShortError +@dataclass +class SimilarPost: + """Данные о похожем посте.""" + + similarity: float + created_at: int + post_id: Optional[int] + text: str + rag_score: Optional[float] + + +@dataclass +class SimilarPostsResult: + """Результат поиска похожих постов.""" + + similar_count: int + similar_posts: List[SimilarPost] + max_similarity: float = 0.0 + + def __post_init__(self): + if self.similar_posts: + self.max_similarity = max(p.similarity for p in self.similar_posts) + + class RagApiClient: """ HTTP клиент для взаимодействия с внешним RAG сервисом. @@ -329,21 +354,39 @@ class RagApiClient: Словарь со статистикой или пустой словарь при ошибке """ if not self._enabled: + logger.debug("RagApiClient: get_stats пропущен - клиент отключен") return {} try: + logger.debug(f"RagApiClient: Запрос статистики от {self.api_url}/stats") response = await self._client.get(f"{self.api_url}/stats") if response.status_code == 200: - return response.json() + data = response.json() + logger.info( + f"RagApiClient: Статистика получена успешно: " + f"model_loaded={data.get('model_loaded')}, " + f"model_name={data.get('model_name')}, " + f"vector_store={data.get('vector_store', {}).get('total_count', 'N/A')} примеров" + ) + return data + elif response.status_code == 401 or response.status_code == 403: + logger.warning( + f"RagApiClient: Ошибка авторизации при получении статистики: " + f"status={response.status_code}, body={response.text[:200]}" + ) + return {} else: logger.warning( - f"RagApiClient: Неожиданный статус при получении статистики: {response.status_code}" + f"RagApiClient: Неожиданный статус при получении статистики: " + f"status={response.status_code}, body={response.text[:200]}" ) return {} except httpx.TimeoutException: - logger.warning(f"RagApiClient: Таймаут при получении статистики") + logger.warning( + f"RagApiClient: Таймаут при получении статистики (timeout={self.timeout}s)" + ) return {} except httpx.RequestError as e: logger.warning( @@ -365,3 +408,135 @@ class RagApiClient: "api_url": self.api_url, "timeout": self.timeout, } + + @track_time("find_similar_posts", "rag_client") + async def find_similar_posts( + self, text: str, threshold: float = 0.9, hours: int = 24 + ) -> Optional[SimilarPostsResult]: + """ + Ищет похожие посты за последние N часов. + + Args: + text: Текст поста для поиска похожих + threshold: Порог схожести (0.0-1.0), по умолчанию 0.9 + hours: За сколько часов искать (1-168), по умолчанию 24 + + Returns: + SimilarPostsResult с информацией о похожих постах или None при ошибке + """ + if not self._enabled: + return None + + if not text or not text.strip(): + return None + + try: + response = await self._client.post( + f"{self.api_url}/similar", + json={"text": text.strip(), "threshold": threshold, "hours": hours}, + ) + + if response.status_code == 200: + data = response.json() + similar_posts = [] + + for post_data in data.get("similar_posts", []): + similar_posts.append( + SimilarPost( + similarity=float(post_data.get("similarity", 0.0)), + created_at=int(post_data.get("created_at", 0)), + post_id=post_data.get("post_id"), + text=post_data.get("text", ""), + rag_score=post_data.get("rag_score"), + ) + ) + + result = SimilarPostsResult( + similar_count=data.get("similar_count", 0), + similar_posts=similar_posts, + ) + + if result.similar_count > 0: + logger.info( + f"RagApiClient: Найдено {result.similar_count} похожих постов " + f"(max_similarity={result.max_similarity:.2%})" + ) + + return result + else: + logger.warning( + f"RagApiClient: Неожиданный статус при поиске похожих постов: " + f"{response.status_code}, body: {response.text}" + ) + return None + + except httpx.TimeoutException: + logger.warning("RagApiClient: Таймаут при поиске похожих постов") + return None + except httpx.RequestError as e: + logger.warning( + f"RagApiClient: Ошибка подключения при поиске похожих постов: {e}" + ) + return None + except Exception as e: + logger.error(f"RagApiClient: Ошибка поиска похожих постов: {e}") + return None + + @track_time("add_submitted_post", "rag_client") + async def add_submitted_post( + self, text: str, post_id: Optional[int] = None, rag_score: Optional[float] = None + ) -> bool: + """ + Добавляет пост в коллекцию submitted для поиска похожих. + + Args: + text: Текст поста + post_id: ID поста (опционально) + rag_score: RAG скор на момент добавления (опционально) + + Returns: + True если пост успешно добавлен + """ + if not self._enabled: + return False + + if not text or not text.strip(): + return False + + try: + payload = {"text": text.strip()} + if post_id is not None: + payload["post_id"] = post_id + if rag_score is not None: + payload["rag_score"] = rag_score + + response = await self._client.post( + f"{self.api_url}/submitted", + json=payload, + ) + + if response.status_code in (200, 201): + data = response.json() + logger.debug( + f"RagApiClient: Пост добавлен в submitted " + f"(post_id={post_id}, submitted_count={data.get('submitted_count', 'N/A')})" + ) + return True + else: + logger.warning( + f"RagApiClient: Неожиданный статус при добавлении в submitted: " + f"{response.status_code}" + ) + return False + + except httpx.TimeoutException: + logger.warning("RagApiClient: Таймаут при добавлении в submitted") + return False + except httpx.RequestError as e: + logger.warning( + f"RagApiClient: Ошибка подключения при добавлении в submitted: {e}" + ) + return False + except Exception as e: + logger.error(f"RagApiClient: Ошибка добавления в submitted: {e}") + return False diff --git a/helper_bot/services/scoring/scoring_manager.py b/helper_bot/services/scoring/scoring_manager.py index 6761176..8a332c8 100644 --- a/helper_bot/services/scoring/scoring_manager.py +++ b/helper_bot/services/scoring/scoring_manager.py @@ -221,3 +221,43 @@ class ScoringManager: stats["deepseek"] = self.deepseek_service.get_stats() return stats + + @track_time("find_similar_posts", "scoring_manager") + async def find_similar_posts( + self, text: str, threshold: float = 0.9, hours: int = 24 + ): + """ + Ищет похожие посты через RAG API. + + Args: + text: Текст для поиска похожих + threshold: Порог схожести (0.0-1.0) + hours: За сколько часов искать + + Returns: + SimilarPostsResult или None + """ + if not self.rag_client or not self.rag_client.is_enabled: + return None + + return await self.rag_client.find_similar_posts(text, threshold, hours) + + @track_time("add_submitted_post", "scoring_manager") + async def add_submitted_post( + self, text: str, post_id: Optional[int] = None, rag_score: Optional[float] = None + ) -> bool: + """ + Добавляет пост в коллекцию submitted для поиска похожих. + + Args: + text: Текст поста + post_id: ID поста (опционально) + rag_score: RAG скор на момент добавления (опционально) + + Returns: + True если успешно добавлен + """ + if not self.rag_client or not self.rag_client.is_enabled: + return False + + return await self.rag_client.add_submitted_post(text, post_id, rag_score) diff --git a/helper_bot/utils/helper_func.py b/helper_bot/utils/helper_func.py index 39aab76..6a677c1 100644 --- a/helper_bot/utils/helper_func.py +++ b/helper_bot/utils/helper_func.py @@ -138,6 +138,52 @@ def determine_anonymity(post_text: str) -> bool: return False +def get_publish_text( + post_text: str, + first_name: str, + username: str = None, + is_anonymous: Optional[bool] = None, +) -> str: + """ + Форматирует текст для финальной публикации в канал. + Только текст поста + подпись автора или анон. + + Args: + post_text: Текст сообщения + first_name: Имя автора поста + username: Юзернейм автора поста (может быть None) + is_anonymous: Флаг анонимности (True - анонимно, False - не анонимно, None - legacy) + + Returns: + str: Текст для публикации в канал + """ + safe_post_text = post_text or "" + safe_first_name = first_name or "Пользователь" + + # Формируем строку с информацией об авторе + if username: + author_info = f"{safe_first_name} @{username}" + else: + author_info = f"{safe_first_name}" + + # Определяем анонимность и формируем финальный текст + if is_anonymous is not None: + if is_anonymous: + final_text = f"{safe_post_text}\n\nПост опубликован анонимно" + else: + final_text = f"{safe_post_text}\n\nАвтор поста: {author_info}" + else: + # Legacy: определяем по тексту + if "неанон" in post_text.lower() or "не анон" in post_text.lower(): + final_text = f"{safe_post_text}\n\nАвтор поста: {author_info}" + elif "анон" in post_text.lower(): + final_text = f"{safe_post_text}\n\nПост опубликован анонимно" + else: + final_text = f"{safe_post_text}\n\nАвтор поста: {author_info}" + + return final_text + + def get_text_message( post_text: str, first_name: str, @@ -147,10 +193,10 @@ def get_text_message( rag_score: Optional[float] = None, rag_confidence: Optional[float] = None, rag_score_pos_only: Optional[float] = None, + user_id: Optional[int] = None, ): """ - Форматирует текст сообщения для публикации в зависимости от наличия ключевых слов "анон" и "неанон" - или переданного параметра is_anonymous. + Форматирует текст сообщения для модерации (с полной информацией об авторе и скорами). Args: post_text: Текст сообщения @@ -161,64 +207,69 @@ def get_text_message( rag_score: Скор от RAG/ruBERT neg/pos (0.0-1.0, опционально) rag_confidence: Уверенность RAG модели (0.0-1.0, зависит от количества примеров) rag_score_pos_only: Скор RAG только по положительным примерам (0.0-1.0, опционально) + user_id: ID пользователя Telegram (опционально) Returns: - str: - Сформированный текст сообщения. + str: - Сформированный текст сообщения для модерации. """ # Экранируем post_text для безопасного использования в HTML safe_post_text = html.escape(str(post_text)) if post_text else "" # Экранируем username для безопасного использования в HTML safe_username = html.escape(username) if username else None + safe_first_name = html.escape(first_name) if first_name else "Пользователь" - # Формируем строку с информацией об авторе + # Формируем шапку с информацией об авторе if safe_username: - author_info = f"{first_name} @{safe_username}" + header = f"👤 От: {safe_first_name} (@{safe_username})" else: - author_info = f"{first_name} (Ник не указан)" + header = f"👤 От: {safe_first_name} (Ник не указан)" - # Формируем базовый текст - # Если передан is_anonymous, используем его, иначе определяем по тексту (legacy) + if user_id: + header += f" | ID: {user_id}" + + # Формируем строку с информацией об авторе для подвала + if safe_username: + author_info = f"{safe_first_name} @{safe_username}" + else: + author_info = f"{safe_first_name} (Ник не указан)" + + # Формируем блок с текстом поста + separator = "=" * 32 + post_block = f"{header}\nТекст поста:\n{separator}\n{safe_post_text}" + + # Определяем анонимность и формируем подвал if is_anonymous is not None: if is_anonymous: - final_text = f"{safe_post_text}\n\nПост опубликован анонимно" + post_block += f"\n\nПост опубликован анонимно" else: - final_text = f"{safe_post_text}\n\nАвтор поста: {author_info}" + post_block += f"\n\nАвтор поста: {author_info}" else: # Legacy: определяем по тексту if "неанон" in post_text or "не анон" in post_text: - final_text = f"{safe_post_text}\n\nАвтор поста: {author_info}" + post_block += f"\n\nАвтор поста: {author_info}" elif "анон" in post_text: - final_text = f"{safe_post_text}\n\nПост опубликован анонимно" + post_block += f"\n\nПост опубликован анонимно" else: - final_text = f"{safe_post_text}\n\nАвтор поста: {author_info}" + post_block += f"\n\nАвтор поста: {author_info}" - # Добавляем блок со скорами если есть - if ( - deepseek_score is not None - or rag_score is not None - or rag_score_pos_only is not None - ): - scores_lines = ["\n📊 Уверенность в одобрении:"] + post_block += f"\n{separator}" + + # Добавляем блок со скорами если есть (без RAG pos only и уверенности) + if deepseek_score is not None or rag_score is not None: + scores_lines = ["📊 Уверенность в одобрении:"] if deepseek_score is not None: scores_lines.append(f"DeepSeek: {deepseek_score:.2f}") if rag_score is not None: logger.debug( f"get_text_message: Форматирование rag_score - " f"rag_score={rag_score} (type: {type(rag_score).__name__}), " - f"rag_score_pos_only={rag_score_pos_only} (type: {type(rag_score_pos_only).__name__ if rag_score_pos_only is not None else 'None'}), " - f"rag_confidence={rag_confidence} (type: {type(rag_confidence).__name__ if rag_confidence is not None else 'None'}), " f"formatted_value={rag_score:.2f}" ) - rag_line = f"RAG neg/pos: {rag_score:.2f}" - if rag_confidence is not None: - rag_line += f" (уверенность: {rag_confidence:.0%})" - scores_lines.append(rag_line) - if rag_score_pos_only is not None: - scores_lines.append(f"RAG pos only: {rag_score_pos_only:.2f}") - final_text += "\n" + "\n".join(scores_lines) + scores_lines.append(f"RAG neg/pos: {rag_score:.2f}") + post_block += "\n" + "\n".join(scores_lines) - return final_text + return post_block @track_time("download_file", "helper_func") @@ -854,15 +905,14 @@ async def send_text_message( ): from .rate_limiter import send_with_rate_limit - # Экранируем post_text для безопасного использования в HTML - safe_post_text = html.escape(str(post_text)) if post_text else "" - async def _send_message(): if markup is None: - return await message.bot.send_message(chat_id=chat_id, text=safe_post_text) + return await message.bot.send_message( + chat_id=chat_id, text=post_text, parse_mode="HTML" + ) else: return await message.bot.send_message( - chat_id=chat_id, text=safe_post_text, reply_markup=markup + chat_id=chat_id, text=post_text, reply_markup=markup, parse_mode="HTML" ) sent_message = await send_with_rate_limit(_send_message, chat_id) @@ -878,16 +928,17 @@ async def send_photo_message( post_text: str, markup: types.ReplyKeyboardMarkup = None, ): - # Экранируем post_text для безопасного использования в HTML - safe_post_text = html.escape(str(post_text)) if post_text else "" - if markup is None: sent_message = await message.bot.send_photo( - chat_id=chat_id, caption=safe_post_text, photo=photo + chat_id=chat_id, caption=post_text, photo=photo, parse_mode="HTML" ) else: sent_message = await message.bot.send_photo( - chat_id=chat_id, caption=safe_post_text, photo=photo, reply_markup=markup + chat_id=chat_id, + caption=post_text, + photo=photo, + reply_markup=markup, + parse_mode="HTML", ) return sent_message @@ -901,16 +952,17 @@ async def send_video_message( post_text: str = "", markup: types.ReplyKeyboardMarkup = None, ): - # Экранируем post_text для безопасного использования в HTML - safe_post_text = html.escape(str(post_text)) if post_text else "" - if markup is None: sent_message = await message.bot.send_video( - chat_id=chat_id, caption=safe_post_text, video=video + chat_id=chat_id, caption=post_text, video=video, parse_mode="HTML" ) else: sent_message = await message.bot.send_video( - chat_id=chat_id, caption=safe_post_text, video=video, reply_markup=markup + chat_id=chat_id, + caption=post_text, + video=video, + reply_markup=markup, + parse_mode="HTML", ) return sent_message @@ -943,16 +995,17 @@ async def send_audio_message( post_text: str, markup: types.ReplyKeyboardMarkup = None, ): - # Экранируем post_text для безопасного использования в HTML - safe_post_text = html.escape(str(post_text)) if post_text else "" - if markup is None: sent_message = await message.bot.send_audio( - chat_id=chat_id, caption=safe_post_text, audio=audio + chat_id=chat_id, caption=post_text, audio=audio, parse_mode="HTML" ) else: sent_message = await message.bot.send_audio( - chat_id=chat_id, caption=safe_post_text, audio=audio, reply_markup=markup + chat_id=chat_id, + caption=post_text, + audio=audio, + reply_markup=markup, + parse_mode="HTML", ) return sent_message @@ -1012,11 +1065,12 @@ async def get_banned_users_list(offset: int, bot_db): message - текст сообщения user_ids - лист кортежей [(user_name: user_id)] """ - users = await bot_db.get_banned_users_from_db_with_limits(limit=7, offset=offset) + items_per_page = 9 + users = await bot_db.get_banned_users_from_db_with_limits(limit=items_per_page, offset=offset) message = "Список заблокированных пользователей:\n" for user in users: - user_id, ban_reason, unban_date = user + user_id, ban_reason, unban_date, ban_date = user # Получаем имя пользователя из таблицы users username = await bot_db.get_username(user_id) full_name = await bot_db.get_full_name_by_id(user_id) @@ -1028,41 +1082,42 @@ async def get_banned_users_list(offset: int, bot_db): html.escape(str(ban_reason)) if ban_reason else "Причина не указана" ) - # Форматируем дату разбана в человекочитаемый формат - if unban_date: - try: - # Предполагаем, что unban_date это UNIX timestamp - if isinstance(unban_date, (int, float)): - unban_datetime = datetime.fromtimestamp(unban_date) - safe_unban_date = unban_datetime.strftime("%d-%m-%Y %H:%M") - elif isinstance(unban_date, str): - # Если это строка, попытаемся её обработать - try: - # Попробуем преобразовать строку в timestamp - timestamp = int(unban_date) - unban_datetime = datetime.fromtimestamp(timestamp) - safe_unban_date = unban_datetime.strftime("%d-%m-%Y %H:%M") - except (ValueError, TypeError): - # Если не удалось, показываем как есть - safe_unban_date = html.escape(str(unban_date)) - elif hasattr(unban_date, "strftime"): - # Если это datetime объект - safe_unban_date = unban_date.strftime("%d-%m-%Y %H:%M") - else: - # Для всех остальных случаев - safe_unban_date = html.escape(str(unban_date)) - except (ValueError, TypeError, OSError): - # В случае ошибки показываем исходное значение - safe_unban_date = html.escape(str(unban_date)) - else: - safe_unban_date = "Дата не указана" + # Форматируем дату бана в человекочитаемый формат + safe_ban_date = _format_timestamp_to_date(ban_date) - message += f"**Пользователь:** {safe_user_name}\n" - message += f"**Причина бана:** {safe_ban_reason}\n" - message += f"**Дата разбана:** {safe_unban_date}\n\n" + # Форматируем дату разбана в человекочитаемый формат + safe_unban_date = _format_timestamp_to_date(unban_date, default="Навсегда") + + message += f"Пользователь: {safe_user_name}\n" + message += f"Причина бана: {safe_ban_reason}\n" + message += f"Дата бана: {safe_ban_date}\n" + message += f"Дата разбана: {safe_unban_date}\n\n" return message +def _format_timestamp_to_date(timestamp, default: str = "Дата не указана") -> str: + """Форматирует timestamp в читаемую дату.""" + if not timestamp: + return default + try: + if isinstance(timestamp, (int, float)): + dt = datetime.fromtimestamp(timestamp) + return dt.strftime("%d.%m.%Y %H:%M") + elif isinstance(timestamp, str): + try: + ts = int(timestamp) + dt = datetime.fromtimestamp(ts) + return dt.strftime("%d.%m.%Y %H:%M") + except (ValueError, TypeError): + return html.escape(str(timestamp)) + elif hasattr(timestamp, "strftime"): + return timestamp.strftime("%d.%m.%Y %H:%M") + else: + return html.escape(str(timestamp)) + except (ValueError, TypeError, OSError): + return html.escape(str(timestamp)) + + @track_time("get_banned_users_buttons", "helper_func") @track_errors("helper_func", "get_banned_users_buttons") @db_query_time("get_banned_users_buttons", "users", "select") diff --git a/tests/test_blacklist_repository.py b/tests/test_blacklist_repository.py index 97caf4f..5b323a7 100644 --- a/tests/test_blacklist_repository.py +++ b/tests/test_blacklist_repository.py @@ -274,9 +274,9 @@ class TestBlacklistRepository: # Нормализуем SQL запрос (убираем лишние пробелы и переносы строк) actual_query = " ".join(call_args[0][0].split()) - expected_query = "SELECT user_id, message_for_user, date_to_unban, created_at, ban_author FROM blacklist LIMIT ?, ?" + expected_query = "SELECT user_id, message_for_user, date_to_unban, created_at, ban_author FROM blacklist ORDER BY created_at DESC LIMIT ? OFFSET ?" assert actual_query == expected_query - assert call_args[0][1] == (0, 10) + assert call_args[0][1] == (10, 0) # Проверяем логирование blacklist_repository.logger.info.assert_called_once_with( @@ -310,7 +310,7 @@ class TestBlacklistRepository: # Нормализуем SQL запрос (убираем лишние пробелы и переносы строк) actual_query = " ".join(call_args[0][0].split()) - expected_query = "SELECT user_id, message_for_user, date_to_unban, created_at, ban_author FROM blacklist" + expected_query = "SELECT user_id, message_for_user, date_to_unban, created_at, ban_author FROM blacklist ORDER BY created_at DESC" assert actual_query == expected_query # Проверяем, что параметры пустые (без лимитов) assert len(call_args[0]) == 1 # Только SQL запрос, без параметров diff --git a/tests/test_callback_services.py b/tests/test_callback_services.py index 1bfea92..216f6b9 100644 --- a/tests/test_callback_services.py +++ b/tests/test_callback_services.py @@ -85,7 +85,7 @@ class TestPostPublishService: return call @patch("helper_bot.handlers.callback.services.send_text_message") - @patch("helper_bot.handlers.callback.services.get_text_message") + @patch("helper_bot.handlers.callback.services.get_publish_text") async def test_publish_post_text_success( self, mock_get_text, mock_send_text, service, mock_call_text, mock_db ): @@ -214,7 +214,7 @@ class TestPostPublishService: @patch("helper_bot.handlers.callback.services.send_photo_message") @patch("helper_bot.handlers.callback.services.send_text_message") - @patch("helper_bot.handlers.callback.services.get_text_message") + @patch("helper_bot.handlers.callback.services.get_publish_text") async def test_publish_post_photo_success( self, mock_get_text, mock_send_text, mock_send_photo, service, mock_db ): @@ -239,7 +239,7 @@ class TestPostPublishService: @patch("helper_bot.handlers.callback.services.send_video_message") @patch("helper_bot.handlers.callback.services.send_text_message") - @patch("helper_bot.handlers.callback.services.get_text_message") + @patch("helper_bot.handlers.callback.services.get_publish_text") async def test_publish_post_video_success( self, mock_get_text, mock_send_text, mock_send_video, service, mock_db ): @@ -285,7 +285,7 @@ class TestPostPublishService: @patch("helper_bot.handlers.callback.services.send_audio_message") @patch("helper_bot.handlers.callback.services.send_text_message") - @patch("helper_bot.handlers.callback.services.get_text_message") + @patch("helper_bot.handlers.callback.services.get_publish_text") async def test_publish_post_audio_success( self, mock_get_text, mock_send_text, mock_send_audio, service, mock_db ): @@ -499,7 +499,7 @@ class TestPostPublishService: @patch("helper_bot.handlers.callback.services.send_media_group_to_channel") @patch("helper_bot.handlers.callback.services.send_text_message") - @patch("helper_bot.handlers.callback.services.get_text_message") + @patch("helper_bot.handlers.callback.services.get_publish_text") async def test_publish_media_group_success( self, mock_get_text, mock_send_text, mock_send_media, service, mock_db ): diff --git a/tests/test_refactored_private_handlers.py b/tests/test_refactored_private_handlers.py index d36b4e3..515eb2c 100644 --- a/tests/test_refactored_private_handlers.py +++ b/tests/test_refactored_private_handlers.py @@ -29,6 +29,11 @@ class TestPrivateHandlers: db.add_message = AsyncMock() db.update_helper_message = AsyncMock() db.update_user_activity = AsyncMock() + db.get_user_posts_stats = AsyncMock(return_value=(5, 2, 3)) + db.get_last_post_by_author = AsyncMock(return_value="Last post text") + db.get_user_by_id = AsyncMock(return_value=Mock(date_added=1704067200)) + db.get_user_ban_count = AsyncMock(return_value=0) + db.get_last_ban_info = AsyncMock(return_value=None) return db @pytest.fixture @@ -257,6 +262,7 @@ class TestPrivateHandlers: """resend_message_in_group при PRE_CHAT переводит в START и отправляет question.""" handlers = create_private_handlers(mock_db, mock_settings) mock_state.get_state = AsyncMock(return_value=FSM_STATES["PRE_CHAT"]) + mock_message.bot.send_message = AsyncMock(return_value=Mock(message_id=100)) with pytest.MonkeyPatch().context() as m: m.setattr( "helper_bot.handlers.private.private_handlers.get_reply_keyboard", @@ -267,9 +273,7 @@ class TestPrivateHandlers: lambda x, y: "Question?", ) await handlers.resend_message_in_group_for_message(mock_message, mock_state) - mock_message.forward.assert_called_once_with( - chat_id=mock_settings.group_for_message - ) + mock_message.bot.send_message.assert_called_once() mock_state.set_state.assert_called_once_with(FSM_STATES["START"]) @pytest.mark.asyncio @@ -279,6 +283,7 @@ class TestPrivateHandlers: """resend_message_in_group при CHAT оставляет в CHAT и отправляет question с leave markup.""" handlers = create_private_handlers(mock_db, mock_settings) mock_state.get_state = AsyncMock(return_value=FSM_STATES["CHAT"]) + mock_message.bot.send_message = AsyncMock(return_value=Mock(message_id=100)) with pytest.MonkeyPatch().context() as m: m.setattr( "helper_bot.handlers.private.private_handlers.get_reply_keyboard_leave_chat", @@ -289,9 +294,7 @@ class TestPrivateHandlers: lambda x, y: "Question?", ) await handlers.resend_message_in_group_for_message(mock_message, mock_state) - mock_message.forward.assert_called_once_with( - chat_id=mock_settings.group_for_message - ) + mock_message.bot.send_message.assert_called_once() mock_message.answer.assert_called() @pytest.mark.asyncio diff --git a/tests/test_utils.py b/tests/test_utils.py index d511122..b57d253 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -665,7 +665,7 @@ class TestSendMessageFunctions: assert result == mock_sent_message mock_message.bot.send_photo.assert_called_once_with( - chat_id=123, caption="Подпись к фото", photo="photo.jpg" + chat_id=123, caption="Подпись к фото", photo="photo.jpg", parse_mode="HTML" ) @pytest.mark.asyncio @@ -684,7 +684,7 @@ class TestSendMessageFunctions: assert result == mock_sent_message mock_message.bot.send_video.assert_called_once_with( - chat_id=123, caption="Подпись к видео", video="video.mp4" + chat_id=123, caption="Подпись к видео", video="video.mp4", parse_mode="HTML" ) @@ -722,8 +722,9 @@ class TestUtilityFunctions: """Тест получения списка заблокированных пользователей""" mock_db = AsyncMock() mock_db.get_banned_users_from_db_with_limits.return_value = [ - (123, "Spam", 1704067200), # user_id, ban_reason, unban_date (timestamp) - (456, "Violation", 1704153600), + # user_id, ban_reason, unban_date (timestamp), ban_date (timestamp) + (123, "Spam", 1704067200, 1703980800), + (456, "Violation", 1704153600, 1704067200), ] mock_db.get_username.return_value = None mock_db.get_full_name_by_id.return_value = "Test User" @@ -734,18 +735,16 @@ class TestUtilityFunctions: assert "Test User" in result assert "Spam" in result assert "Violation" in result + assert "Дата бана:" in result @pytest.mark.asyncio async def test_get_banned_users_list_with_string_timestamp(self): """Тест получения списка заблокированных пользователей со строковым timestamp""" mock_db = AsyncMock() mock_db.get_banned_users_from_db_with_limits.return_value = [ - ( - 123, - "Spam", - "1704067200", - ), # user_id, ban_reason, unban_date (string timestamp) - (456, "Violation", "1704153600"), + # user_id, ban_reason, unban_date (string timestamp), ban_date (string timestamp) + (123, "Spam", "1704067200", "1703980800"), + (456, "Violation", "1704153600", "1704067200"), ] mock_db.get_username.return_value = None mock_db.get_full_name_by_id.return_value = "Test User" @@ -756,6 +755,7 @@ class TestUtilityFunctions: assert "Test User" in result assert "Spam" in result assert "Violation" in result + assert "Дата бана:" in result @pytest.mark.asyncio async def test_get_banned_users_buttons(self): -- 2.49.1 From b3cdadfd8e5209f4ed359490ec702c446e1b9699 Mon Sep 17 00:00:00 2001 From: Andrey Date: Sat, 28 Feb 2026 21:30:16 +0300 Subject: [PATCH 2/5] 11 --- .cursor/implementation-plan-features.md | 428 ------------------------ .cursor/prompt-stage-4-similar-posts.md | 126 ------- 2 files changed, 554 deletions(-) delete mode 100644 .cursor/implementation-plan-features.md delete mode 100644 .cursor/prompt-stage-4-similar-posts.md diff --git a/.cursor/implementation-plan-features.md b/.cursor/implementation-plan-features.md deleted file mode 100644 index 3189d74..0000000 --- a/.cursor/implementation-plan-features.md +++ /dev/null @@ -1,428 +0,0 @@ -# План реализации фич Telegram Helper Bot - -> Документ создан: 28 февраля 2025 -> Ветка: `dev-*` -> Статус: План утверждён - ---- - -## Обзор фич - -1. **Пагинация заблокированных пользователей** — сортировка по дате бана, единая логика текста и кнопок -2. **Обогащение сообщений пользователей админам** — данные о пользователе при обращении в поддержку -3. **Причина бана «Последний пост»** — замена «Спам» на «Последний пост» при быстром бане из поста -4. **Похожие посты за 24ч** — проверка на дубликаты через RAG (threshold >0.9) -5. **Авто-публикация/отклонение по RAG** — задел на будущее (>0.8 publish, <0.4 decline) -6. **ML Scoring Статистика** — восстановить полный вывод (модель, примеры, device) вместо fallback (API URL, статус) - ---- - -## Решения по уточняющим вопросам - -| Вопрос | Решение | -|--------|---------| -| Пагинация | Единый источник данных, единый `items_per_page` | -| message_id при forward | Осознанный workaround (n+1). При переходе на send — использовать `returned_message.message_id` | -| RAG similar | Новый endpoint в RAG. Нужна **отдельная коллекция** для submitted-постов (не позитив/негатив) | -| Скор для авто-решений | Только `rag_score` | -| Ветки | `dev-*` | - ---- - -## Проверка: message_id при forward - -**Telegram Bot API:** `forwardMessage` возвращает объект `Message` — это **новое** сообщение в целевом чате со своим `message_id`. Telegram присваивает `message_id` в целевом чате — он не обязан быть `n+1` от исходного. - -Если всё работает — возможно, бот единственный отправитель в `group_for_message`, и id часто идут подряд. Рекомендация: при переходе на `send_message` обязательно сохранять `message_id` из возвращаемого объекта. - ---- - -## RAG: коллекция для похожих постов - -- **Позитив/негатив** — примеры для скоринга модерации -- **Похожие посты** — сравнение с другими submitted-постами за 24ч - -Endpoint `/similar` должен искать по **отдельной коллекции submitted-постов** (с `created_at`), а не по позитиву/негативу. Нужно: -- Новая коллекция в RAG (например, `posts_submitted`) с полями: `text`, `vector`, `created_at`, `post_id` -- Endpoint `POST /similar`: `{"text": "...", "threshold": 0.9, "hours": 24}` → список похожих постов -- При каждом suggest — добавлять пост в эту коллекцию (или вызывать endpoint RAG для индексации) - ---- - -## Пошаговый план реализации - -### Этап 0: Подготовка - -| Шаг | Действие | -|-----|----------| -| 0.1 | Создать ветку `dev-N` (N — следующий номер) от `main` | -| 0.2 | Убедиться, что локально проходит `make code-quality` (или `isort`, `black`, `flake8`, `pytest`) | - ---- - -### Этап 1: Пагинация заблокированных пользователей - -| Шаг | Действие | -|-----|----------| -| 1.1 | В `BlacklistRepository`: добавить `ORDER BY created_at DESC` в `get_all_users` и `get_all_users_no_limit` | -| 1.2 | Ввести единый `items_per_page = 9` в `create_keyboard_with_pagination` и во всех местах пагинации | -| 1.3 | Создать единую функцию `get_banned_users_data(page, items_per_page)` в `AdminService` или `helper_func`, возвращающую `(message_text, buttons_list)` для заданной страницы | -| 1.4 | Обновить `get_banned_users_list` и `get_banned_users_buttons` — использовать общий источник и пагинацию | -| 1.5 | Обновить `admin_handlers.get_banned_users` и `callback_handlers.change_page` — вызывать единую функцию с `page` | -| 1.6 | Прогнать тесты `test_keyboards_and_filters`, `test_callback_handlers`, `test_admin_handlers` | - -**Затронутые файлы:** -- `database/repositories/blacklist_repository.py` -- `helper_bot/keyboards/keyboards.py` -- `helper_bot/utils/helper_func.py` -- `helper_bot/handlers/admin/services.py` -- `helper_bot/handlers/admin/admin_handlers.py` -- `helper_bot/handlers/callback/callback_handlers.py` - ---- - -### Этап 2: Обогащение сообщений пользователей админам - -| Шаг | Действие | -|-----|----------| -| 2.1 | Добавить в `AsyncBotDB`/репозитории методы: `get_posts_count_by_author`, `get_last_post_by_author`, `get_ban_history_count`, `get_last_ban_info`, `get_user_date_added` | -| 2.2 | Добавить в `BlacklistHistoryRepository` методы для истории банов (количество, последний бан) | -| 2.3 | В `UserService` или отдельном сервисе: метод `format_user_message_for_admins(user_id, message_text)` — собирает текст с данными пользователя | -| 2.4 | В `resend_message_in_group_for_message`: заменить `forward` на `send_message` с обогащённым текстом (имя, ник, id, посты, последний пост, баны, дата регистрации) | -| 2.5 | Сохранять `message_id` из результата `send_message` в `user_messages` (вместо `message.message_id + 1`) | -| 2.6 | Проверить, что `get_user_by_message_id` по-прежнему возвращает `user_id` для ответа админа | -| 2.7 | Обновить/добавить тесты для `resend_message_in_group_for_message` и `AdminReplyService` | - -**Данные для обогащения:** -- Количество постов от пользователя -- Последний пост пользователя (текст) -- Количество банов -- Дата и причина последнего бана (если был) -- Дата создания пользователя в БД (первый контакт с ботом) -- Имя, ник, id пользователя (обязательно при send вместо forward) - -**Шаблон итогового сообщения для админов:** - -```text -👤 От: Иван Петров (@ivan_petrov) | ID: 123456789 - -📊 Постов в базе: 5 -📝 Последний пост: "Привет, хочу поделиться мыслями о..." -📅 В боте с: 15.01.2025 - -🚫 Банов: 2 - Последний: 20.02.2025, причина «Спам», истёк 27.02.2025 - ---- -**Сообщение пользователя:** - -**Почему удалили мой пост?** -``` - -**Правила форматирования:** -- Секции «Банов: 0» и «Последний: …» — показывать только если были баны -- «Последний пост» — обрезать до ~80 символов + «…» если длиннее; если постов нет — «Нет постов» -- Даты в формате `DD.MM.YYYY` или `DD.MM.YYYY HH:MM` для разбана -- Разделитель `---` перед текстом сообщения пользователя - -**Для тестов:** использовать этот шаблон как эталон; проверять наличие всех секций, порядок полей, экранирование HTML в имени/нике/тексте. - -**Затронутые файлы:** -- `database/async_db.py` -- `database/repositories/post_repository.py` -- `database/repositories/blacklist_history_repository.py` -- `database/repositories/user_repository.py` -- `helper_bot/handlers/private/private_handlers.py` -- `helper_bot/handlers/private/services.py` -- `helper_bot/handlers/group/services.py` - ---- - -### Этап 3: Причина бана «Последний пост» - -| Шаг | Действие | -|-----|----------| -| 3.1 | В `callback/services.py` в `ban_user_from_post`: заменить `message_for_user="Спам"` на `message_for_user="Последний пост"` | -| 3.2 | Обновить тесты, где ожидается «Спам» | - -**Затронутые файлы:** -- `helper_bot/handlers/callback/services.py` -- `tests/test_callback_services.py` (если есть) - ---- - -### Этап 4: Похожие посты (RAG + бот) - -**Разделение работ:** -- **RAG сервис** — промпт в `.cursor/prompt-stage-4-similar-posts.md` -- **Подключение в боте** — ниже, раздел 4.2 - -#### 4.0. Текущая архитектура RAG сервиса - -**Путь:** `/Users/andrejkatyhin/Work/PycharmProjects/rag-service` - -| Компонент | Описание | -|-----------|----------| -| **VectorStore** (`app/storage/vector_store.py`) | In-memory хранилище векторов. `_positive_vectors` / `_negative_vectors` — для модерации. Персистентность: `positive_embeddings.npy`, `negative_embeddings.npy` или `vectors.npz`. Косинусное сходство через `np.dot` для нормализованных векторов. | -| **RAGService** (`app/services/rag_service.py`) | Модель `sentence-transformers/all-MiniLM-L12-v2` (384 dim). `get_embedding(text)`, `calculate_score(text)`, `add_positive_example`, `add_negative_example`. | -| **API** (`app/api/routes.py`) | `POST /api/v1/score`, `POST /api/v1/examples/positive`, `POST /api/v1/examples/negative`, `GET /api/v1/stats`, `POST /api/v1/warmup`, `GET/PUT /api/v1/scoring/params`. | -| **Config** (`app/config.py`) | `RAG_VECTORS_PATH`, `RAG_MAX_EXAMPLES`, `vector_dim=384`. | - -**Важно:** Позитив/негатив — отдельные коллекции без `created_at`. Для похожих постов нужна **третья** коллекция с временными метками. - ---- - -#### 4.1. Работы в RAG сервисе - -##### 4.1.1. Расширить VectorStore - -**Файл:** `app/storage/vector_store.py` - -Добавить третью коллекцию для submitted-постов: - -```python -# Новые атрибуты (аналогично _positive_vectors) -self._submitted_vectors: list = [] -self._submitted_hashes: list = [] -self._submitted_created_at: list = [] # Unix timestamps -self._submitted_post_ids: list = [] # post_id из бота -self._submitted_texts: list = [] # текст поста (для возврата в similar) -self._submitted_rag_scores: list = [] # rag_score на момент добавления -``` - -**Новые методы:** - -| Метод | Описание | -|-------|----------| -| `add_submitted(vector, text_hash, created_at, post_id=None, text="", rag_score=None)` | Добавить пост в коллекцию submitted. FIFO при превышении `max_submitted` (новый лимит, например 5000). | -| `find_similar_submitted(vector, threshold, hours)` | Возвращает: `List[dict]` с полями `similarity`, `created_at`, `post_id`, `text`, `rag_score`. Фильтрует по `created_at >= now - hours*3600`. Сравнивает с `_submitted_vectors` через `np.dot`. Возвращает только те, где similarity >= threshold. | - -**Персистентность:** Добавить в `save_to_disk` / `_load_from_disk` сохранение submitted-коллекции. Файл `submitted_embeddings.npz` с полями: `vectors`, `hashes`, `created_at`, `post_ids`, `texts`, `rag_scores`. - -**Конфиг:** Добавить `RAG_MAX_SUBMITTED` (default 5000), `RAG_SUBMITTED_PATH` (путь к файлу submitted). - -##### 4.1.2. Расширить RAGService - -**Файл:** `app/services/rag_service.py` - -| Метод | Описание | -|-------|----------| -| `add_submitted_post(text, post_id=None, rag_score=None)` | Очистить текст, получить embedding, `add_submitted` в vector_store. Сохраняет `text` и `rag_score` для возврата в similar. Вызывается при каждом suggest (бот передаёт rag_score из скоринга). | -| `find_similar_posts(text, threshold=0.9, hours=24)` | Получить embedding, вызвать `vector_store.find_similar_submitted`. Вернуть список похожих с полями: `similarity`, `created_at`, `post_id`, `text`, `rag_score`. | - -##### 4.1.3. Добавить API endpoint - -**Файл:** `app/api/routes.py` - -**POST /api/v1/similar** - -Возвращает: количество похожих постов, текст каждого, similarity (косинусное сходство), присвоенный rag_score на момент добавления. - -```python -# Request -class SimilarRequest(BaseModel): - text: str = Field(..., min_length=1) - threshold: float = Field(default=0.9, ge=0.0, le=1.0) - hours: int = Field(default=24, ge=1, le=168) # 1ч–7дней - -# Response -class SimilarPostItem(BaseModel): - similarity: float # косинусное сходство (0.0–1.0) - created_at: int # Unix timestamp - post_id: Optional[int] = None - text: str # текст похожего поста - rag_score: Optional[float] = None # rag_score на момент добавления - -class SimilarResponse(BaseModel): - similar_count: int - similar_posts: List[SimilarPostItem] -``` - -**POST /api/v1/submitted** - -```python -# Request -class SubmittedRequest(BaseModel): - text: str = Field(..., min_length=1) - post_id: Optional[int] = None - rag_score: Optional[float] = None # для возврата в similar - -# Response -class SubmittedResponse(BaseModel): - success: bool - message: str - submitted_count: int -``` - -**Примечание:** `POST /submitted` вызывается ботом при каждом suggest (после сохранения поста в БД). `POST /similar` вызывается ботом **перед** отправкой в группу модерации — чтобы проверить, есть ли похожие посты за последние сутки. - -##### 4.1.4. Схемы и исключения - -**Файл:** `app/schemas.py` — добавить `SimilarRequest`, `SimilarResponse`, `SimilarPostItem`, `SubmittedRequest`, `SubmittedResponse`. - -**Файл:** `app/exceptions.py` — при необходимости добавить `SubmittedStoreError` (если коллекция пуста и т.п.). - -##### 4.1.5. Автоочистка submitted (опционально) - -В `autosave_loop` или отдельно: периодически удалять из `_submitted_*` записи старше N часов (например, 48), чтобы не раздувать память. - ---- - -#### 4.2. Подключение в Telegram Helper Bot - -> RAG сервис реализуется отдельно (промпт: `.cursor/prompt-stage-4-similar-posts.md`). Ниже — интеграция в бота. - -##### 4.2.1. RagApiClient (`helper_bot/services/scoring/rag_client.py`) - -Добавить методы: - -- `find_similar_posts(text, threshold=0.9, hours=24)` — POST на `{api_url}/similar`, body `{"text": text, "threshold": threshold, "hours": hours}`. Вернуть `SimilarResponse` (или dataclass/dict) или `None` при ошибке. -- `add_submitted_post(text, post_id=None, rag_score=None)` — POST на `{api_url}/submitted`, body `{"text": text, "post_id": post_id, "rag_score": rag_score}`. При ошибке — логировать, не падать. - -Оба метода проверяют `self._enabled` и не делают запросы, если RAG отключён. - -##### 4.2.2. PostService (`helper_bot/handlers/private/services.py`) - -В `_process_post_background` и `_process_media_group_background`: - -**Порядок вызовов:** -1. Получить скоры (`_get_scores_with_error_handling`) — уже есть `rag_score`. -2. **Перед** отправкой: вызвать `find_similar_posts(original_raw_text, 0.9, 24)`. Если RAG недоступен или ошибка — не падать, пропустить. -3. Если `similar_count > 0`: добавить в `post_text` строку `\n\n⚠️ Похожий пост за последние 24ч (совпадение {max_similarity:.0%})`. -4. Отправить пост в группу модерации. -5. Сохранить в БД. -6. **После** успешной отправки: вызвать `add_submitted_post(original_raw_text, sent_message.message_id, rag_score)` — в фоне. `rag_score` из шага 1. - -**Важно:** Проверка similar — **до** добавления текущего поста в submitted. - -##### 4.2.3. Доступ к RagApiClient - -`RagApiClient` создаётся через `ScoringManager` или `BaseDependencyFactory`. PostService должен иметь доступ к `rag_client` (или `scoring_manager`). При необходимости добавить методы в `ScoringManager` как прокси к RAG. - -##### 4.2.4. Обработка ошибок - -При недоступности RAG — не падать, не добавлять предупреждение и не индексировать. - ---- - -#### 4.3. Порядок вызовов в боте - -```text -1. Пользователь отправляет пост -2. PostService._process_post_background: - a) Получить скоры (rag_score, confidence, ...) - b) find_similar_posts(text, 0.9, 24) — есть ли похожие? (возвращает count, text, similarity, rag_score) - c) Если да — добавить предупреждение в post_text - d) Отправить пост в группу модерации - e) Сохранить в БД (add_post) - f) add_submitted_post(text, message_id, rag_score) — индексировать в RAG -``` - -**Важно:** Проверка similar делается **до** добавления текущего поста в submitted, иначе пост будет похож сам на себя. - ---- - -#### 4.4. Затронутые файлы - -| Репозиторий | Файлы | -|-------------|-------| -| **rag-service** | `app/storage/vector_store.py`, `app/services/rag_service.py`, `app/api/routes.py`, `app/schemas.py`, `app/config.py`, `app/main.py` (описание в docs) | -| **telegram-helper-bot** | `helper_bot/services/scoring/rag_client.py`, `helper_bot/handlers/private/services.py` | - ---- - -### Этап 5: Авто-публикация/отклонение (задел на будущее) - -| Шаг | Действие | -|-----|----------| -| 5.1 | В `PostService._process_post_background`: после получения `rag_score` проверять пороги | -| 5.2 | Если `rag_score >= 0.8`: не показывать кнопки модерации, сразу публиковать (или вызывать логику publish) | -| 5.3 | Если `rag_score <= 0.4`: сразу отклонять (decline) | -| 5.4 | Добавить флаги в `.env` (например, `AUTO_PUBLISH_ENABLED`, `AUTO_DECLINE_ENABLED`) — по умолчанию `false` | -| 5.5 | Реализацию оформить как выключенную по умолчанию; включение — через конфиг | - -**Затронутые файлы:** -- `helper_bot/handlers/private/services.py` -- `helper_bot/config/` или `.env` - ---- - -### Этап 5.5: ML Scoring Статистика — восстановить полный вывод - -**Проблема:** Раньше «📊 ML Статистика» показывала детали (модель, device, кол-во примеров, размерность). Теперь только API URL и статус. - -**Причина:** Бот использует fallback (`get_stats_sync`) когда `RagApiClient.get_stats()` возвращает пустой результат. Это происходит при: -- 401/403 (ошибка авторизации) -- таймауте или ошибке соединения -- неверном формате ответа от API - -**Задачи:** - -| Шаг | Действие | -|-----|----------| -| 5.5.1 | Проверить, что RAG API `GET /stats` доступен с бота (сеть, CORS, API key). | -| 5.5.2 | Убедиться, что `RagApiClient.get_stats()` передаёт заголовок `X-API-Key` и корректно обрабатывает 200. | -| 5.5.3 | Проверить контракт ответа: RAG возвращает `model_name`, `model_loaded`, `device`, `vector_store` (positive_count, negative_count, total_count, vector_dim, max_examples). | -| 5.5.4 | При ошибке API — логировать причину (status, body) и при необходимости улучшить fallback-сообщение (например, «API недоступен: …»). | -| 5.5.5 | Добавить тесты для `get_ml_stats` с моком API (успешный ответ и fallback). | - -**Затронутые файлы:** -- `helper_bot/handlers/admin/admin_handlers.py` (get_ml_stats) -- `helper_bot/services/scoring/rag_client.py` (get_stats, get_stats_sync) -- `helper_bot/services/scoring/scoring_manager.py` (get_stats) - ---- - -### Этап 6: Тесты и качество кода - -| Шаг | Действие | -|-----|----------| -| 6.1 | Прогнать все тесты: `pytest tests/ -v` | -| 6.2 | `make code-quality` (или `isort`, `black`, `flake8`) | -| 6.3 | При необходимости обновить моки и фикстуры | - ---- - -### Этап 7: Release Notes и деплой - -| Шаг | Действие | -|-----|----------| -| 7.1 | Создать `docs/RELEASE_NOTES_DEV-N.md` по шаблону из `.cursor/rules/release-notes-template.md` | -| 7.2 | Коммиты в формате: `feat:`, `fix:`, `refactor:` | -| 7.3 | Push в `dev-N` → CI (тесты, code quality) | -| 7.4 | Создать/обновить PR в `main` | -| 7.5 | После мержа — деплой по `deploy.yml` (если настроен в prod) | - ---- - -## Зависимости между этапами - -``` -Этап 0 → Этап 1, 2, 3 (можно параллельно) -Этап 1, 2, 3 → Этап 6 -Этап 4 зависит от RAG (отдельный сервис) -Этап 5 можно делать после 4 или независимо -Этап 5.5 — независимо (можно параллельно с 1–3) -``` - ---- - -## Рекомендуемый порядок реализации - -1. Этап 0 — подготовка -2. Этапы 1, 2, 3 — независимо, можно в любом порядке -3. Этап 4 — после готовности RAG -4. Этап 5 — после 4 или параллельно с 1–3 -5. Этап 5.5 — разобраться с ML Scoring Статистикой (можно параллельно) -6. Этап 6 — перед PR -7. Этап 7 — после ревью и мержа - ---- - -## Ссылки на документацию проекта - -- `.cursor/rules/my-custom-rule.mdc` — общие правила -- `.cursor/rules/architecture.md` — архитектура -- `.cursor/rules/handlers-patterns.md` — паттерны handlers -- `.cursor/rules/release-notes-template.md` — шаблон Release Notes -- `prod/.cursor/rules/my-custom-rule.mdc` — CI/CD, ветки, деплой diff --git a/.cursor/prompt-stage-4-similar-posts.md b/.cursor/prompt-stage-4-similar-posts.md deleted file mode 100644 index 61b3b66..0000000 --- a/.cursor/prompt-stage-4-similar-posts.md +++ /dev/null @@ -1,126 +0,0 @@ -# Промпт: Реализация Этапа 4 — RAG сервис (похожие посты) - -Скопируй этот промпт нейросети для реализации фичи «похожие посты» в RAG сервисе. - -> Подключение к Telegram боту описано в `.cursor/implementation-plan-features.md` (Этап 4, раздел 4.2). - ---- - -## Задача - -Добавить в RAG сервис третью коллекцию для submitted-постов. Endpoints: -- `POST /similar` — поиск похожих постов за N часов (threshold, text) -- `POST /submitted` — добавление поста в коллекцию (для индексации ботом) - ---- - -## Контекст - -**Путь:** `/Users/andrejkatyhin/Work/PycharmProjects/rag-service` - -RAG уже имеет: -- `VectorStore` с `_positive_vectors` / `_negative_vectors` — для модерации (score) -- `RAGService` с `get_embedding`, `calculate_score`, `add_positive_example`, `add_negative_example` -- API: `POST /score`, `POST /examples/positive`, `POST /examples/negative`, `GET /stats` - -Нужно добавить **третью коллекцию** для submitted-постов (с `created_at`, `text`, `rag_score`). - ---- - -## 1. VectorStore (`app/storage/vector_store.py`) - -Добавь коллекцию submitted: - -```python -self._submitted_vectors: list = [] -self._submitted_hashes: list = [] -self._submitted_created_at: list = [] # Unix timestamps -self._submitted_post_ids: list = [] -self._submitted_texts: list = [] -self._submitted_rag_scores: list = [] -``` - -**Методы:** -- `add_submitted(vector, text_hash, created_at, post_id=None, text="", rag_score=None)` — добавить пост. FIFO при превышении `max_submitted` (новый параметр в конструкторе, default 5000). -- `find_similar_submitted(vector, threshold, hours)` — вернуть `List[dict]` с полями `similarity`, `created_at`, `post_id`, `text`, `rag_score`. Фильтр: `created_at >= now - hours*3600`. Сравнение через `np.dot` (как для positive/negative). Только те, где similarity >= threshold. - -**Персистентность:** Сохранять/загружать submitted в отдельный файл (например, `submitted_embeddings.npz`). Поля: `vectors`, `hashes`, `created_at`, `post_ids`, `texts`, `rag_scores`. Используй `np.array(..., dtype=object)` для строк и `allow_pickle=True` при необходимости. - ---- - -## 2. Config (`app/config.py`) - -Добавь: -- `RAG_MAX_SUBMITTED` (default 5000) -- `RAG_SUBMITTED_PATH` (default `data/vectors/submitted.npz`) - ---- - -## 3. RAGService (`app/services/rag_service.py`) - -- `add_submitted_post(text, post_id=None, rag_score=None)` — очистить текст, получить embedding, вызвать `vector_store.add_submitted` с `created_at=int(time.time())`, `text`, `rag_score`. -- `find_similar_posts(text, threshold=0.9, hours=24)` — получить embedding, вызвать `vector_store.find_similar_submitted`, вернуть результат. - ---- - -## 4. Схемы (`app/schemas.py`) - -```python -class SimilarRequest(BaseModel): - text: str = Field(..., min_length=1) - threshold: float = Field(default=0.9, ge=0.0, le=1.0) - hours: int = Field(default=24, ge=1, le=168) - -class SimilarPostItem(BaseModel): - similarity: float - created_at: int - post_id: Optional[int] = None - text: str - rag_score: Optional[float] = None - -class SimilarResponse(BaseModel): - similar_count: int - similar_posts: List[SimilarPostItem] - -class SubmittedRequest(BaseModel): - text: str = Field(..., min_length=1) - post_id: Optional[int] = None - rag_score: Optional[float] = None - -class SubmittedResponse(BaseModel): - success: bool - message: str - submitted_count: int -``` - ---- - -## 5. API (`app/api/routes.py`) - -- `POST /api/v1/similar` — принять `SimilarRequest`, вызвать `service.find_similar_posts`, вернуть `SimilarResponse`. -- `POST /api/v1/submitted` — принять `SubmittedRequest`, вызвать `service.add_submitted_post`, вернуть `SubmittedResponse`. - ---- - -## 6. Автосохранение - -В `autosave_loop` или при `save_vectors` — сохранять submitted-коллекцию. При загрузке — загружать submitted из файла в `_load_from_disk` или отдельном методе. - ---- - -## Требования - -- Не ломать существующий функционал: score, examples, stats работают как раньше. -- Следовать стилю кода проекта (Black, isort, type hints). -- Добавить тесты для новых методов и endpoints. - ---- - -## Файлы для изменения - -- `app/storage/vector_store.py` -- `app/services/rag_service.py` -- `app/api/routes.py` -- `app/schemas.py` -- `app/config.py` -- `app/main.py` (при необходимости — lifespan для autosave submitted) -- 2.49.1 From 31314c9c9bfe1fcee9a218189fda3427d325e78a Mon Sep 17 00:00:00 2001 From: Andrey Date: Sat, 28 Feb 2026 22:21:29 +0300 Subject: [PATCH 3/5] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=BC=D0=B5=D1=82=D0=BE=D0=B4=D1=8B=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=8B=20=D1=81=20?= =?UTF-8?q?=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B0=D0=BC?= =?UTF-8?q?=D0=B8=20=D0=B0=D0=B2=D1=82=D0=BE-=D0=BC=D0=BE=D0=B4=D0=B5?= =?UTF-8?q?=D1=80=D0=B0=D1=86=D0=B8=D0=B8,=20=D0=B2=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B0=D1=8F=20=D0=BF=D0=BE=D0=BB=D1=83=D1=87=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B8=20=D1=83=D1=81=D1=82=D0=B0=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BA=D1=83=20=D0=B7=D0=BD=D0=B0=D1=87=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B9,=20=D0=B0=20=D1=82=D0=B0=D0=BA=D0=B6=D0=B5=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D0=B9?= =?UTF-8?q?=20=D0=B0=D0=B2=D1=82=D0=BE-=D0=BF=D1=83=D0=B1=D0=BB=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B8=20=D0=B0=D0=B2=D1=82?= =?UTF-8?q?=D0=BE-=D0=BE=D1=82=D0=BA=D0=BB=D0=BE=D0=BD=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F.=20=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D1=8B=20=D1=81=D0=BE=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D1=81=D1=82?= =?UTF-8?q?=D0=B2=D1=83=D1=8E=D1=89=D0=B8=D0=B5=20=D1=80=D0=B5=D0=BF=D0=BE?= =?UTF-8?q?=D0=B7=D0=B8=D1=82=D0=BE=D1=80=D0=B8=D0=B8=20=D0=B8=20=D0=BE?= =?UTF-8?q?=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=87=D0=B8=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20=D0=BD=D0=BE=D0=B2=D1=8B=D1=85=20=D1=84?= =?UTF-8?q?=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=B9=20=D0=B2=20=D0=B0=D0=B4?= =?UTF-8?q?=D0=BC=D0=B8=D0=BD-=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D0=B8.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- database/async_db.py | 29 ++ database/repositories/__init__.py | 3 + .../repositories/bot_settings_repository.py | 160 ++++++++ database/repository_factory.py | 10 + helper_bot/handlers/admin/admin_handlers.py | 263 +++++++++++++ .../handlers/private/private_handlers.py | 15 +- helper_bot/handlers/private/services.py | 349 ++++++++++++++++++ helper_bot/keyboards/keyboards.py | 53 +++ scripts/create_bot_settings_table.py | 117 ++++++ tests/test_auto_moderation_service.py | 222 +++++++++++ tests/test_bot_settings_repository.py | 161 ++++++++ tests/test_keyboards_and_filters.py | 11 +- 12 files changed, 1388 insertions(+), 5 deletions(-) create mode 100644 database/repositories/bot_settings_repository.py create mode 100644 scripts/create_bot_settings_table.py create mode 100644 tests/test_auto_moderation_service.py create mode 100644 tests/test_bot_settings_repository.py diff --git a/database/async_db.py b/database/async_db.py index 09ae147..f36a324 100644 --- a/database/async_db.py +++ b/database/async_db.py @@ -572,3 +572,32 @@ class AsyncBotDB: except Exception as e: self.logger.error(f"Error executing query: {e}") return None + + # Методы для работы с настройками бота + async def get_auto_moderation_settings(self) -> Dict[str, Any]: + """Получает все настройки авто-модерации.""" + return await self.factory.bot_settings.get_auto_moderation_settings() + + async def get_bool_setting(self, key: str, default: bool = False) -> bool: + """Получает булево значение настройки.""" + return await self.factory.bot_settings.get_bool_setting(key, default) + + async def get_float_setting(self, key: str, default: float = 0.0) -> float: + """Получает числовое значение настройки.""" + return await self.factory.bot_settings.get_float_setting(key, default) + + async def set_setting(self, key: str, value: str) -> None: + """Устанавливает значение настройки.""" + await self.factory.bot_settings.set_setting(key, value) + + async def set_float_setting(self, key: str, value: float) -> None: + """Устанавливает числовое значение настройки.""" + await self.factory.bot_settings.set_float_setting(key, value) + + async def toggle_auto_publish(self) -> bool: + """Переключает состояние авто-публикации.""" + return await self.factory.bot_settings.toggle_auto_publish() + + async def toggle_auto_decline(self) -> bool: + """Переключает состояние авто-отклонения.""" + return await self.factory.bot_settings.toggle_auto_decline() diff --git a/database/repositories/__init__.py b/database/repositories/__init__.py index 3b57f50..9062023 100644 --- a/database/repositories/__init__.py +++ b/database/repositories/__init__.py @@ -10,12 +10,14 @@ - admin_repository: работа с администраторами - audio_repository: работа с аудио - migration_repository: работа с миграциями БД +- bot_settings_repository: работа с настройками бота """ from .admin_repository import AdminRepository from .audio_repository import AudioRepository from .blacklist_history_repository import BlacklistHistoryRepository from .blacklist_repository import BlacklistRepository +from .bot_settings_repository import BotSettingsRepository from .message_repository import MessageRepository from .migration_repository import MigrationRepository from .post_repository import PostRepository @@ -30,4 +32,5 @@ __all__ = [ "AdminRepository", "AudioRepository", "MigrationRepository", + "BotSettingsRepository", ] diff --git a/database/repositories/bot_settings_repository.py b/database/repositories/bot_settings_repository.py new file mode 100644 index 0000000..7e0bc21 --- /dev/null +++ b/database/repositories/bot_settings_repository.py @@ -0,0 +1,160 @@ +"""Репозиторий для работы с настройками бота.""" + +from typing import Dict, Optional + +from database.base import DatabaseConnection + + +class BotSettingsRepository(DatabaseConnection): + """Репозиторий для управления настройками бота в таблице bot_settings.""" + + async def create_table(self) -> None: + """Создает таблицу bot_settings, если она не существует.""" + query = """ + CREATE TABLE IF NOT EXISTS bot_settings ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + key TEXT NOT NULL UNIQUE, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) + ) + """ + await self._execute_query(query) + self.logger.info("Таблица bot_settings создана или уже существует") + + async def get_setting(self, key: str) -> Optional[str]: + """ + Получает значение настройки по ключу. + + Args: + key: Ключ настройки + + Returns: + Значение настройки или None, если не найдено + """ + query = "SELECT value FROM bot_settings WHERE key = ?" + rows = await self._execute_query_with_result(query, (key,)) + if rows and len(rows) > 0: + return rows[0][0] + return None + + async def set_setting(self, key: str, value: str) -> None: + """ + Устанавливает значение настройки. + + Args: + key: Ключ настройки + value: Значение настройки + """ + query = """ + INSERT INTO bot_settings (key, value, updated_at) + VALUES (?, ?, strftime('%s', 'now')) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = strftime('%s', 'now') + """ + await self._execute_query(query, (key, value)) + self.logger.debug(f"Настройка {key} установлена: {value}") + + async def get_bool_setting(self, key: str, default: bool = False) -> bool: + """ + Получает булево значение настройки. + + Args: + key: Ключ настройки + default: Значение по умолчанию + + Returns: + True если значение 'true', иначе False + """ + value = await self.get_setting(key) + if value is None: + return default + return value.lower() == "true" + + async def set_bool_setting(self, key: str, value: bool) -> None: + """ + Устанавливает булево значение настройки. + + Args: + key: Ключ настройки + value: Булево значение + """ + await self.set_setting(key, "true" if value else "false") + + async def get_float_setting(self, key: str, default: float = 0.0) -> float: + """ + Получает числовое значение настройки. + + Args: + key: Ключ настройки + default: Значение по умолчанию + + Returns: + Числовое значение или default + """ + value = await self.get_setting(key) + if value is None: + return default + try: + return float(value) + except ValueError: + self.logger.warning( + f"Невозможно преобразовать значение '{value}' в float для ключа '{key}'" + ) + return default + + async def set_float_setting(self, key: str, value: float) -> None: + """ + Устанавливает числовое значение настройки. + + Args: + key: Ключ настройки + value: Числовое значение + """ + await self.set_setting(key, str(value)) + + async def get_auto_moderation_settings(self) -> Dict[str, any]: + """ + Получает все настройки авто-модерации. + + Returns: + Словарь с настройками авто-модерации + """ + return { + "auto_publish_enabled": await self.get_bool_setting( + "auto_publish_enabled", False + ), + "auto_decline_enabled": await self.get_bool_setting( + "auto_decline_enabled", False + ), + "auto_publish_threshold": await self.get_float_setting( + "auto_publish_threshold", 0.8 + ), + "auto_decline_threshold": await self.get_float_setting( + "auto_decline_threshold", 0.4 + ), + } + + async def toggle_auto_publish(self) -> bool: + """ + Переключает состояние авто-публикации. + + Returns: + Новое состояние (True/False) + """ + current = await self.get_bool_setting("auto_publish_enabled", False) + new_value = not current + await self.set_bool_setting("auto_publish_enabled", new_value) + return new_value + + async def toggle_auto_decline(self) -> bool: + """ + Переключает состояние авто-отклонения. + + Returns: + Новое состояние (True/False) + """ + current = await self.get_bool_setting("auto_decline_enabled", False) + new_value = not current + await self.set_bool_setting("auto_decline_enabled", new_value) + return new_value diff --git a/database/repository_factory.py b/database/repository_factory.py index d218f21..728e759 100644 --- a/database/repository_factory.py +++ b/database/repository_factory.py @@ -6,6 +6,7 @@ from database.repositories.blacklist_history_repository import ( BlacklistHistoryRepository, ) from database.repositories.blacklist_repository import BlacklistRepository +from database.repositories.bot_settings_repository import BotSettingsRepository from database.repositories.message_repository import MessageRepository from database.repositories.migration_repository import MigrationRepository from database.repositories.post_repository import PostRepository @@ -25,6 +26,7 @@ class RepositoryFactory: self._admin_repo: Optional[AdminRepository] = None self._audio_repo: Optional[AudioRepository] = None self._migration_repo: Optional[MigrationRepository] = None + self._bot_settings_repo: Optional[BotSettingsRepository] = None @property def users(self) -> UserRepository: @@ -82,6 +84,13 @@ class RepositoryFactory: self._migration_repo = MigrationRepository(self.db_path) return self._migration_repo + @property + def bot_settings(self) -> BotSettingsRepository: + """Возвращает репозиторий настроек бота.""" + if self._bot_settings_repo is None: + self._bot_settings_repo = BotSettingsRepository(self.db_path) + return self._bot_settings_repo + async def create_all_tables(self): """Создает все таблицы в базе данных.""" await self.migrations.create_table() # Сначала создаем таблицу миграций @@ -92,6 +101,7 @@ class RepositoryFactory: await self.posts.create_tables() await self.admins.create_tables() await self.audio.create_tables() + await self.bot_settings.create_table() async def check_database_integrity(self): """Проверяет целостность базы данных.""" diff --git a/helper_bot/handlers/admin/admin_handlers.py b/helper_bot/handlers/admin/admin_handlers.py index c5cd1de..2db9b50 100644 --- a/helper_bot/handlers/admin/admin_handlers.py +++ b/helper_bot/handlers/admin/admin_handlers.py @@ -21,6 +21,7 @@ from helper_bot.keyboards.keyboards import ( create_keyboard_for_ban_days, create_keyboard_for_ban_reason, create_keyboard_with_pagination, + get_auto_moderation_keyboard, get_reply_keyboard_admin, ) from helper_bot.utils.base_dependency_factory import get_global_instance @@ -250,6 +251,268 @@ async def get_ml_stats(message: types.Message, state: FSMContext, **kwargs): await message.answer(f"❌ Ошибка получения статистики: {str(e)}") +# ============================================================================ +# ХЕНДЛЕРЫ АВТО-МОДЕРАЦИИ +# ============================================================================ + + +@admin_router.message( + ChatTypeFilter(chat_type=["private"]), + StateFilter("ADMIN"), + F.text == "⚙️ Авто-модерация", +) +@track_time("auto_moderation_menu", "admin_handlers") +@track_errors("admin_handlers", "auto_moderation_menu") +async def auto_moderation_menu( + message: types.Message, state: FSMContext, bot_db: MagicData("bot_db") +): + """Меню управления авто-модерацией""" + try: + logger.info( + f"Открытие меню авто-модерации пользователем: {message.from_user.full_name}" + ) + + settings = await bot_db.get_auto_moderation_settings() + + text = _format_auto_moderation_status(settings) + keyboard = get_auto_moderation_keyboard(settings) + + await message.answer(text, reply_markup=keyboard, parse_mode="HTML") + + except Exception as e: + logger.error(f"Ошибка открытия меню авто-модерации: {e}") + await message.answer(f"❌ Ошибка: {str(e)}") + + +def _format_auto_moderation_status(settings: dict) -> str: + """Форматирует текст статуса авто-модерации.""" + auto_publish = settings.get("auto_publish_enabled", False) + auto_decline = settings.get("auto_decline_enabled", False) + publish_threshold = settings.get("auto_publish_threshold", 0.8) + decline_threshold = settings.get("auto_decline_threshold", 0.4) + + publish_status = "✅ Включена" if auto_publish else "❌ Выключена" + decline_status = "✅ Включено" if auto_decline else "❌ Выключено" + + return ( + "⚙️ Авто-модерация постов\n\n" + f"🤖 Авто-публикация: {publish_status}\n" + f" Порог: RAG score ≥ {publish_threshold}\n\n" + f"🚫 Авто-отклонение: {decline_status}\n" + f" Порог: RAG score ≤ {decline_threshold}" + ) + + +@admin_router.callback_query(F.data == "auto_mod_toggle_publish") +@track_time("toggle_auto_publish", "admin_handlers") +@track_errors("admin_handlers", "toggle_auto_publish") +async def toggle_auto_publish(call: types.CallbackQuery, bot_db: MagicData("bot_db")): + """Переключение авто-публикации""" + try: + new_state = await bot_db.toggle_auto_publish() + logger.info( + f"Авто-публикация {'включена' if new_state else 'выключена'} " + f"пользователем {call.from_user.full_name}" + ) + + settings = await bot_db.get_auto_moderation_settings() + text = _format_auto_moderation_status(settings) + keyboard = get_auto_moderation_keyboard(settings) + + await call.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML") + await call.answer( + f"Авто-публикация {'включена ✅' if new_state else 'выключена ❌'}" + ) + + except Exception as e: + logger.error(f"Ошибка переключения авто-публикации: {e}") + await call.answer(f"❌ Ошибка: {str(e)}", show_alert=True) + + +@admin_router.callback_query(F.data == "auto_mod_toggle_decline") +@track_time("toggle_auto_decline", "admin_handlers") +@track_errors("admin_handlers", "toggle_auto_decline") +async def toggle_auto_decline(call: types.CallbackQuery, bot_db: MagicData("bot_db")): + """Переключение авто-отклонения""" + try: + new_state = await bot_db.toggle_auto_decline() + logger.info( + f"Авто-отклонение {'включено' if new_state else 'выключено'} " + f"пользователем {call.from_user.full_name}" + ) + + settings = await bot_db.get_auto_moderation_settings() + text = _format_auto_moderation_status(settings) + keyboard = get_auto_moderation_keyboard(settings) + + await call.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML") + await call.answer( + f"Авто-отклонение {'включено ✅' if new_state else 'выключено ❌'}" + ) + + except Exception as e: + logger.error(f"Ошибка переключения авто-отклонения: {e}") + await call.answer(f"❌ Ошибка: {str(e)}", show_alert=True) + + +@admin_router.callback_query(F.data == "auto_mod_refresh") +@track_time("refresh_auto_moderation", "admin_handlers") +@track_errors("admin_handlers", "refresh_auto_moderation") +async def refresh_auto_moderation( + call: types.CallbackQuery, bot_db: MagicData("bot_db") +): + """Обновление статуса авто-модерации""" + try: + settings = await bot_db.get_auto_moderation_settings() + text = _format_auto_moderation_status(settings) + keyboard = get_auto_moderation_keyboard(settings) + + try: + await call.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML") + except Exception as edit_error: + if "message is not modified" in str(edit_error): + pass # Сообщение не изменилось - это нормально + else: + raise + await call.answer("🔄 Обновлено") + + except Exception as e: + logger.error(f"Ошибка обновления статуса авто-модерации: {e}") + await call.answer(f"❌ Ошибка: {str(e)}", show_alert=True) + + +@admin_router.callback_query(F.data == "auto_mod_threshold_publish") +@track_time("change_publish_threshold", "admin_handlers") +@track_errors("admin_handlers", "change_publish_threshold") +async def change_publish_threshold( + call: types.CallbackQuery, state: FSMContext, bot_db: MagicData("bot_db") +): + """Начало изменения порога авто-публикации""" + try: + await state.set_state("AWAIT_PUBLISH_THRESHOLD") + await call.message.answer( + "📈 Изменение порога авто-публикации\n\n" + "Введите новое значение порога (от 0.0 до 1.0).\n" + "Посты с RAG score ≥ этого значения будут автоматически публиковаться.\n\n" + "Текущее рекомендуемое значение: 0.8", + parse_mode="HTML", + ) + await call.answer() + + except Exception as e: + logger.error(f"Ошибка начала изменения порога публикации: {e}") + await call.answer(f"❌ Ошибка: {str(e)}", show_alert=True) + + +@admin_router.callback_query(F.data == "auto_mod_threshold_decline") +@track_time("change_decline_threshold", "admin_handlers") +@track_errors("admin_handlers", "change_decline_threshold") +async def change_decline_threshold( + call: types.CallbackQuery, state: FSMContext, bot_db: MagicData("bot_db") +): + """Начало изменения порога авто-отклонения""" + try: + await state.set_state("AWAIT_DECLINE_THRESHOLD") + await call.message.answer( + "📉 Изменение порога авто-отклонения\n\n" + "Введите новое значение порога (от 0.0 до 1.0).\n" + "Посты с RAG score ≤ этого значения будут автоматически отклоняться.\n\n" + "Текущее рекомендуемое значение: 0.4", + parse_mode="HTML", + ) + await call.answer() + + except Exception as e: + logger.error(f"Ошибка начала изменения порога отклонения: {e}") + await call.answer(f"❌ Ошибка: {str(e)}", show_alert=True) + + +@admin_router.message( + ChatTypeFilter(chat_type=["private"]), + StateFilter("AWAIT_PUBLISH_THRESHOLD"), +) +@track_time("process_publish_threshold", "admin_handlers") +@track_errors("admin_handlers", "process_publish_threshold") +async def process_publish_threshold( + message: types.Message, state: FSMContext, bot_db: MagicData("bot_db") +): + """Обработка нового порога авто-публикации""" + try: + value = float(message.text.strip().replace(",", ".")) + if not 0.0 <= value <= 1.0: + raise ValueError("Значение должно быть от 0.0 до 1.0") + + await bot_db.set_float_setting("auto_publish_threshold", value) + logger.info( + f"Порог авто-публикации изменен на {value} " + f"пользователем {message.from_user.full_name}" + ) + + await state.set_state("ADMIN") + await message.answer( + f"✅ Порог авто-публикации изменен на {value}", + parse_mode="HTML", + ) + + settings = await bot_db.get_auto_moderation_settings() + text = _format_auto_moderation_status(settings) + keyboard = get_auto_moderation_keyboard(settings) + await message.answer(text, reply_markup=keyboard, parse_mode="HTML") + + except ValueError as e: + await message.answer( + f"❌ Неверное значение: {e}\n" + "Введите число от 0.0 до 1.0 (например: 0.8)" + ) + except Exception as e: + logger.error(f"Ошибка изменения порога публикации: {e}") + await state.set_state("ADMIN") + await message.answer(f"❌ Ошибка: {str(e)}") + + +@admin_router.message( + ChatTypeFilter(chat_type=["private"]), + StateFilter("AWAIT_DECLINE_THRESHOLD"), +) +@track_time("process_decline_threshold", "admin_handlers") +@track_errors("admin_handlers", "process_decline_threshold") +async def process_decline_threshold( + message: types.Message, state: FSMContext, bot_db: MagicData("bot_db") +): + """Обработка нового порога авто-отклонения""" + try: + value = float(message.text.strip().replace(",", ".")) + if not 0.0 <= value <= 1.0: + raise ValueError("Значение должно быть от 0.0 до 1.0") + + await bot_db.set_float_setting("auto_decline_threshold", value) + logger.info( + f"Порог авто-отклонения изменен на {value} " + f"пользователем {message.from_user.full_name}" + ) + + await state.set_state("ADMIN") + await message.answer( + f"✅ Порог авто-отклонения изменен на {value}", + parse_mode="HTML", + ) + + settings = await bot_db.get_auto_moderation_settings() + text = _format_auto_moderation_status(settings) + keyboard = get_auto_moderation_keyboard(settings) + await message.answer(text, reply_markup=keyboard, parse_mode="HTML") + + except ValueError as e: + await message.answer( + f"❌ Неверное значение: {e}\n" + "Введите число от 0.0 до 1.0 (например: 0.4)" + ) + except Exception as e: + logger.error(f"Ошибка изменения порога отклонения: {e}") + await state.set_state("ADMIN") + await message.answer(f"❌ Ошибка: {str(e)}") + + # ============================================================================ # ХЕНДЛЕРЫ ПРОЦЕССА БАНА # ============================================================================ diff --git a/helper_bot/handlers/private/private_handlers.py b/helper_bot/handlers/private/private_handlers.py index 3f47aa2..f7ffb7c 100644 --- a/helper_bot/handlers/private/private_handlers.py +++ b/helper_bot/handlers/private/private_handlers.py @@ -31,7 +31,13 @@ from helper_bot.utils.metrics import db_query_time, track_errors, track_time # Local imports - modular components from .constants import BUTTON_TEXTS, ERROR_MESSAGES, FSM_STATES from .decorators import error_handler -from .services import BotSettings, PostService, StickerService, UserService +from .services import ( + AutoModerationService, + BotSettings, + PostService, + StickerService, + UserService, +) # Expose sleep for tests (tests patch helper_bot.handlers.private.private_handlers.sleep) sleep = asyncio.sleep @@ -50,7 +56,12 @@ class PrivateHandlers: self.db = db self.settings = settings self.user_service = UserService(db, settings) - self.post_service = PostService(db, settings, s3_storage, scoring_manager) + self.auto_moderation_service = AutoModerationService( + db, settings, scoring_manager, s3_storage + ) + self.post_service = PostService( + db, settings, s3_storage, scoring_manager, self.auto_moderation_service + ) self.sticker_service = StickerService(settings) self.router = Router() diff --git a/helper_bot/handlers/private/services.py b/helper_bot/handlers/private/services.py index 3a6f1fa..8035503 100644 --- a/helper_bot/handlers/private/services.py +++ b/helper_bot/handlers/private/services.py @@ -22,6 +22,7 @@ from helper_bot.utils.helper_func import ( check_username_and_full_name, determine_anonymity, get_first_name, + get_publish_text, get_text_message, prepare_media_group_from_middlewares, send_audio_message, @@ -252,11 +253,13 @@ class PostService: settings: BotSettings, s3_storage=None, scoring_manager=None, + auto_moderation_service: "AutoModerationService" = None, ) -> None: self.db = db self.settings = settings self.s3_storage = s3_storage self.scoring_manager = scoring_manager + self.auto_moderation = auto_moderation_service async def _save_media_background( self, sent_message: types.Message, bot_db: Any, s3_storage @@ -379,6 +382,200 @@ class PostService: error_message = "Не удалось рассчитать скоры" return None, None, None, None, None, error_message + @track_time("_handle_auto_action", "post_service") + @track_errors("post_service", "_handle_auto_action") + async def _handle_auto_action( + self, + auto_action: str, + message: types.Message, + content_type: str, + original_raw_text: str, + first_name: str, + is_anonymous: bool, + rag_score: float, + ml_scores_json: str = None, + album: Union[list, None] = None, + ) -> None: + """ + Обрабатывает автоматическое действие (публикация или отклонение). + + Args: + auto_action: 'publish' или 'decline' + message: Сообщение пользователя + content_type: Тип контента + original_raw_text: Оригинальный текст поста + first_name: Имя автора + is_anonymous: Флаг анонимности + rag_score: Скор RAG модели + ml_scores_json: JSON со скорами для БД + album: Медиагруппа (если есть) + """ + author_id = message.from_user.id + author_name = message.from_user.full_name or first_name + author_username = message.from_user.username or "" + + try: + if auto_action == "publish": + await self._auto_publish( + message=message, + content_type=content_type, + original_raw_text=original_raw_text, + first_name=first_name, + is_anonymous=is_anonymous, + rag_score=rag_score, + ml_scores_json=ml_scores_json, + album=album, + ) + else: # decline + await self._auto_decline(message=message, author_id=author_id) + + # Логируем действие + if self.auto_moderation: + await self.auto_moderation.log_auto_action( + bot=message.bot, + action=auto_action, + author_id=author_id, + author_name=author_name, + author_username=author_username, + rag_score=rag_score, + post_text=original_raw_text, + ) + + except Exception as e: + logger.error( + f"PostService: Ошибка авто-{auto_action} для message_id={message.message_id}: {e}" + ) + raise + + @track_time("_auto_publish", "post_service") + @track_errors("post_service", "_auto_publish") + async def _auto_publish( + self, + message: types.Message, + content_type: str, + original_raw_text: str, + first_name: str, + is_anonymous: bool, + rag_score: float, + ml_scores_json: str = None, + album: Union[list, None] = None, + ) -> None: + """Автоматически публикует пост в канал.""" + author_id = message.from_user.id + username = message.from_user.username + + # Формируем текст для публикации (без скоров и разметки) + formatted_text = get_publish_text( + original_raw_text, first_name, username, is_anonymous + ) + + sent_message = None + + # Публикуем в зависимости от типа контента + if content_type == "text": + sent_message = await message.bot.send_message( + chat_id=self.settings.main_public, + text=formatted_text, + ) + elif content_type == "photo": + sent_message = await message.bot.send_photo( + chat_id=self.settings.main_public, + photo=message.photo[-1].file_id, + caption=formatted_text, + ) + elif content_type == "video": + sent_message = await message.bot.send_video( + chat_id=self.settings.main_public, + video=message.video.file_id, + caption=formatted_text, + ) + elif content_type == "audio": + sent_message = await message.bot.send_audio( + chat_id=self.settings.main_public, + audio=message.audio.file_id, + caption=formatted_text, + ) + elif content_type == "voice": + sent_message = await message.bot.send_voice( + chat_id=self.settings.main_public, + voice=message.voice.file_id, + ) + elif content_type == "video_note": + sent_message = await message.bot.send_video_note( + chat_id=self.settings.main_public, + video_note=message.video_note.file_id, + ) + elif content_type == "media_group" and album: + # TODO: Реализовать авто-публикацию медиагрупп при необходимости + logger.warning( + "PostService: Авто-публикация медиагрупп пока не поддерживается" + ) + return + + if sent_message: + # Сохраняем пост в БД со статусом approved + post = TelegramPost( + message_id=sent_message.message_id, + text=original_raw_text, + author_id=author_id, + created_at=int(datetime.now().timestamp()), + is_anonymous=is_anonymous, + status="approved", + ) + await self.db.add_post(post) + + # Сохраняем скоры если есть + if ml_scores_json: + asyncio.create_task( + self._save_scores_background(sent_message.message_id, ml_scores_json) + ) + + # Индексируем пост в RAG + if self.scoring_manager and original_raw_text and original_raw_text.strip(): + asyncio.create_task( + self._add_submitted_post_background( + original_raw_text, sent_message.message_id, rag_score + ) + ) + + # Уведомляем автора + try: + await message.bot.send_message( + chat_id=author_id, + text="Твой пост был выложен🥰", + ) + except Exception as e: + logger.warning(f"PostService: Не удалось уведомить автора {author_id}: {e}") + + logger.info( + f"PostService: Пост авто-опубликован в {self.settings.main_public}, " + f"author_id={author_id}, rag_score={rag_score:.2f}" + ) + + @track_time("_auto_decline", "post_service") + @track_errors("post_service", "_auto_decline") + async def _auto_decline(self, message: types.Message, author_id: int) -> None: + """Автоматически отклоняет пост.""" + # Обучаем RAG на отклоненном посте + if self.scoring_manager: + original_text = message.text or message.caption or "" + if original_text and original_text.strip(): + try: + await self.scoring_manager.on_post_declined(original_text) + except Exception as e: + logger.warning(f"PostService: Ошибка обучения RAG на отклоненном посте: {e}") + + # Уведомляем автора + try: + await message.bot.send_message( + chat_id=author_id, + text="Твой пост был отклонен😔", + ) + except Exception as e: + logger.warning(f"PostService: Не удалось уведомить автора {author_id}: {e}") + + logger.info(f"PostService: Пост авто-отклонен, author_id={author_id}") + @track_time("_process_post_background", "post_service") @track_errors("post_service", "_process_post_background") async def _process_post_background( @@ -485,6 +682,33 @@ class PostService: # Определяем анонимность по исходному тексту (без сообщения об ошибке) is_anonymous = determine_anonymity(original_raw_text) + # Проверяем авто-модерацию + logger.debug( + f"PostService: Проверка авто-модерации - " + f"auto_moderation={self.auto_moderation is not None}, " + f"rag_score={rag_score}" + ) + if self.auto_moderation and rag_score is not None: + auto_action = await self.auto_moderation.check_auto_action(rag_score) + logger.info( + f"PostService: Авто-модерация решение - " + f"rag_score={rag_score:.2f}, action={auto_action}" + ) + + if auto_action in ("publish", "decline"): + await self._handle_auto_action( + auto_action=auto_action, + message=message, + content_type=content_type, + original_raw_text=original_raw_text, + first_name=first_name, + is_anonymous=is_anonymous, + rag_score=rag_score, + ml_scores_json=ml_scores_json, + album=album, + ) + return + markup = get_reply_keyboard_for_post() sent_message = None @@ -1128,3 +1352,128 @@ class StickerService: random_stick_bye = random.choice(name_stick_bye) random_stick_bye = FSInputFile(path=random_stick_bye) await message.answer_sticker(random_stick_bye) + + +class AutoModerationService: + """ + Сервис автоматической модерации постов на основе RAG score. + + Автоматически публикует посты с высоким скором и отклоняет с низким. + """ + + def __init__( + self, + db: DatabaseProtocol, + settings: BotSettings, + scoring_manager=None, + s3_storage=None, + ) -> None: + self.db = db + self.settings = settings + self.scoring_manager = scoring_manager + self.s3_storage = s3_storage + + @track_time("check_auto_action", "auto_moderation_service") + async def check_auto_action(self, rag_score: float) -> str: + """ + Проверяет, требуется ли автоматическое действие. + + Args: + rag_score: Скор от RAG модели (0.0 - 1.0) + + Returns: + 'publish' - автопубликация + 'decline' - автоотклонение + 'manual' - ручная модерация + """ + if rag_score is None: + return "manual" + + settings = await self.db.get_auto_moderation_settings() + + auto_publish_enabled = settings.get("auto_publish_enabled", False) + auto_decline_enabled = settings.get("auto_decline_enabled", False) + auto_publish_threshold = settings.get("auto_publish_threshold", 0.8) + auto_decline_threshold = settings.get("auto_decline_threshold", 0.4) + + logger.info( + f"AutoModeration: Настройки из БД - " + f"publish_enabled={auto_publish_enabled}, decline_enabled={auto_decline_enabled}, " + f"publish_threshold={auto_publish_threshold}, decline_threshold={auto_decline_threshold}, " + f"rag_score={rag_score:.2f}" + ) + + if auto_publish_enabled and rag_score >= auto_publish_threshold: + logger.info( + f"AutoModeration: score {rag_score:.2f} >= {auto_publish_threshold} → auto_publish" + ) + return "publish" + + if auto_decline_enabled and rag_score <= auto_decline_threshold: + logger.info( + f"AutoModeration: score {rag_score:.2f} <= {auto_decline_threshold} → auto_decline" + ) + return "decline" + + return "manual" + + @track_time("log_auto_action", "auto_moderation_service") + async def log_auto_action( + self, + bot, + action: str, + author_id: int, + author_name: str, + author_username: str, + rag_score: float, + post_text: str, + ) -> None: + """ + Отправляет лог автоматического действия в IMPORTANT_LOGS. + + Args: + bot: Экземпляр бота для отправки сообщений + action: Тип действия ('publish' или 'decline') + author_id: ID автора поста + author_name: Имя автора + author_username: Username автора + rag_score: Скор модели + post_text: Текст поста + """ + try: + safe_name = html.escape(author_name or "Без имени") + safe_username = html.escape(author_username or "нет") + + truncated_text = post_text[:200] if post_text else "" + if len(post_text or "") > 200: + truncated_text += "..." + safe_text = html.escape(truncated_text) + + if action == "publish": + emoji = "🤖" + action_title = "АВТО-ПУБЛИКАЦИЯ" + action_result = "✅ Пост автоматически опубликован" + else: + emoji = "🚫" + action_title = "АВТО-ОТКЛОНЕНИЕ" + action_result = "❌ Пост автоматически отклонён" + + message_text = ( + f"{emoji} {action_title}\n\n" + f"👤 Автор: {safe_name} (@{safe_username}) | ID: {author_id}\n" + f"📊 RAG Score: {rag_score:.2f}\n\n" + f"📝 Текст поста:\n" + f'"{safe_text}"\n\n' + f"{action_result}" + ) + + await bot.send_message( + chat_id=self.settings.important_logs, + text=message_text, + parse_mode="HTML", + ) + logger.info( + f"AutoModeration: Лог отправлен в IMPORTANT_LOGS ({action})" + ) + except Exception as e: + logger.error(f"AutoModeration: Ошибка отправки лога: {e}") diff --git a/helper_bot/keyboards/keyboards.py b/helper_bot/keyboards/keyboards.py index ed605ad..40f26bd 100644 --- a/helper_bot/keyboards/keyboards.py +++ b/helper_bot/keyboards/keyboards.py @@ -46,11 +46,64 @@ def get_reply_keyboard_admin(): types.KeyboardButton(text="Разбан (список)"), types.KeyboardButton(text="📊 ML Статистика"), ) + builder.row(types.KeyboardButton(text="⚙️ Авто-модерация")) builder.row(types.KeyboardButton(text="Вернуться в бота")) markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True) return markup +def get_auto_moderation_keyboard(settings: dict) -> types.InlineKeyboardMarkup: + """ + Создает inline клавиатуру для управления авто-модерацией. + + Args: + settings: Словарь с текущими настройками авто-модерации + + Returns: + InlineKeyboardMarkup с кнопками управления + """ + builder = InlineKeyboardBuilder() + + auto_publish = settings.get("auto_publish_enabled", False) + auto_decline = settings.get("auto_decline_enabled", False) + publish_threshold = settings.get("auto_publish_threshold", 0.8) + decline_threshold = settings.get("auto_decline_threshold", 0.4) + + publish_status = "✅" if auto_publish else "❌" + decline_status = "✅" if auto_decline else "❌" + + builder.row( + types.InlineKeyboardButton( + text=f"{publish_status} Авто-публикация (≥{publish_threshold})", + callback_data="auto_mod_toggle_publish", + ) + ) + builder.row( + types.InlineKeyboardButton( + text=f"{decline_status} Авто-отклонение (≤{decline_threshold})", + callback_data="auto_mod_toggle_decline", + ) + ) + builder.row( + types.InlineKeyboardButton( + text="📈 Изменить порог публикации", + callback_data="auto_mod_threshold_publish", + ), + types.InlineKeyboardButton( + text="📉 Изменить порог отклонения", + callback_data="auto_mod_threshold_decline", + ), + ) + builder.row( + types.InlineKeyboardButton( + text="🔄 Обновить", + callback_data="auto_mod_refresh", + ) + ) + + return builder.as_markup() + + @track_time("create_keyboard_with_pagination", "keyboard_service") @track_errors("keyboard_service", "create_keyboard_with_pagination") def create_keyboard_with_pagination( diff --git a/scripts/create_bot_settings_table.py b/scripts/create_bot_settings_table.py new file mode 100644 index 0000000..a13c033 --- /dev/null +++ b/scripts/create_bot_settings_table.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +""" +Миграция: Создание таблицы bot_settings для хранения настроек бота. + +Создает таблицу с ключ-значение для хранения: +- auto_publish_enabled: Включена ли авто-публикация (default: false) +- auto_decline_enabled: Включено ли авто-отклонение (default: false) +- auto_publish_threshold: Порог для авто-публикации (default: 0.8) +- auto_decline_threshold: Порог для авто-отклонения (default: 0.4) +""" + +import argparse +import asyncio +import os +import sys +from pathlib import Path + +project_root = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(project_root)) + +import aiosqlite + +try: + from logs.custom_logger import logger +except ImportError: + import logging + + logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" + ) + logger = logging.getLogger(__name__) + +DEFAULT_DB_PATH = "database/tg-bot-database.db" + +DEFAULT_SETTINGS = [ + ("auto_publish_enabled", "false"), + ("auto_decline_enabled", "false"), + ("auto_publish_threshold", "0.8"), + ("auto_decline_threshold", "0.4"), +] + + +async def table_exists(conn: aiosqlite.Connection, table_name: str) -> bool: + """Проверяет существование таблицы.""" + cursor = await conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?", + (table_name,), + ) + result = await cursor.fetchone() + return result is not None + + +async def main(db_path: str) -> None: + """ + Основная функция миграции. + + Создает таблицу bot_settings и добавляет дефолтные настройки. + Миграция идемпотентна - можно запускать повторно без ошибок. + """ + db_path = os.path.abspath(db_path) + + if not os.path.exists(db_path): + logger.error(f"База данных не найдена: {db_path}") + return + + async with aiosqlite.connect(db_path) as conn: + await conn.execute("PRAGMA foreign_keys = ON") + + if not await table_exists(conn, "bot_settings"): + await conn.execute( + """ + CREATE TABLE bot_settings ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + key TEXT NOT NULL UNIQUE, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) + ) + """ + ) + logger.info("Таблица bot_settings создана") + + for key, value in DEFAULT_SETTINGS: + await conn.execute( + "INSERT INTO bot_settings (key, value) VALUES (?, ?)", + (key, value), + ) + logger.info(f"Добавлена настройка: {key} = {value}") + else: + logger.info("Таблица bot_settings уже существует") + + for key, value in DEFAULT_SETTINGS: + cursor = await conn.execute( + "SELECT COUNT(*) FROM bot_settings WHERE key = ?", (key,) + ) + row = await cursor.fetchone() + if row[0] == 0: + await conn.execute( + "INSERT INTO bot_settings (key, value) VALUES (?, ?)", + (key, value), + ) + logger.info(f"Добавлена отсутствующая настройка: {key} = {value}") + + await conn.commit() + logger.info("Миграция create_bot_settings_table завершена успешно") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Создание таблицы bot_settings для настроек авто-модерации" + ) + parser.add_argument( + "--db", + default=os.environ.get("DATABASE_PATH", DEFAULT_DB_PATH), + help="Путь к БД", + ) + args = parser.parse_args() + asyncio.run(main(args.db)) diff --git a/tests/test_auto_moderation_service.py b/tests/test_auto_moderation_service.py new file mode 100644 index 0000000..c9e6b7e --- /dev/null +++ b/tests/test_auto_moderation_service.py @@ -0,0 +1,222 @@ +"""Тесты для AutoModerationService.""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from helper_bot.handlers.private.services import AutoModerationService, BotSettings + + +class TestAutoModerationService: + """Тесты для сервиса авто-модерации.""" + + @pytest.fixture + def mock_db(self): + """Создает мок базы данных.""" + db = MagicMock() + db.get_auto_moderation_settings = AsyncMock() + return db + + @pytest.fixture + def settings(self): + """Создает настройки бота.""" + return BotSettings( + group_for_posts="-123", + group_for_message="-456", + main_public="@test_channel", + group_for_logs="-789", + important_logs="-999", + preview_link="false", + logs="false", + test="false", + ) + + @pytest.fixture + def service(self, mock_db, settings): + """Создает экземпляр сервиса.""" + return AutoModerationService(mock_db, settings) + + @pytest.mark.asyncio + async def test_check_auto_action_returns_manual_when_score_is_none( + self, service, mock_db + ): + """Тест: возвращает manual когда score равен None.""" + result = await service.check_auto_action(None) + assert result == "manual" + + @pytest.mark.asyncio + async def test_check_auto_action_returns_publish_when_score_above_threshold( + self, service, mock_db + ): + """Тест: возвращает publish когда score выше порога.""" + mock_db.get_auto_moderation_settings.return_value = { + "auto_publish_enabled": True, + "auto_decline_enabled": False, + "auto_publish_threshold": 0.8, + "auto_decline_threshold": 0.4, + } + + result = await service.check_auto_action(0.9) + + assert result == "publish" + + @pytest.mark.asyncio + async def test_check_auto_action_returns_decline_when_score_below_threshold( + self, service, mock_db + ): + """Тест: возвращает decline когда score ниже порога.""" + mock_db.get_auto_moderation_settings.return_value = { + "auto_publish_enabled": False, + "auto_decline_enabled": True, + "auto_publish_threshold": 0.8, + "auto_decline_threshold": 0.4, + } + + result = await service.check_auto_action(0.3) + + assert result == "decline" + + @pytest.mark.asyncio + async def test_check_auto_action_returns_manual_when_disabled( + self, service, mock_db + ): + """Тест: возвращает manual когда авто-действия отключены.""" + mock_db.get_auto_moderation_settings.return_value = { + "auto_publish_enabled": False, + "auto_decline_enabled": False, + "auto_publish_threshold": 0.8, + "auto_decline_threshold": 0.4, + } + + result = await service.check_auto_action(0.9) + + assert result == "manual" + + @pytest.mark.asyncio + async def test_check_auto_action_returns_manual_when_score_in_middle( + self, service, mock_db + ): + """Тест: возвращает manual когда score между порогами.""" + mock_db.get_auto_moderation_settings.return_value = { + "auto_publish_enabled": True, + "auto_decline_enabled": True, + "auto_publish_threshold": 0.8, + "auto_decline_threshold": 0.4, + } + + result = await service.check_auto_action(0.6) + + assert result == "manual" + + @pytest.mark.asyncio + async def test_check_auto_action_publish_at_exact_threshold( + self, service, mock_db + ): + """Тест: возвращает publish когда score равен порогу.""" + mock_db.get_auto_moderation_settings.return_value = { + "auto_publish_enabled": True, + "auto_decline_enabled": False, + "auto_publish_threshold": 0.8, + "auto_decline_threshold": 0.4, + } + + result = await service.check_auto_action(0.8) + + assert result == "publish" + + @pytest.mark.asyncio + async def test_check_auto_action_decline_at_exact_threshold( + self, service, mock_db + ): + """Тест: возвращает decline когда score равен порогу.""" + mock_db.get_auto_moderation_settings.return_value = { + "auto_publish_enabled": False, + "auto_decline_enabled": True, + "auto_publish_threshold": 0.8, + "auto_decline_threshold": 0.4, + } + + result = await service.check_auto_action(0.4) + + assert result == "decline" + + @pytest.mark.asyncio + async def test_log_auto_action_publish(self, service, settings): + """Тест отправки лога для авто-публикации.""" + mock_bot = MagicMock() + mock_bot.send_message = AsyncMock() + + await service.log_auto_action( + bot=mock_bot, + action="publish", + author_id=12345, + author_name="Test User", + author_username="testuser", + rag_score=0.85, + post_text="Test post text", + ) + + mock_bot.send_message.assert_called_once() + call_kwargs = mock_bot.send_message.call_args[1] + assert call_kwargs["chat_id"] == settings.important_logs + assert "АВТО-ПУБЛИКАЦИЯ" in call_kwargs["text"] + assert "Test User" in call_kwargs["text"] + assert "0.85" in call_kwargs["text"] + + @pytest.mark.asyncio + async def test_log_auto_action_decline(self, service, settings): + """Тест отправки лога для авто-отклонения.""" + mock_bot = MagicMock() + mock_bot.send_message = AsyncMock() + + await service.log_auto_action( + bot=mock_bot, + action="decline", + author_id=12345, + author_name="Test User", + author_username="testuser", + rag_score=0.25, + post_text="Test post text", + ) + + mock_bot.send_message.assert_called_once() + call_kwargs = mock_bot.send_message.call_args[1] + assert "АВТО-ОТКЛОНЕНИЕ" in call_kwargs["text"] + + @pytest.mark.asyncio + async def test_log_auto_action_handles_exception(self, service): + """Тест обработки исключения при отправке лога.""" + mock_bot = MagicMock() + mock_bot.send_message = AsyncMock(side_effect=Exception("Network error")) + + # Не должно выбрасывать исключение + await service.log_auto_action( + bot=mock_bot, + action="publish", + author_id=12345, + author_name="Test User", + author_username="testuser", + rag_score=0.85, + post_text="Test post text", + ) + + @pytest.mark.asyncio + async def test_log_auto_action_truncates_long_text(self, service): + """Тест обрезки длинного текста в логе.""" + mock_bot = MagicMock() + mock_bot.send_message = AsyncMock() + + long_text = "a" * 300 + + await service.log_auto_action( + bot=mock_bot, + action="publish", + author_id=12345, + author_name="Test User", + author_username="testuser", + rag_score=0.85, + post_text=long_text, + ) + + call_kwargs = mock_bot.send_message.call_args[1] + # Текст должен быть обрезан до 200 символов + "..." + assert "..." in call_kwargs["text"] diff --git a/tests/test_bot_settings_repository.py b/tests/test_bot_settings_repository.py new file mode 100644 index 0000000..bd0af31 --- /dev/null +++ b/tests/test_bot_settings_repository.py @@ -0,0 +1,161 @@ +"""Тесты для BotSettingsRepository.""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from database.repositories.bot_settings_repository import BotSettingsRepository + + +class TestBotSettingsRepository: + """Тесты для репозитория настроек бота.""" + + @pytest.fixture + def repository(self): + """Создает экземпляр репозитория с замоканным путем к БД.""" + return BotSettingsRepository("test.db") + + @pytest.mark.asyncio + async def test_get_setting_returns_value(self, repository): + """Тест получения настройки по ключу.""" + with patch.object( + repository, "_execute_query_with_result", new_callable=AsyncMock + ) as mock_query: + mock_query.return_value = [("true",)] + + result = await repository.get_setting("auto_publish_enabled") + + assert result == "true" + mock_query.assert_called_once() + + @pytest.mark.asyncio + async def test_get_setting_returns_none_when_not_found(self, repository): + """Тест получения несуществующей настройки.""" + with patch.object( + repository, "_execute_query_with_result", new_callable=AsyncMock + ) as mock_query: + mock_query.return_value = [] + + result = await repository.get_setting("nonexistent_key") + + assert result is None + + @pytest.mark.asyncio + async def test_set_setting(self, repository): + """Тест установки настройки.""" + with patch.object( + repository, "_execute_query", new_callable=AsyncMock + ) as mock_query: + await repository.set_setting("auto_publish_enabled", "true") + + mock_query.assert_called_once() + call_args = mock_query.call_args[0] + assert "auto_publish_enabled" in str(call_args) + assert "true" in str(call_args) + + @pytest.mark.asyncio + async def test_get_bool_setting_true(self, repository): + """Тест получения булевой настройки со значением true.""" + with patch.object( + repository, "get_setting", new_callable=AsyncMock + ) as mock_get: + mock_get.return_value = "true" + + result = await repository.get_bool_setting("auto_publish_enabled") + + assert result is True + + @pytest.mark.asyncio + async def test_get_bool_setting_false(self, repository): + """Тест получения булевой настройки со значением false.""" + with patch.object( + repository, "get_setting", new_callable=AsyncMock + ) as mock_get: + mock_get.return_value = "false" + + result = await repository.get_bool_setting("auto_publish_enabled") + + assert result is False + + @pytest.mark.asyncio + async def test_get_bool_setting_default(self, repository): + """Тест получения булевой настройки с дефолтным значением.""" + with patch.object( + repository, "get_setting", new_callable=AsyncMock + ) as mock_get: + mock_get.return_value = None + + result = await repository.get_bool_setting("auto_publish_enabled", True) + + assert result is True + + @pytest.mark.asyncio + async def test_get_float_setting(self, repository): + """Тест получения числовой настройки.""" + with patch.object( + repository, "get_setting", new_callable=AsyncMock + ) as mock_get: + mock_get.return_value = "0.8" + + result = await repository.get_float_setting("auto_publish_threshold") + + assert result == 0.8 + + @pytest.mark.asyncio + async def test_get_float_setting_invalid_value(self, repository): + """Тест получения числовой настройки с некорректным значением.""" + with patch.object( + repository, "get_setting", new_callable=AsyncMock + ) as mock_get: + mock_get.return_value = "invalid" + + result = await repository.get_float_setting("auto_publish_threshold", 0.5) + + assert result == 0.5 + + @pytest.mark.asyncio + async def test_get_auto_moderation_settings(self, repository): + """Тест получения всех настроек авто-модерации.""" + with patch.object( + repository, "get_bool_setting", new_callable=AsyncMock + ) as mock_bool, patch.object( + repository, "get_float_setting", new_callable=AsyncMock + ) as mock_float: + mock_bool.side_effect = [True, False] + mock_float.side_effect = [0.8, 0.4] + + result = await repository.get_auto_moderation_settings() + + assert result["auto_publish_enabled"] is True + assert result["auto_decline_enabled"] is False + assert result["auto_publish_threshold"] == 0.8 + assert result["auto_decline_threshold"] == 0.4 + + @pytest.mark.asyncio + async def test_toggle_auto_publish(self, repository): + """Тест переключения авто-публикации.""" + with patch.object( + repository, "get_bool_setting", new_callable=AsyncMock + ) as mock_get, patch.object( + repository, "set_bool_setting", new_callable=AsyncMock + ) as mock_set: + mock_get.return_value = False + + result = await repository.toggle_auto_publish() + + assert result is True + mock_set.assert_called_once_with("auto_publish_enabled", True) + + @pytest.mark.asyncio + async def test_toggle_auto_decline(self, repository): + """Тест переключения авто-отклонения.""" + with patch.object( + repository, "get_bool_setting", new_callable=AsyncMock + ) as mock_get, patch.object( + repository, "set_bool_setting", new_callable=AsyncMock + ) as mock_set: + mock_get.return_value = True + + result = await repository.toggle_auto_decline() + + assert result is False + mock_set.assert_called_once_with("auto_decline_enabled", False) diff --git a/tests/test_keyboards_and_filters.py b/tests/test_keyboards_and_filters.py index 48de3c1..74a5cb9 100644 --- a/tests/test_keyboards_and_filters.py +++ b/tests/test_keyboards_and_filters.py @@ -115,7 +115,7 @@ class TestKeyboards: assert isinstance(keyboard, ReplyKeyboardMarkup) assert keyboard.keyboard is not None - assert len(keyboard.keyboard) == 3 # Три строки + assert len(keyboard.keyboard) == 4 # Четыре строки # Проверяем первую строку (3 кнопки) first_row = keyboard.keyboard[0] @@ -130,10 +130,15 @@ class TestKeyboards: assert second_row[0].text == "Разбан (список)" assert second_row[1].text == "📊 ML Статистика" - # Проверяем третью строку (1 кнопка) + # Проверяем третью строку (1 кнопка - авто-модерация) third_row = keyboard.keyboard[2] assert len(third_row) == 1 - assert third_row[0].text == "Вернуться в бота" + assert third_row[0].text == "⚙️ Авто-модерация" + + # Проверяем четвертую строку (1 кнопка) + fourth_row = keyboard.keyboard[3] + assert len(fourth_row) == 1 + assert fourth_row[0].text == "Вернуться в бота" def test_get_reply_keyboard_for_post(self): """Тест клавиатуры для постов""" -- 2.49.1 From d0c8dab24a0dfbf9e7b945afdc212fb89ac9acc1 Mon Sep 17 00:00:00 2001 From: Andrey Date: Sat, 28 Feb 2026 23:01:21 +0300 Subject: [PATCH 4/5] fix imports --- database/async_db.py | 1 - database/base.py | 1 - database/repositories/migration_repository.py | 1 - helper_bot/handlers/admin/admin_handlers.py | 1 - helper_bot/handlers/admin/dependencies.py | 1 - helper_bot/handlers/admin/rate_limit_handlers.py | 1 - helper_bot/handlers/admin/utils.py | 1 - helper_bot/handlers/callback/callback_handlers.py | 1 - helper_bot/handlers/callback/dependency_factory.py | 1 - helper_bot/handlers/callback/services.py | 1 - helper_bot/handlers/private/services.py | 1 - helper_bot/handlers/voice/services.py | 1 - helper_bot/handlers/voice/voice_handler.py | 1 - helper_bot/main.py | 1 - helper_bot/middlewares/blacklist_middleware.py | 1 - helper_bot/middlewares/dependencies_middleware.py | 1 - helper_bot/middlewares/rate_limit_middleware.py | 1 - helper_bot/services/scoring/deepseek_service.py | 1 - helper_bot/services/scoring/rag_client.py | 1 - helper_bot/utils/auto_unban_scheduler.py | 1 - helper_bot/utils/base_dependency_factory.py | 3 +-- helper_bot/utils/helper_func.py | 1 - helper_bot/utils/rate_limiter.py | 1 - helper_bot/utils/s3_storage.py | 1 - tests/conftest.py | 2 +- tests/conftest_message_repository.py | 1 - tests/conftest_post_repository.py | 1 - tests/test_admin_dependencies.py | 1 - tests/test_admin_handlers.py | 1 - tests/test_admin_repository.py | 1 - tests/test_admin_utils.py | 1 - tests/test_album_middleware.py | 1 - tests/test_async_db.py | 1 - tests/test_audio_file_service.py | 1 - tests/test_audio_repository.py | 1 - tests/test_audio_repository_schema.py | 1 - tests/test_auto_moderation_service.py | 2 +- tests/test_auto_unban_integration.py | 1 - tests/test_auto_unban_scheduler.py | 1 - tests/test_blacklist_history_repository.py | 1 - tests/test_blacklist_middleware.py | 1 - tests/test_blacklist_repository.py | 1 - tests/test_bot_settings_repository.py | 2 +- tests/test_callback_dependency_factory.py | 1 - tests/test_callback_handlers.py | 1 - tests/test_callback_services.py | 1 - tests/test_decorators.py | 1 - tests/test_deepseek_service.py | 1 - tests/test_dependencies_middleware.py | 1 - tests/test_improved_media_processing.py | 1 - tests/test_keyboards_and_filters.py | 1 - tests/test_main.py | 1 - tests/test_message_repository.py | 1 - tests/test_message_repository_integration.py | 1 - tests/test_metrics_middleware.py | 1 - tests/test_post_repository.py | 1 - tests/test_post_repository_integration.py | 1 - tests/test_post_service.py | 1 - tests/test_rag_client.py | 1 - tests/test_rate_limit_middleware.py | 1 - tests/test_rate_limit_monitor.py | 1 - tests/test_rate_limiter.py | 1 - tests/test_refactored_admin_handlers.py | 1 - tests/test_refactored_group_handlers.py | 1 - tests/test_refactored_private_handlers.py | 1 - tests/test_s3_storage.py | 1 - tests/test_scoring_services.py | 1 - tests/test_server_prometheus.py | 1 - tests/test_text_middleware.py | 1 - tests/test_utils.py | 3 +-- tests/test_voice_bot_architecture.py | 1 - tests/test_voice_constants.py | 1 - tests/test_voice_exceptions.py | 1 - tests/test_voice_handler.py | 1 - tests/test_voice_services.py | 1 - tests/test_voice_utils.py | 1 - 76 files changed, 5 insertions(+), 78 deletions(-) diff --git a/database/async_db.py b/database/async_db.py index f36a324..a142a66 100644 --- a/database/async_db.py +++ b/database/async_db.py @@ -2,7 +2,6 @@ from datetime import datetime from typing import Any, Dict, List, Optional, Tuple import aiosqlite - from database.models import ( Admin, AudioMessage, diff --git a/database/base.py b/database/base.py index ca32425..4fb6fb0 100644 --- a/database/base.py +++ b/database/base.py @@ -2,7 +2,6 @@ import os from typing import Optional import aiosqlite - from logs.custom_logger import logger diff --git a/database/repositories/migration_repository.py b/database/repositories/migration_repository.py index 8c7a02b..628ecee 100644 --- a/database/repositories/migration_repository.py +++ b/database/repositories/migration_repository.py @@ -1,7 +1,6 @@ """Репозиторий для работы с миграциями базы данных.""" import aiosqlite - from database.base import DatabaseConnection diff --git a/helper_bot/handlers/admin/admin_handlers.py b/helper_bot/handlers/admin/admin_handlers.py index 2db9b50..468f234 100644 --- a/helper_bot/handlers/admin/admin_handlers.py +++ b/helper_bot/handlers/admin/admin_handlers.py @@ -1,7 +1,6 @@ from aiogram import F, Router, types from aiogram.filters import Command, MagicData, StateFilter from aiogram.fsm.context import FSMContext - from helper_bot.filters.main import ChatTypeFilter from helper_bot.handlers.admin.dependencies import AdminAccessMiddleware from helper_bot.handlers.admin.exceptions import ( diff --git a/helper_bot/handlers/admin/dependencies.py b/helper_bot/handlers/admin/dependencies.py index 89a486f..9774837 100644 --- a/helper_bot/handlers/admin/dependencies.py +++ b/helper_bot/handlers/admin/dependencies.py @@ -7,7 +7,6 @@ except ImportError: from aiogram import BaseMiddleware from aiogram.types import TelegramObject - from helper_bot.utils.base_dependency_factory import get_global_instance from helper_bot.utils.helper_func import check_access from logs.custom_logger import logger diff --git a/helper_bot/handlers/admin/rate_limit_handlers.py b/helper_bot/handlers/admin/rate_limit_handlers.py index 2837121..143e9fb 100644 --- a/helper_bot/handlers/admin/rate_limit_handlers.py +++ b/helper_bot/handlers/admin/rate_limit_handlers.py @@ -6,7 +6,6 @@ from aiogram import F, Router, types from aiogram.filters import Command, MagicData from aiogram.fsm.context import FSMContext from aiogram.types import FSInputFile - from helper_bot.filters.main import ChatTypeFilter from helper_bot.middlewares.dependencies_middleware import DependenciesMiddleware diff --git a/helper_bot/handlers/admin/utils.py b/helper_bot/handlers/admin/utils.py index 74fea5c..b9cf51c 100644 --- a/helper_bot/handlers/admin/utils.py +++ b/helper_bot/handlers/admin/utils.py @@ -3,7 +3,6 @@ from typing import Optional from aiogram import types from aiogram.fsm.context import FSMContext - from helper_bot.handlers.admin.exceptions import AdminError from helper_bot.keyboards.keyboards import get_reply_keyboard_admin from logs.custom_logger import logger diff --git a/helper_bot/handlers/callback/callback_handlers.py b/helper_bot/handlers/callback/callback_handlers.py index f652ceb..1c34304 100644 --- a/helper_bot/handlers/callback/callback_handlers.py +++ b/helper_bot/handlers/callback/callback_handlers.py @@ -7,7 +7,6 @@ from aiogram import F, Router from aiogram.filters import MagicData from aiogram.fsm.context import FSMContext from aiogram.types import CallbackQuery - from helper_bot.handlers.admin.utils import format_user_info from helper_bot.handlers.voice.constants import CALLBACK_DELETE, CALLBACK_SAVE from helper_bot.handlers.voice.services import AudioFileService diff --git a/helper_bot/handlers/callback/dependency_factory.py b/helper_bot/handlers/callback/dependency_factory.py index a8b376f..3a4ca99 100644 --- a/helper_bot/handlers/callback/dependency_factory.py +++ b/helper_bot/handlers/callback/dependency_factory.py @@ -3,7 +3,6 @@ from typing import Callable from aiogram import Bot from aiogram.client.default import DefaultBotProperties from aiogram.fsm.context import FSMContext - from helper_bot.utils.base_dependency_factory import get_global_instance from .services import BanService, PostPublishService diff --git a/helper_bot/handlers/callback/services.py b/helper_bot/handlers/callback/services.py index 910d852..40dba23 100644 --- a/helper_bot/handlers/callback/services.py +++ b/helper_bot/handlers/callback/services.py @@ -4,7 +4,6 @@ from typing import Any, Dict from aiogram import Bot, types from aiogram.types import CallbackQuery - from helper_bot.keyboards.keyboards import create_keyboard_for_ban_reason from helper_bot.utils.helper_func import ( delete_user_blacklist, diff --git a/helper_bot/handlers/private/services.py b/helper_bot/handlers/private/services.py index 8035503..d7436b3 100644 --- a/helper_bot/handlers/private/services.py +++ b/helper_bot/handlers/private/services.py @@ -12,7 +12,6 @@ from typing import Any, Callable, Dict, Protocol, Union # Third-party imports from aiogram import types from aiogram.types import FSInputFile - from database.models import TelegramPost, User from helper_bot.keyboards import get_reply_keyboard_for_post diff --git a/helper_bot/handlers/voice/services.py b/helper_bot/handlers/voice/services.py index 36808ee..cc68ff8 100644 --- a/helper_bot/handlers/voice/services.py +++ b/helper_bot/handlers/voice/services.py @@ -7,7 +7,6 @@ from pathlib import Path from typing import List, Optional, Tuple from aiogram.types import FSInputFile - from helper_bot.handlers.voice.constants import ( MESSAGE_DELAY_1, MESSAGE_DELAY_2, diff --git a/helper_bot/handlers/voice/voice_handler.py b/helper_bot/handlers/voice/voice_handler.py index 3ea223e..f3124b6 100644 --- a/helper_bot/handlers/voice/voice_handler.py +++ b/helper_bot/handlers/voice/voice_handler.py @@ -6,7 +6,6 @@ from aiogram import F, Router, types from aiogram.filters import Command, MagicData, StateFilter from aiogram.fsm.context import FSMContext from aiogram.types import FSInputFile - from helper_bot.filters.main import ChatTypeFilter from helper_bot.handlers.private.constants import BUTTON_TEXTS, FSM_STATES from helper_bot.handlers.voice.constants import * diff --git a/helper_bot/main.py b/helper_bot/main.py index f055db6..942888b 100644 --- a/helper_bot/main.py +++ b/helper_bot/main.py @@ -6,7 +6,6 @@ from aiogram import Bot, Dispatcher from aiogram.client.default import DefaultBotProperties from aiogram.fsm.storage.memory import MemoryStorage from aiogram.fsm.strategy import FSMStrategy - from helper_bot.handlers.admin import admin_router from helper_bot.handlers.callback import callback_router from helper_bot.handlers.group import group_router diff --git a/helper_bot/middlewares/blacklist_middleware.py b/helper_bot/middlewares/blacklist_middleware.py index 32279e2..05bdd17 100644 --- a/helper_bot/middlewares/blacklist_middleware.py +++ b/helper_bot/middlewares/blacklist_middleware.py @@ -4,7 +4,6 @@ from typing import Any, Dict from aiogram import BaseMiddleware, types from aiogram.types import CallbackQuery, Message, TelegramObject - from helper_bot.utils.base_dependency_factory import get_global_instance from logs.custom_logger import logger diff --git a/helper_bot/middlewares/dependencies_middleware.py b/helper_bot/middlewares/dependencies_middleware.py index d18c28c..ce266c0 100644 --- a/helper_bot/middlewares/dependencies_middleware.py +++ b/helper_bot/middlewares/dependencies_middleware.py @@ -2,7 +2,6 @@ from typing import Any, Dict from aiogram import BaseMiddleware from aiogram.types import TelegramObject - from helper_bot.utils.base_dependency_factory import get_global_instance from logs.custom_logger import logger diff --git a/helper_bot/middlewares/rate_limit_middleware.py b/helper_bot/middlewares/rate_limit_middleware.py index c50ef88..83d9ef0 100644 --- a/helper_bot/middlewares/rate_limit_middleware.py +++ b/helper_bot/middlewares/rate_limit_middleware.py @@ -7,7 +7,6 @@ from typing import Any, Awaitable, Callable, Dict, Union from aiogram import BaseMiddleware from aiogram.exceptions import TelegramAPIError, TelegramRetryAfter from aiogram.types import CallbackQuery, ChatMemberUpdated, InlineQuery, Message, Update - from helper_bot.utils.rate_limiter import telegram_rate_limiter from logs.custom_logger import logger diff --git a/helper_bot/services/scoring/deepseek_service.py b/helper_bot/services/scoring/deepseek_service.py index 4f9cc23..a365323 100644 --- a/helper_bot/services/scoring/deepseek_service.py +++ b/helper_bot/services/scoring/deepseek_service.py @@ -9,7 +9,6 @@ import json from typing import List, Optional import httpx - from helper_bot.utils.metrics import track_errors, track_time from logs.custom_logger import logger diff --git a/helper_bot/services/scoring/rag_client.py b/helper_bot/services/scoring/rag_client.py index 4ebb4ea..5aa937f 100644 --- a/helper_bot/services/scoring/rag_client.py +++ b/helper_bot/services/scoring/rag_client.py @@ -8,7 +8,6 @@ from dataclasses import dataclass from typing import Any, Dict, List, Optional import httpx - from helper_bot.utils.metrics import track_errors, track_time from logs.custom_logger import logger diff --git a/helper_bot/utils/auto_unban_scheduler.py b/helper_bot/utils/auto_unban_scheduler.py index 91550b5..d46a688 100644 --- a/helper_bot/utils/auto_unban_scheduler.py +++ b/helper_bot/utils/auto_unban_scheduler.py @@ -4,7 +4,6 @@ from typing import Optional from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger - from helper_bot.utils.base_dependency_factory import get_global_instance from logs.custom_logger import logger diff --git a/helper_bot/utils/base_dependency_factory.py b/helper_bot/utils/base_dependency_factory.py index e3e1971..58143a5 100644 --- a/helper_bot/utils/base_dependency_factory.py +++ b/helper_bot/utils/base_dependency_factory.py @@ -2,9 +2,8 @@ import os import sys from typing import Optional -from dotenv import load_dotenv - from database.async_db import AsyncBotDB +from dotenv import load_dotenv from helper_bot.utils.s3_storage import S3StorageService from logs.custom_logger import logger diff --git a/helper_bot/utils/helper_func.py b/helper_bot/utils/helper_func.py index 6a677c1..6d94c14 100644 --- a/helper_bot/utils/helper_func.py +++ b/helper_bot/utils/helper_func.py @@ -24,7 +24,6 @@ from aiogram.types import ( InputMediaPhoto, InputMediaVideo, ) - from database.models import TelegramPost from helper_bot.utils.base_dependency_factory import ( BaseDependencyFactory, diff --git a/helper_bot/utils/rate_limiter.py b/helper_bot/utils/rate_limiter.py index 78d891f..07dff74 100644 --- a/helper_bot/utils/rate_limiter.py +++ b/helper_bot/utils/rate_limiter.py @@ -8,7 +8,6 @@ from dataclasses import dataclass from typing import Any, Callable, Dict, Optional from aiogram.exceptions import TelegramAPIError, TelegramRetryAfter - from logs.custom_logger import logger from .metrics import metrics diff --git a/helper_bot/utils/s3_storage.py b/helper_bot/utils/s3_storage.py index dbbf2d6..5b5dfc6 100644 --- a/helper_bot/utils/s3_storage.py +++ b/helper_bot/utils/s3_storage.py @@ -8,7 +8,6 @@ from pathlib import Path from typing import Optional import aioboto3 - from logs.custom_logger import logger diff --git a/tests/conftest.py b/tests/conftest.py index 96cb8d7..041e950 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,10 +13,10 @@ if str(_project_root) not in sys.path: import pytest from aiogram.fsm.context import FSMContext from aiogram.types import Chat, Message, User +from database.async_db import AsyncBotDB # Импортируем моки в самом начале import tests.mocks -from database.async_db import AsyncBotDB # Настройка pytest-asyncio pytest_plugins = ("pytest_asyncio",) diff --git a/tests/conftest_message_repository.py b/tests/conftest_message_repository.py index 90f7b8b..0793b59 100644 --- a/tests/conftest_message_repository.py +++ b/tests/conftest_message_repository.py @@ -3,7 +3,6 @@ import tempfile from datetime import datetime import pytest - from database.models import UserMessage from database.repositories.message_repository import MessageRepository diff --git a/tests/conftest_post_repository.py b/tests/conftest_post_repository.py index 8c660ce..56c26b5 100644 --- a/tests/conftest_post_repository.py +++ b/tests/conftest_post_repository.py @@ -5,7 +5,6 @@ from datetime import datetime from unittest.mock import AsyncMock, Mock import pytest - from database.models import MessageContentLink, PostContent, TelegramPost from database.repositories.post_repository import PostRepository diff --git a/tests/test_admin_dependencies.py b/tests/test_admin_dependencies.py index fda702c..9c9da2a 100644 --- a/tests/test_admin_dependencies.py +++ b/tests/test_admin_dependencies.py @@ -5,7 +5,6 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest - from helper_bot.handlers.admin.dependencies import ( AdminAccessMiddleware, get_bot_db, diff --git a/tests/test_admin_handlers.py b/tests/test_admin_handlers.py index 2f807c4..430c22f 100644 --- a/tests/test_admin_handlers.py +++ b/tests/test_admin_handlers.py @@ -7,7 +7,6 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from aiogram import types from aiogram.fsm.context import FSMContext - from helper_bot.handlers.admin.admin_handlers import ( admin_panel, cancel_ban_process, diff --git a/tests/test_admin_repository.py b/tests/test_admin_repository.py index 1eee060..5e9f5c9 100644 --- a/tests/test_admin_repository.py +++ b/tests/test_admin_repository.py @@ -3,7 +3,6 @@ from datetime import datetime from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest - from database.models import Admin from database.repositories.admin_repository import AdminRepository diff --git a/tests/test_admin_utils.py b/tests/test_admin_utils.py index 57e661d..52f8940 100644 --- a/tests/test_admin_utils.py +++ b/tests/test_admin_utils.py @@ -5,7 +5,6 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest - from helper_bot.handlers.admin.exceptions import AdminError from helper_bot.handlers.admin.utils import ( escape_html, diff --git a/tests/test_album_middleware.py b/tests/test_album_middleware.py index adf7eae..2502da8 100644 --- a/tests/test_album_middleware.py +++ b/tests/test_album_middleware.py @@ -6,7 +6,6 @@ import asyncio from unittest.mock import AsyncMock, MagicMock, patch import pytest - from helper_bot.middlewares.album_middleware import AlbumGetter, AlbumMiddleware diff --git a/tests/test_async_db.py b/tests/test_async_db.py index 02b9b3f..fdb2cab 100644 --- a/tests/test_async_db.py +++ b/tests/test_async_db.py @@ -1,7 +1,6 @@ from unittest.mock import AsyncMock, Mock, patch import pytest - from database.async_db import AsyncBotDB diff --git a/tests/test_audio_file_service.py b/tests/test_audio_file_service.py index 7d298ed..ca68bb7 100644 --- a/tests/test_audio_file_service.py +++ b/tests/test_audio_file_service.py @@ -3,7 +3,6 @@ from datetime import datetime from unittest.mock import AsyncMock, MagicMock, Mock, mock_open, patch import pytest - from helper_bot.handlers.voice.exceptions import DatabaseError, FileOperationError from helper_bot.handlers.voice.services import AudioFileService diff --git a/tests/test_audio_repository.py b/tests/test_audio_repository.py index 5ed86fe..0ee248f 100644 --- a/tests/test_audio_repository.py +++ b/tests/test_audio_repository.py @@ -3,7 +3,6 @@ from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest - from database.models import AudioListenRecord, AudioMessage, AudioModerate from database.repositories.audio_repository import AudioRepository diff --git a/tests/test_audio_repository_schema.py b/tests/test_audio_repository_schema.py index ed57604..d084df3 100644 --- a/tests/test_audio_repository_schema.py +++ b/tests/test_audio_repository_schema.py @@ -3,7 +3,6 @@ from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest - from database.repositories.audio_repository import AudioRepository diff --git a/tests/test_auto_moderation_service.py b/tests/test_auto_moderation_service.py index c9e6b7e..bb78822 100644 --- a/tests/test_auto_moderation_service.py +++ b/tests/test_auto_moderation_service.py @@ -1,8 +1,8 @@ """Тесты для AutoModerationService.""" -import pytest from unittest.mock import AsyncMock, MagicMock, patch +import pytest from helper_bot.handlers.private.services import AutoModerationService, BotSettings diff --git a/tests/test_auto_unban_integration.py b/tests/test_auto_unban_integration.py index 1bb82b6..1b7bd99 100644 --- a/tests/test_auto_unban_integration.py +++ b/tests/test_auto_unban_integration.py @@ -4,7 +4,6 @@ from datetime import datetime, timedelta, timezone from unittest.mock import AsyncMock, Mock, patch import pytest - from helper_bot.utils.auto_unban_scheduler import AutoUnbanScheduler diff --git a/tests/test_auto_unban_scheduler.py b/tests/test_auto_unban_scheduler.py index 7f64bef..bcf1c9b 100644 --- a/tests/test_auto_unban_scheduler.py +++ b/tests/test_auto_unban_scheduler.py @@ -3,7 +3,6 @@ from datetime import datetime, timedelta, timezone from unittest.mock import AsyncMock, Mock, patch import pytest - from helper_bot.utils.auto_unban_scheduler import ( AutoUnbanScheduler, get_auto_unban_scheduler, diff --git a/tests/test_blacklist_history_repository.py b/tests/test_blacklist_history_repository.py index 828a222..5d7a7ef 100644 --- a/tests/test_blacklist_history_repository.py +++ b/tests/test_blacklist_history_repository.py @@ -3,7 +3,6 @@ from datetime import datetime from unittest.mock import AsyncMock, Mock, patch import pytest - from database.models import BlacklistHistoryRecord from database.repositories.blacklist_history_repository import ( BlacklistHistoryRepository, diff --git a/tests/test_blacklist_middleware.py b/tests/test_blacklist_middleware.py index e3b6a29..5bda9b6 100644 --- a/tests/test_blacklist_middleware.py +++ b/tests/test_blacklist_middleware.py @@ -6,7 +6,6 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from aiogram.types import CallbackQuery, Message - from helper_bot.middlewares.blacklist_middleware import BlacklistMiddleware diff --git a/tests/test_blacklist_repository.py b/tests/test_blacklist_repository.py index 5b323a7..0443820 100644 --- a/tests/test_blacklist_repository.py +++ b/tests/test_blacklist_repository.py @@ -3,7 +3,6 @@ from datetime import datetime from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest - from database.models import BlacklistUser from database.repositories.blacklist_repository import BlacklistRepository diff --git a/tests/test_bot_settings_repository.py b/tests/test_bot_settings_repository.py index bd0af31..8b02a2e 100644 --- a/tests/test_bot_settings_repository.py +++ b/tests/test_bot_settings_repository.py @@ -1,8 +1,8 @@ """Тесты для BotSettingsRepository.""" -import pytest from unittest.mock import AsyncMock, MagicMock, patch +import pytest from database.repositories.bot_settings_repository import BotSettingsRepository diff --git a/tests/test_callback_dependency_factory.py b/tests/test_callback_dependency_factory.py index 8a36079..3bdb285 100644 --- a/tests/test_callback_dependency_factory.py +++ b/tests/test_callback_dependency_factory.py @@ -5,7 +5,6 @@ from unittest.mock import MagicMock, patch import pytest - from helper_bot.handlers.callback.dependency_factory import ( get_ban_service, get_post_publish_service, diff --git a/tests/test_callback_handlers.py b/tests/test_callback_handlers.py index 7f85b78..0fd1caf 100644 --- a/tests/test_callback_handlers.py +++ b/tests/test_callback_handlers.py @@ -3,7 +3,6 @@ from datetime import datetime from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest - from helper_bot.handlers.callback.callback_handlers import ( change_page, delete_voice_message, diff --git a/tests/test_callback_services.py b/tests/test_callback_services.py index 216f6b9..3ec7b20 100644 --- a/tests/test_callback_services.py +++ b/tests/test_callback_services.py @@ -6,7 +6,6 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from aiogram.types import CallbackQuery, Message - from helper_bot.handlers.callback.constants import CONTENT_TYPE_MEDIA_GROUP from helper_bot.handlers.callback.exceptions import ( PostNotFoundError, diff --git a/tests/test_decorators.py b/tests/test_decorators.py index 479f8c8..ca8bbd5 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -6,7 +6,6 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from aiogram import types - from helper_bot.handlers.group.decorators import error_handler as group_error_handler from helper_bot.handlers.private.decorators import ( error_handler as private_error_handler, diff --git a/tests/test_deepseek_service.py b/tests/test_deepseek_service.py index 6575cbd..91e9fb4 100644 --- a/tests/test_deepseek_service.py +++ b/tests/test_deepseek_service.py @@ -5,7 +5,6 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest - from helper_bot.services.scoring.deepseek_service import DeepSeekService from helper_bot.services.scoring.exceptions import ( DeepSeekAPIError, diff --git a/tests/test_dependencies_middleware.py b/tests/test_dependencies_middleware.py index c345b94..fdd4c12 100644 --- a/tests/test_dependencies_middleware.py +++ b/tests/test_dependencies_middleware.py @@ -5,7 +5,6 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest - from helper_bot.middlewares.dependencies_middleware import DependenciesMiddleware diff --git a/tests/test_improved_media_processing.py b/tests/test_improved_media_processing.py index 05d78e7..d2baf83 100644 --- a/tests/test_improved_media_processing.py +++ b/tests/test_improved_media_processing.py @@ -8,7 +8,6 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest from aiogram import types - from helper_bot.utils.helper_func import ( add_in_db_media, add_in_db_media_mediagroup, diff --git a/tests/test_keyboards_and_filters.py b/tests/test_keyboards_and_filters.py index 74a5cb9..c83aa37 100644 --- a/tests/test_keyboards_and_filters.py +++ b/tests/test_keyboards_and_filters.py @@ -7,7 +7,6 @@ from aiogram.types import ( KeyboardButton, ReplyKeyboardMarkup, ) - from database.async_db import AsyncBotDB from helper_bot.filters.main import ChatTypeFilter from helper_bot.keyboards.keyboards import ( diff --git a/tests/test_main.py b/tests/test_main.py index 0991cbc..0a492c2 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -6,7 +6,6 @@ import asyncio from unittest.mock import AsyncMock, MagicMock, patch import pytest - from helper_bot.main import start_bot, start_bot_with_retry diff --git a/tests/test_message_repository.py b/tests/test_message_repository.py index b8f2557..2fdbceb 100644 --- a/tests/test_message_repository.py +++ b/tests/test_message_repository.py @@ -3,7 +3,6 @@ from datetime import datetime from unittest.mock import AsyncMock, MagicMock import pytest - from database.models import UserMessage from database.repositories.message_repository import MessageRepository diff --git a/tests/test_message_repository_integration.py b/tests/test_message_repository_integration.py index 84c3b3a..8e129ab 100644 --- a/tests/test_message_repository_integration.py +++ b/tests/test_message_repository_integration.py @@ -4,7 +4,6 @@ import tempfile from datetime import datetime import pytest - from database.models import UserMessage from database.repositories.message_repository import MessageRepository diff --git a/tests/test_metrics_middleware.py b/tests/test_metrics_middleware.py index 80e1a4b..999152a 100644 --- a/tests/test_metrics_middleware.py +++ b/tests/test_metrics_middleware.py @@ -7,7 +7,6 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from aiogram.types import Message - from helper_bot.middlewares.metrics_middleware import ( DatabaseMetricsMiddleware, ErrorMetricsMiddleware, diff --git a/tests/test_post_repository.py b/tests/test_post_repository.py index 797be28..dd982bd 100644 --- a/tests/test_post_repository.py +++ b/tests/test_post_repository.py @@ -3,7 +3,6 @@ from datetime import datetime from unittest.mock import AsyncMock, MagicMock import pytest - from database.models import MessageContentLink, PostContent, TelegramPost from database.repositories.post_repository import PostRepository diff --git a/tests/test_post_repository_integration.py b/tests/test_post_repository_integration.py index d485fec..43bb399 100644 --- a/tests/test_post_repository_integration.py +++ b/tests/test_post_repository_integration.py @@ -4,7 +4,6 @@ import tempfile from datetime import datetime import pytest - from database.models import MessageContentLink, PostContent, TelegramPost from database.repositories.post_repository import PostRepository diff --git a/tests/test_post_service.py b/tests/test_post_service.py index ffabd2b..591fb6b 100644 --- a/tests/test_post_service.py +++ b/tests/test_post_service.py @@ -5,7 +5,6 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest from aiogram import types - from database.models import TelegramPost, User from helper_bot.handlers.private.services import BotSettings, PostService diff --git a/tests/test_rag_client.py b/tests/test_rag_client.py index 241c4c6..9a812eb 100644 --- a/tests/test_rag_client.py +++ b/tests/test_rag_client.py @@ -5,7 +5,6 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest - from helper_bot.services.scoring.exceptions import ( InsufficientExamplesError, ScoringError, diff --git a/tests/test_rate_limit_middleware.py b/tests/test_rate_limit_middleware.py index ebf0c83..640b52f 100644 --- a/tests/test_rate_limit_middleware.py +++ b/tests/test_rate_limit_middleware.py @@ -6,7 +6,6 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from aiogram.types import CallbackQuery, Message, Update - from helper_bot.middlewares.rate_limit_middleware import RateLimitMiddleware diff --git a/tests/test_rate_limit_monitor.py b/tests/test_rate_limit_monitor.py index ef55a23..8f4bff3 100644 --- a/tests/test_rate_limit_monitor.py +++ b/tests/test_rate_limit_monitor.py @@ -7,7 +7,6 @@ from collections import deque from unittest.mock import patch import pytest - from helper_bot.utils.rate_limit_monitor import ( RateLimitMonitor, RateLimitStats, diff --git a/tests/test_rate_limiter.py b/tests/test_rate_limiter.py index ed3e7bd..2a72d45 100644 --- a/tests/test_rate_limiter.py +++ b/tests/test_rate_limiter.py @@ -7,7 +7,6 @@ import time from unittest.mock import AsyncMock, MagicMock, patch import pytest - from helper_bot.config.rate_limit_config import RateLimitSettings, get_rate_limit_config from helper_bot.utils.rate_limit_monitor import ( RateLimitMonitor, diff --git a/tests/test_refactored_admin_handlers.py b/tests/test_refactored_admin_handlers.py index 7633715..3f212d6 100644 --- a/tests/test_refactored_admin_handlers.py +++ b/tests/test_refactored_admin_handlers.py @@ -3,7 +3,6 @@ from unittest.mock import AsyncMock, Mock, patch import pytest from aiogram import types from aiogram.fsm.context import FSMContext - from helper_bot.handlers.admin.exceptions import ( InvalidInputError, UserAlreadyBannedError, diff --git a/tests/test_refactored_group_handlers.py b/tests/test_refactored_group_handlers.py index 464c95f..5a8b15b 100644 --- a/tests/test_refactored_group_handlers.py +++ b/tests/test_refactored_group_handlers.py @@ -5,7 +5,6 @@ from unittest.mock import AsyncMock, MagicMock, Mock import pytest from aiogram import types from aiogram.fsm.context import FSMContext - from helper_bot.handlers.group.constants import ERROR_MESSAGES, FSM_STATES from helper_bot.handlers.group.exceptions import ( NoReplyToMessageError, diff --git a/tests/test_refactored_private_handlers.py b/tests/test_refactored_private_handlers.py index 515eb2c..434a7e6 100644 --- a/tests/test_refactored_private_handlers.py +++ b/tests/test_refactored_private_handlers.py @@ -5,7 +5,6 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest from aiogram import types from aiogram.fsm.context import FSMContext - from helper_bot.handlers.private.constants import BUTTON_TEXTS, FSM_STATES from helper_bot.handlers.private.private_handlers import ( PrivateHandlers, diff --git a/tests/test_s3_storage.py b/tests/test_s3_storage.py index 214fc78..ac5bab8 100644 --- a/tests/test_s3_storage.py +++ b/tests/test_s3_storage.py @@ -7,7 +7,6 @@ from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest - from helper_bot.utils.s3_storage import S3StorageService diff --git a/tests/test_scoring_services.py b/tests/test_scoring_services.py index c7d427d..9dbce67 100644 --- a/tests/test_scoring_services.py +++ b/tests/test_scoring_services.py @@ -137,7 +137,6 @@ class TestVectorStore: """Создает VectorStore для тестов.""" try: import numpy as np - from helper_bot.services.scoring.vector_store import VectorStore return VectorStore(vector_dim=768, max_examples=100) diff --git a/tests/test_server_prometheus.py b/tests/test_server_prometheus.py index 42631a5..5e6dc55 100644 --- a/tests/test_server_prometheus.py +++ b/tests/test_server_prometheus.py @@ -6,7 +6,6 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from aiohttp import web - from helper_bot.server_prometheus import ( MetricsServer, start_metrics_server, diff --git a/tests/test_text_middleware.py b/tests/test_text_middleware.py index 63c64b1..6187382 100644 --- a/tests/test_text_middleware.py +++ b/tests/test_text_middleware.py @@ -5,7 +5,6 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest - from helper_bot.middlewares.text_middleware import BulkTextMiddleware diff --git a/tests/test_utils.py b/tests/test_utils.py index b57d253..53b3c4d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,9 +2,8 @@ import os from datetime import datetime from unittest.mock import AsyncMock, Mock, patch -import pytest - import helper_bot.utils.messages as messages # Import for patching constants +import pytest from database.async_db import AsyncBotDB from helper_bot.utils.base_dependency_factory import ( BaseDependencyFactory, diff --git a/tests/test_voice_bot_architecture.py b/tests/test_voice_bot_architecture.py index 9f7b7bc..3788d51 100644 --- a/tests/test_voice_bot_architecture.py +++ b/tests/test_voice_bot_architecture.py @@ -3,7 +3,6 @@ from pathlib import Path from unittest.mock import AsyncMock, Mock, patch import pytest - from helper_bot.handlers.voice.exceptions import AudioProcessingError, VoiceMessageError from helper_bot.handlers.voice.services import VoiceBotService from helper_bot.handlers.voice.utils import ( diff --git a/tests/test_voice_constants.py b/tests/test_voice_constants.py index b6774ef..ab3eec1 100644 --- a/tests/test_voice_constants.py +++ b/tests/test_voice_constants.py @@ -1,5 +1,4 @@ import pytest - from helper_bot.handlers.voice.constants import ( BTN_LISTEN, BTN_SPEAK, diff --git a/tests/test_voice_exceptions.py b/tests/test_voice_exceptions.py index 38d8150..f94349b 100644 --- a/tests/test_voice_exceptions.py +++ b/tests/test_voice_exceptions.py @@ -1,5 +1,4 @@ import pytest - from helper_bot.handlers.voice.exceptions import ( AudioProcessingError, VoiceBotError, diff --git a/tests/test_voice_handler.py b/tests/test_voice_handler.py index af1fff9..304bfc8 100644 --- a/tests/test_voice_handler.py +++ b/tests/test_voice_handler.py @@ -3,7 +3,6 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest from aiogram import types from aiogram.fsm.context import FSMContext - from helper_bot.handlers.voice.constants import STATE_STANDUP_WRITE, STATE_START from helper_bot.handlers.voice.voice_handler import VoiceHandlers diff --git a/tests/test_voice_services.py b/tests/test_voice_services.py index 8448d82..75de09a 100644 --- a/tests/test_voice_services.py +++ b/tests/test_voice_services.py @@ -3,7 +3,6 @@ from pathlib import Path from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest - from helper_bot.handlers.voice.exceptions import AudioProcessingError, VoiceMessageError from helper_bot.handlers.voice.services import VoiceBotService diff --git a/tests/test_voice_utils.py b/tests/test_voice_utils.py index 50de559..f744ac0 100644 --- a/tests/test_voice_utils.py +++ b/tests/test_voice_utils.py @@ -3,7 +3,6 @@ from unittest.mock import Mock, patch import pytest from aiogram import types - from helper_bot.handlers.voice.utils import ( format_time_ago, get_last_message_text, -- 2.49.1 From 3d6b4353f9c9abe88c10f40bfe6c30564b01c615 Mon Sep 17 00:00:00 2001 From: Andrey Date: Sat, 28 Feb 2026 23:24:25 +0300 Subject: [PATCH 5/5] Refactor imports across multiple files to improve code organization and readability. --- database/async_db.py | 1 + database/base.py | 1 + database/repositories/migration_repository.py | 1 + helper_bot/handlers/admin/admin_handlers.py | 19 ++++----- helper_bot/handlers/admin/dependencies.py | 1 + .../handlers/admin/rate_limit_handlers.py | 1 + helper_bot/handlers/admin/utils.py | 1 + .../handlers/callback/callback_handlers.py | 1 + .../handlers/callback/dependency_factory.py | 1 + helper_bot/handlers/callback/services.py | 1 + helper_bot/handlers/private/services.py | 29 +++++++++----- helper_bot/handlers/voice/services.py | 1 + helper_bot/handlers/voice/voice_handler.py | 1 + helper_bot/main.py | 1 + .../middlewares/blacklist_middleware.py | 1 + .../middlewares/dependencies_middleware.py | 1 + .../middlewares/rate_limit_middleware.py | 1 + .../services/scoring/deepseek_service.py | 1 + helper_bot/services/scoring/rag_client.py | 6 ++- .../services/scoring/scoring_manager.py | 5 ++- helper_bot/utils/auto_unban_scheduler.py | 1 + helper_bot/utils/base_dependency_factory.py | 3 +- helper_bot/utils/helper_func.py | 5 ++- helper_bot/utils/rate_limiter.py | 1 + helper_bot/utils/s3_storage.py | 1 + scripts/create_bot_settings_table.py | 6 +-- tests/conftest.py | 2 +- tests/conftest_message_repository.py | 1 + tests/conftest_post_repository.py | 1 + tests/test_admin_dependencies.py | 1 + tests/test_admin_handlers.py | 1 + tests/test_admin_repository.py | 1 + tests/test_admin_utils.py | 1 + tests/test_album_middleware.py | 1 + tests/test_async_db.py | 1 + tests/test_audio_file_service.py | 1 + tests/test_audio_repository.py | 1 + tests/test_audio_repository_schema.py | 1 + tests/test_auto_moderation_service.py | 9 ++--- tests/test_auto_unban_integration.py | 1 + tests/test_auto_unban_scheduler.py | 1 + tests/test_blacklist_history_repository.py | 1 + tests/test_blacklist_middleware.py | 1 + tests/test_blacklist_repository.py | 1 + tests/test_bot_settings_repository.py | 40 ++++++++++++------- tests/test_callback_dependency_factory.py | 1 + tests/test_callback_handlers.py | 1 + tests/test_callback_services.py | 1 + tests/test_decorators.py | 1 + tests/test_deepseek_service.py | 1 + tests/test_dependencies_middleware.py | 1 + tests/test_improved_media_processing.py | 1 + tests/test_keyboards_and_filters.py | 1 + tests/test_main.py | 1 + tests/test_message_repository.py | 1 + tests/test_message_repository_integration.py | 1 + tests/test_metrics_middleware.py | 1 + tests/test_post_repository.py | 1 + tests/test_post_repository_integration.py | 1 + tests/test_post_service.py | 1 + tests/test_rag_client.py | 1 + tests/test_rate_limit_middleware.py | 1 + tests/test_rate_limit_monitor.py | 1 + tests/test_rate_limiter.py | 1 + tests/test_refactored_admin_handlers.py | 1 + tests/test_refactored_group_handlers.py | 1 + tests/test_refactored_private_handlers.py | 1 + tests/test_s3_storage.py | 1 + tests/test_scoring_services.py | 1 + tests/test_server_prometheus.py | 1 + tests/test_text_middleware.py | 1 + tests/test_utils.py | 3 +- tests/test_voice_bot_architecture.py | 1 + tests/test_voice_constants.py | 1 + tests/test_voice_exceptions.py | 1 + tests/test_voice_handler.py | 1 + tests/test_voice_services.py | 1 + tests/test_voice_utils.py | 1 + 78 files changed, 141 insertions(+), 53 deletions(-) diff --git a/database/async_db.py b/database/async_db.py index a142a66..f36a324 100644 --- a/database/async_db.py +++ b/database/async_db.py @@ -2,6 +2,7 @@ from datetime import datetime from typing import Any, Dict, List, Optional, Tuple import aiosqlite + from database.models import ( Admin, AudioMessage, diff --git a/database/base.py b/database/base.py index 4fb6fb0..ca32425 100644 --- a/database/base.py +++ b/database/base.py @@ -2,6 +2,7 @@ import os from typing import Optional import aiosqlite + from logs.custom_logger import logger diff --git a/database/repositories/migration_repository.py b/database/repositories/migration_repository.py index 628ecee..8c7a02b 100644 --- a/database/repositories/migration_repository.py +++ b/database/repositories/migration_repository.py @@ -1,6 +1,7 @@ """Репозиторий для работы с миграциями базы данных.""" import aiosqlite + from database.base import DatabaseConnection diff --git a/helper_bot/handlers/admin/admin_handlers.py b/helper_bot/handlers/admin/admin_handlers.py index 468f234..862d088 100644 --- a/helper_bot/handlers/admin/admin_handlers.py +++ b/helper_bot/handlers/admin/admin_handlers.py @@ -1,6 +1,7 @@ from aiogram import F, Router, types from aiogram.filters import Command, MagicData, StateFilter from aiogram.fsm.context import FSMContext + from helper_bot.filters.main import ChatTypeFilter from helper_bot.handlers.admin.dependencies import AdminAccessMiddleware from helper_bot.handlers.admin.exceptions import ( @@ -138,7 +139,9 @@ async def get_banned_users( keyboard = create_keyboard_with_pagination( 1, len(buttons_list), buttons_list, "unlock" ) - await message.answer(text=message_text, reply_markup=keyboard, parse_mode="HTML") + await message.answer( + text=message_text, reply_markup=keyboard, parse_mode="HTML" + ) else: await message.answer( text="В списке заблокированных пользователей никого нет" @@ -217,12 +220,8 @@ async def get_ml_stats(message: types.Message, state: FSMContext, **kwargs): lines.append(f" • API URL: {rag.get('api_url', 'N/A')}") if "enabled" in rag: if rag.get("enabled"): - lines.append( - f" • Статус: ⚠️ Включен, но API не отвечает" - ) - lines.append( - f" • Проверьте доступность сервиса и API ключ" - ) + lines.append(f" • Статус: ⚠️ Включен, но API не отвечает") + lines.append(f" • Проверьте доступность сервиса и API ключ") else: lines.append(f" • Статус: ❌ Отключен") @@ -460,8 +459,7 @@ async def process_publish_threshold( except ValueError as e: await message.answer( - f"❌ Неверное значение: {e}\n" - "Введите число от 0.0 до 1.0 (например: 0.8)" + f"❌ Неверное значение: {e}\n" "Введите число от 0.0 до 1.0 (например: 0.8)" ) except Exception as e: logger.error(f"Ошибка изменения порога публикации: {e}") @@ -503,8 +501,7 @@ async def process_decline_threshold( except ValueError as e: await message.answer( - f"❌ Неверное значение: {e}\n" - "Введите число от 0.0 до 1.0 (например: 0.4)" + f"❌ Неверное значение: {e}\n" "Введите число от 0.0 до 1.0 (например: 0.4)" ) except Exception as e: logger.error(f"Ошибка изменения порога отклонения: {e}") diff --git a/helper_bot/handlers/admin/dependencies.py b/helper_bot/handlers/admin/dependencies.py index 9774837..89a486f 100644 --- a/helper_bot/handlers/admin/dependencies.py +++ b/helper_bot/handlers/admin/dependencies.py @@ -7,6 +7,7 @@ except ImportError: from aiogram import BaseMiddleware from aiogram.types import TelegramObject + from helper_bot.utils.base_dependency_factory import get_global_instance from helper_bot.utils.helper_func import check_access from logs.custom_logger import logger diff --git a/helper_bot/handlers/admin/rate_limit_handlers.py b/helper_bot/handlers/admin/rate_limit_handlers.py index 143e9fb..2837121 100644 --- a/helper_bot/handlers/admin/rate_limit_handlers.py +++ b/helper_bot/handlers/admin/rate_limit_handlers.py @@ -6,6 +6,7 @@ from aiogram import F, Router, types from aiogram.filters import Command, MagicData from aiogram.fsm.context import FSMContext from aiogram.types import FSInputFile + from helper_bot.filters.main import ChatTypeFilter from helper_bot.middlewares.dependencies_middleware import DependenciesMiddleware diff --git a/helper_bot/handlers/admin/utils.py b/helper_bot/handlers/admin/utils.py index b9cf51c..74fea5c 100644 --- a/helper_bot/handlers/admin/utils.py +++ b/helper_bot/handlers/admin/utils.py @@ -3,6 +3,7 @@ from typing import Optional from aiogram import types from aiogram.fsm.context import FSMContext + from helper_bot.handlers.admin.exceptions import AdminError from helper_bot.keyboards.keyboards import get_reply_keyboard_admin from logs.custom_logger import logger diff --git a/helper_bot/handlers/callback/callback_handlers.py b/helper_bot/handlers/callback/callback_handlers.py index 1c34304..f652ceb 100644 --- a/helper_bot/handlers/callback/callback_handlers.py +++ b/helper_bot/handlers/callback/callback_handlers.py @@ -7,6 +7,7 @@ from aiogram import F, Router from aiogram.filters import MagicData from aiogram.fsm.context import FSMContext from aiogram.types import CallbackQuery + from helper_bot.handlers.admin.utils import format_user_info from helper_bot.handlers.voice.constants import CALLBACK_DELETE, CALLBACK_SAVE from helper_bot.handlers.voice.services import AudioFileService diff --git a/helper_bot/handlers/callback/dependency_factory.py b/helper_bot/handlers/callback/dependency_factory.py index 3a4ca99..a8b376f 100644 --- a/helper_bot/handlers/callback/dependency_factory.py +++ b/helper_bot/handlers/callback/dependency_factory.py @@ -3,6 +3,7 @@ from typing import Callable from aiogram import Bot from aiogram.client.default import DefaultBotProperties from aiogram.fsm.context import FSMContext + from helper_bot.utils.base_dependency_factory import get_global_instance from .services import BanService, PostPublishService diff --git a/helper_bot/handlers/callback/services.py b/helper_bot/handlers/callback/services.py index 40dba23..910d852 100644 --- a/helper_bot/handlers/callback/services.py +++ b/helper_bot/handlers/callback/services.py @@ -4,6 +4,7 @@ from typing import Any, Dict from aiogram import Bot, types from aiogram.types import CallbackQuery + from helper_bot.keyboards.keyboards import create_keyboard_for_ban_reason from helper_bot.utils.helper_func import ( delete_user_blacklist, diff --git a/helper_bot/handlers/private/services.py b/helper_bot/handlers/private/services.py index d7436b3..13004ac 100644 --- a/helper_bot/handlers/private/services.py +++ b/helper_bot/handlers/private/services.py @@ -12,6 +12,7 @@ from typing import Any, Callable, Dict, Protocol, Union # Third-party imports from aiogram import types from aiogram.types import FSInputFile + from database.models import TelegramPost, User from helper_bot.keyboards import get_reply_keyboard_for_post @@ -171,7 +172,9 @@ class UserService: Returns: Отформатированное сообщение для админов """ - safe_full_name = html.escape(full_name) if full_name else "Неизвестный пользователь" + safe_full_name = ( + html.escape(full_name) if full_name else "Неизвестный пользователь" + ) safe_username = html.escape(username) if username else None safe_message_text = html.escape(message_text) if message_text else "" @@ -198,7 +201,9 @@ class UserService: # Получаем дату регистрации user_info = await self.db.get_user_by_id(user_id) if user_info and user_info.date_added: - date_added = datetime.fromtimestamp(user_info.date_added).strftime("%d.%m.%Y") + date_added = datetime.fromtimestamp(user_info.date_added).strftime( + "%d.%m.%Y" + ) else: date_added = "Неизвестно" @@ -332,9 +337,7 @@ class PostService: if self.scoring_manager: await self.scoring_manager.add_submitted_post(text, post_id, rag_score) except Exception as e: - logger.warning( - f"PostService: Ошибка добавления поста в submitted: {e}" - ) + logger.warning(f"PostService: Ошибка добавления поста в submitted: {e}") async def _get_scores_with_error_handling(self, text: str) -> tuple: """ @@ -526,7 +529,9 @@ class PostService: # Сохраняем скоры если есть if ml_scores_json: asyncio.create_task( - self._save_scores_background(sent_message.message_id, ml_scores_json) + self._save_scores_background( + sent_message.message_id, ml_scores_json + ) ) # Индексируем пост в RAG @@ -544,7 +549,9 @@ class PostService: text="Твой пост был выложен🥰", ) except Exception as e: - logger.warning(f"PostService: Не удалось уведомить автора {author_id}: {e}") + logger.warning( + f"PostService: Не удалось уведомить автора {author_id}: {e}" + ) logger.info( f"PostService: Пост авто-опубликован в {self.settings.main_public}, " @@ -562,7 +569,9 @@ class PostService: try: await self.scoring_manager.on_post_declined(original_text) except Exception as e: - logger.warning(f"PostService: Ошибка обучения RAG на отклоненном посте: {e}") + logger.warning( + f"PostService: Ошибка обучения RAG на отклоненном посте: {e}" + ) # Уведомляем автора try: @@ -1471,8 +1480,6 @@ class AutoModerationService: text=message_text, parse_mode="HTML", ) - logger.info( - f"AutoModeration: Лог отправлен в IMPORTANT_LOGS ({action})" - ) + logger.info(f"AutoModeration: Лог отправлен в IMPORTANT_LOGS ({action})") except Exception as e: logger.error(f"AutoModeration: Ошибка отправки лога: {e}") diff --git a/helper_bot/handlers/voice/services.py b/helper_bot/handlers/voice/services.py index cc68ff8..36808ee 100644 --- a/helper_bot/handlers/voice/services.py +++ b/helper_bot/handlers/voice/services.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import List, Optional, Tuple from aiogram.types import FSInputFile + from helper_bot.handlers.voice.constants import ( MESSAGE_DELAY_1, MESSAGE_DELAY_2, diff --git a/helper_bot/handlers/voice/voice_handler.py b/helper_bot/handlers/voice/voice_handler.py index f3124b6..3ea223e 100644 --- a/helper_bot/handlers/voice/voice_handler.py +++ b/helper_bot/handlers/voice/voice_handler.py @@ -6,6 +6,7 @@ from aiogram import F, Router, types from aiogram.filters import Command, MagicData, StateFilter from aiogram.fsm.context import FSMContext from aiogram.types import FSInputFile + from helper_bot.filters.main import ChatTypeFilter from helper_bot.handlers.private.constants import BUTTON_TEXTS, FSM_STATES from helper_bot.handlers.voice.constants import * diff --git a/helper_bot/main.py b/helper_bot/main.py index 942888b..f055db6 100644 --- a/helper_bot/main.py +++ b/helper_bot/main.py @@ -6,6 +6,7 @@ from aiogram import Bot, Dispatcher from aiogram.client.default import DefaultBotProperties from aiogram.fsm.storage.memory import MemoryStorage from aiogram.fsm.strategy import FSMStrategy + from helper_bot.handlers.admin import admin_router from helper_bot.handlers.callback import callback_router from helper_bot.handlers.group import group_router diff --git a/helper_bot/middlewares/blacklist_middleware.py b/helper_bot/middlewares/blacklist_middleware.py index 05bdd17..32279e2 100644 --- a/helper_bot/middlewares/blacklist_middleware.py +++ b/helper_bot/middlewares/blacklist_middleware.py @@ -4,6 +4,7 @@ from typing import Any, Dict from aiogram import BaseMiddleware, types from aiogram.types import CallbackQuery, Message, TelegramObject + from helper_bot.utils.base_dependency_factory import get_global_instance from logs.custom_logger import logger diff --git a/helper_bot/middlewares/dependencies_middleware.py b/helper_bot/middlewares/dependencies_middleware.py index ce266c0..d18c28c 100644 --- a/helper_bot/middlewares/dependencies_middleware.py +++ b/helper_bot/middlewares/dependencies_middleware.py @@ -2,6 +2,7 @@ from typing import Any, Dict from aiogram import BaseMiddleware from aiogram.types import TelegramObject + from helper_bot.utils.base_dependency_factory import get_global_instance from logs.custom_logger import logger diff --git a/helper_bot/middlewares/rate_limit_middleware.py b/helper_bot/middlewares/rate_limit_middleware.py index 83d9ef0..c50ef88 100644 --- a/helper_bot/middlewares/rate_limit_middleware.py +++ b/helper_bot/middlewares/rate_limit_middleware.py @@ -7,6 +7,7 @@ from typing import Any, Awaitable, Callable, Dict, Union from aiogram import BaseMiddleware from aiogram.exceptions import TelegramAPIError, TelegramRetryAfter from aiogram.types import CallbackQuery, ChatMemberUpdated, InlineQuery, Message, Update + from helper_bot.utils.rate_limiter import telegram_rate_limiter from logs.custom_logger import logger diff --git a/helper_bot/services/scoring/deepseek_service.py b/helper_bot/services/scoring/deepseek_service.py index a365323..4f9cc23 100644 --- a/helper_bot/services/scoring/deepseek_service.py +++ b/helper_bot/services/scoring/deepseek_service.py @@ -9,6 +9,7 @@ import json from typing import List, Optional import httpx + from helper_bot.utils.metrics import track_errors, track_time from logs.custom_logger import logger diff --git a/helper_bot/services/scoring/rag_client.py b/helper_bot/services/scoring/rag_client.py index 5aa937f..da31e1e 100644 --- a/helper_bot/services/scoring/rag_client.py +++ b/helper_bot/services/scoring/rag_client.py @@ -8,6 +8,7 @@ from dataclasses import dataclass from typing import Any, Dict, List, Optional import httpx + from helper_bot.utils.metrics import track_errors, track_time from logs.custom_logger import logger @@ -483,7 +484,10 @@ class RagApiClient: @track_time("add_submitted_post", "rag_client") async def add_submitted_post( - self, text: str, post_id: Optional[int] = None, rag_score: Optional[float] = None + self, + text: str, + post_id: Optional[int] = None, + rag_score: Optional[float] = None, ) -> bool: """ Добавляет пост в коллекцию submitted для поиска похожих. diff --git a/helper_bot/services/scoring/scoring_manager.py b/helper_bot/services/scoring/scoring_manager.py index 8a332c8..f95812b 100644 --- a/helper_bot/services/scoring/scoring_manager.py +++ b/helper_bot/services/scoring/scoring_manager.py @@ -244,7 +244,10 @@ class ScoringManager: @track_time("add_submitted_post", "scoring_manager") async def add_submitted_post( - self, text: str, post_id: Optional[int] = None, rag_score: Optional[float] = None + self, + text: str, + post_id: Optional[int] = None, + rag_score: Optional[float] = None, ) -> bool: """ Добавляет пост в коллекцию submitted для поиска похожих. diff --git a/helper_bot/utils/auto_unban_scheduler.py b/helper_bot/utils/auto_unban_scheduler.py index d46a688..91550b5 100644 --- a/helper_bot/utils/auto_unban_scheduler.py +++ b/helper_bot/utils/auto_unban_scheduler.py @@ -4,6 +4,7 @@ from typing import Optional from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger + from helper_bot.utils.base_dependency_factory import get_global_instance from logs.custom_logger import logger diff --git a/helper_bot/utils/base_dependency_factory.py b/helper_bot/utils/base_dependency_factory.py index 58143a5..e3e1971 100644 --- a/helper_bot/utils/base_dependency_factory.py +++ b/helper_bot/utils/base_dependency_factory.py @@ -2,8 +2,9 @@ import os import sys from typing import Optional -from database.async_db import AsyncBotDB from dotenv import load_dotenv + +from database.async_db import AsyncBotDB from helper_bot.utils.s3_storage import S3StorageService from logs.custom_logger import logger diff --git a/helper_bot/utils/helper_func.py b/helper_bot/utils/helper_func.py index 6d94c14..133bd5f 100644 --- a/helper_bot/utils/helper_func.py +++ b/helper_bot/utils/helper_func.py @@ -24,6 +24,7 @@ from aiogram.types import ( InputMediaPhoto, InputMediaVideo, ) + from database.models import TelegramPost from helper_bot.utils.base_dependency_factory import ( BaseDependencyFactory, @@ -1065,7 +1066,9 @@ async def get_banned_users_list(offset: int, bot_db): user_ids - лист кортежей [(user_name: user_id)] """ items_per_page = 9 - users = await bot_db.get_banned_users_from_db_with_limits(limit=items_per_page, offset=offset) + users = await bot_db.get_banned_users_from_db_with_limits( + limit=items_per_page, offset=offset + ) message = "Список заблокированных пользователей:\n" for user in users: diff --git a/helper_bot/utils/rate_limiter.py b/helper_bot/utils/rate_limiter.py index 07dff74..78d891f 100644 --- a/helper_bot/utils/rate_limiter.py +++ b/helper_bot/utils/rate_limiter.py @@ -8,6 +8,7 @@ from dataclasses import dataclass from typing import Any, Callable, Dict, Optional from aiogram.exceptions import TelegramAPIError, TelegramRetryAfter + from logs.custom_logger import logger from .metrics import metrics diff --git a/helper_bot/utils/s3_storage.py b/helper_bot/utils/s3_storage.py index 5b5dfc6..dbbf2d6 100644 --- a/helper_bot/utils/s3_storage.py +++ b/helper_bot/utils/s3_storage.py @@ -8,6 +8,7 @@ from pathlib import Path from typing import Optional import aioboto3 + from logs.custom_logger import logger diff --git a/scripts/create_bot_settings_table.py b/scripts/create_bot_settings_table.py index a13c033..4a6c262 100644 --- a/scripts/create_bot_settings_table.py +++ b/scripts/create_bot_settings_table.py @@ -67,16 +67,14 @@ async def main(db_path: str) -> None: await conn.execute("PRAGMA foreign_keys = ON") if not await table_exists(conn, "bot_settings"): - await conn.execute( - """ + await conn.execute(""" CREATE TABLE bot_settings ( id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, key TEXT NOT NULL UNIQUE, value TEXT NOT NULL, updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) ) - """ - ) + """) logger.info("Таблица bot_settings создана") for key, value in DEFAULT_SETTINGS: diff --git a/tests/conftest.py b/tests/conftest.py index 041e950..96cb8d7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,10 +13,10 @@ if str(_project_root) not in sys.path: import pytest from aiogram.fsm.context import FSMContext from aiogram.types import Chat, Message, User -from database.async_db import AsyncBotDB # Импортируем моки в самом начале import tests.mocks +from database.async_db import AsyncBotDB # Настройка pytest-asyncio pytest_plugins = ("pytest_asyncio",) diff --git a/tests/conftest_message_repository.py b/tests/conftest_message_repository.py index 0793b59..90f7b8b 100644 --- a/tests/conftest_message_repository.py +++ b/tests/conftest_message_repository.py @@ -3,6 +3,7 @@ import tempfile from datetime import datetime import pytest + from database.models import UserMessage from database.repositories.message_repository import MessageRepository diff --git a/tests/conftest_post_repository.py b/tests/conftest_post_repository.py index 56c26b5..8c660ce 100644 --- a/tests/conftest_post_repository.py +++ b/tests/conftest_post_repository.py @@ -5,6 +5,7 @@ from datetime import datetime from unittest.mock import AsyncMock, Mock import pytest + from database.models import MessageContentLink, PostContent, TelegramPost from database.repositories.post_repository import PostRepository diff --git a/tests/test_admin_dependencies.py b/tests/test_admin_dependencies.py index 9c9da2a..fda702c 100644 --- a/tests/test_admin_dependencies.py +++ b/tests/test_admin_dependencies.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest + from helper_bot.handlers.admin.dependencies import ( AdminAccessMiddleware, get_bot_db, diff --git a/tests/test_admin_handlers.py b/tests/test_admin_handlers.py index 430c22f..2f807c4 100644 --- a/tests/test_admin_handlers.py +++ b/tests/test_admin_handlers.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from aiogram import types from aiogram.fsm.context import FSMContext + from helper_bot.handlers.admin.admin_handlers import ( admin_panel, cancel_ban_process, diff --git a/tests/test_admin_repository.py b/tests/test_admin_repository.py index 5e9f5c9..1eee060 100644 --- a/tests/test_admin_repository.py +++ b/tests/test_admin_repository.py @@ -3,6 +3,7 @@ from datetime import datetime from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest + from database.models import Admin from database.repositories.admin_repository import AdminRepository diff --git a/tests/test_admin_utils.py b/tests/test_admin_utils.py index 52f8940..57e661d 100644 --- a/tests/test_admin_utils.py +++ b/tests/test_admin_utils.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest + from helper_bot.handlers.admin.exceptions import AdminError from helper_bot.handlers.admin.utils import ( escape_html, diff --git a/tests/test_album_middleware.py b/tests/test_album_middleware.py index 2502da8..adf7eae 100644 --- a/tests/test_album_middleware.py +++ b/tests/test_album_middleware.py @@ -6,6 +6,7 @@ import asyncio from unittest.mock import AsyncMock, MagicMock, patch import pytest + from helper_bot.middlewares.album_middleware import AlbumGetter, AlbumMiddleware diff --git a/tests/test_async_db.py b/tests/test_async_db.py index fdb2cab..02b9b3f 100644 --- a/tests/test_async_db.py +++ b/tests/test_async_db.py @@ -1,6 +1,7 @@ from unittest.mock import AsyncMock, Mock, patch import pytest + from database.async_db import AsyncBotDB diff --git a/tests/test_audio_file_service.py b/tests/test_audio_file_service.py index ca68bb7..7d298ed 100644 --- a/tests/test_audio_file_service.py +++ b/tests/test_audio_file_service.py @@ -3,6 +3,7 @@ from datetime import datetime from unittest.mock import AsyncMock, MagicMock, Mock, mock_open, patch import pytest + from helper_bot.handlers.voice.exceptions import DatabaseError, FileOperationError from helper_bot.handlers.voice.services import AudioFileService diff --git a/tests/test_audio_repository.py b/tests/test_audio_repository.py index 0ee248f..5ed86fe 100644 --- a/tests/test_audio_repository.py +++ b/tests/test_audio_repository.py @@ -3,6 +3,7 @@ from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest + from database.models import AudioListenRecord, AudioMessage, AudioModerate from database.repositories.audio_repository import AudioRepository diff --git a/tests/test_audio_repository_schema.py b/tests/test_audio_repository_schema.py index d084df3..ed57604 100644 --- a/tests/test_audio_repository_schema.py +++ b/tests/test_audio_repository_schema.py @@ -3,6 +3,7 @@ from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest + from database.repositories.audio_repository import AudioRepository diff --git a/tests/test_auto_moderation_service.py b/tests/test_auto_moderation_service.py index bb78822..c2dd6ea 100644 --- a/tests/test_auto_moderation_service.py +++ b/tests/test_auto_moderation_service.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest + from helper_bot.handlers.private.services import AutoModerationService, BotSettings @@ -108,9 +109,7 @@ class TestAutoModerationService: assert result == "manual" @pytest.mark.asyncio - async def test_check_auto_action_publish_at_exact_threshold( - self, service, mock_db - ): + async def test_check_auto_action_publish_at_exact_threshold(self, service, mock_db): """Тест: возвращает publish когда score равен порогу.""" mock_db.get_auto_moderation_settings.return_value = { "auto_publish_enabled": True, @@ -124,9 +123,7 @@ class TestAutoModerationService: assert result == "publish" @pytest.mark.asyncio - async def test_check_auto_action_decline_at_exact_threshold( - self, service, mock_db - ): + async def test_check_auto_action_decline_at_exact_threshold(self, service, mock_db): """Тест: возвращает decline когда score равен порогу.""" mock_db.get_auto_moderation_settings.return_value = { "auto_publish_enabled": False, diff --git a/tests/test_auto_unban_integration.py b/tests/test_auto_unban_integration.py index 1b7bd99..1bb82b6 100644 --- a/tests/test_auto_unban_integration.py +++ b/tests/test_auto_unban_integration.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta, timezone from unittest.mock import AsyncMock, Mock, patch import pytest + from helper_bot.utils.auto_unban_scheduler import AutoUnbanScheduler diff --git a/tests/test_auto_unban_scheduler.py b/tests/test_auto_unban_scheduler.py index bcf1c9b..7f64bef 100644 --- a/tests/test_auto_unban_scheduler.py +++ b/tests/test_auto_unban_scheduler.py @@ -3,6 +3,7 @@ from datetime import datetime, timedelta, timezone from unittest.mock import AsyncMock, Mock, patch import pytest + from helper_bot.utils.auto_unban_scheduler import ( AutoUnbanScheduler, get_auto_unban_scheduler, diff --git a/tests/test_blacklist_history_repository.py b/tests/test_blacklist_history_repository.py index 5d7a7ef..828a222 100644 --- a/tests/test_blacklist_history_repository.py +++ b/tests/test_blacklist_history_repository.py @@ -3,6 +3,7 @@ from datetime import datetime from unittest.mock import AsyncMock, Mock, patch import pytest + from database.models import BlacklistHistoryRecord from database.repositories.blacklist_history_repository import ( BlacklistHistoryRepository, diff --git a/tests/test_blacklist_middleware.py b/tests/test_blacklist_middleware.py index 5bda9b6..e3b6a29 100644 --- a/tests/test_blacklist_middleware.py +++ b/tests/test_blacklist_middleware.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from aiogram.types import CallbackQuery, Message + from helper_bot.middlewares.blacklist_middleware import BlacklistMiddleware diff --git a/tests/test_blacklist_repository.py b/tests/test_blacklist_repository.py index 0443820..5b323a7 100644 --- a/tests/test_blacklist_repository.py +++ b/tests/test_blacklist_repository.py @@ -3,6 +3,7 @@ from datetime import datetime from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest + from database.models import BlacklistUser from database.repositories.blacklist_repository import BlacklistRepository diff --git a/tests/test_bot_settings_repository.py b/tests/test_bot_settings_repository.py index 8b02a2e..47b44dd 100644 --- a/tests/test_bot_settings_repository.py +++ b/tests/test_bot_settings_repository.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest + from database.repositories.bot_settings_repository import BotSettingsRepository @@ -115,11 +116,14 @@ class TestBotSettingsRepository: @pytest.mark.asyncio async def test_get_auto_moderation_settings(self, repository): """Тест получения всех настроек авто-модерации.""" - with patch.object( - repository, "get_bool_setting", new_callable=AsyncMock - ) as mock_bool, patch.object( - repository, "get_float_setting", new_callable=AsyncMock - ) as mock_float: + with ( + patch.object( + repository, "get_bool_setting", new_callable=AsyncMock + ) as mock_bool, + patch.object( + repository, "get_float_setting", new_callable=AsyncMock + ) as mock_float, + ): mock_bool.side_effect = [True, False] mock_float.side_effect = [0.8, 0.4] @@ -133,11 +137,14 @@ class TestBotSettingsRepository: @pytest.mark.asyncio async def test_toggle_auto_publish(self, repository): """Тест переключения авто-публикации.""" - with patch.object( - repository, "get_bool_setting", new_callable=AsyncMock - ) as mock_get, patch.object( - repository, "set_bool_setting", new_callable=AsyncMock - ) as mock_set: + with ( + patch.object( + repository, "get_bool_setting", new_callable=AsyncMock + ) as mock_get, + patch.object( + repository, "set_bool_setting", new_callable=AsyncMock + ) as mock_set, + ): mock_get.return_value = False result = await repository.toggle_auto_publish() @@ -148,11 +155,14 @@ class TestBotSettingsRepository: @pytest.mark.asyncio async def test_toggle_auto_decline(self, repository): """Тест переключения авто-отклонения.""" - with patch.object( - repository, "get_bool_setting", new_callable=AsyncMock - ) as mock_get, patch.object( - repository, "set_bool_setting", new_callable=AsyncMock - ) as mock_set: + with ( + patch.object( + repository, "get_bool_setting", new_callable=AsyncMock + ) as mock_get, + patch.object( + repository, "set_bool_setting", new_callable=AsyncMock + ) as mock_set, + ): mock_get.return_value = True result = await repository.toggle_auto_decline() diff --git a/tests/test_callback_dependency_factory.py b/tests/test_callback_dependency_factory.py index 3bdb285..8a36079 100644 --- a/tests/test_callback_dependency_factory.py +++ b/tests/test_callback_dependency_factory.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock, patch import pytest + from helper_bot.handlers.callback.dependency_factory import ( get_ban_service, get_post_publish_service, diff --git a/tests/test_callback_handlers.py b/tests/test_callback_handlers.py index 0fd1caf..7f85b78 100644 --- a/tests/test_callback_handlers.py +++ b/tests/test_callback_handlers.py @@ -3,6 +3,7 @@ from datetime import datetime from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest + from helper_bot.handlers.callback.callback_handlers import ( change_page, delete_voice_message, diff --git a/tests/test_callback_services.py b/tests/test_callback_services.py index 3ec7b20..216f6b9 100644 --- a/tests/test_callback_services.py +++ b/tests/test_callback_services.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from aiogram.types import CallbackQuery, Message + from helper_bot.handlers.callback.constants import CONTENT_TYPE_MEDIA_GROUP from helper_bot.handlers.callback.exceptions import ( PostNotFoundError, diff --git a/tests/test_decorators.py b/tests/test_decorators.py index ca8bbd5..479f8c8 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from aiogram import types + from helper_bot.handlers.group.decorators import error_handler as group_error_handler from helper_bot.handlers.private.decorators import ( error_handler as private_error_handler, diff --git a/tests/test_deepseek_service.py b/tests/test_deepseek_service.py index 91e9fb4..6575cbd 100644 --- a/tests/test_deepseek_service.py +++ b/tests/test_deepseek_service.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest + from helper_bot.services.scoring.deepseek_service import DeepSeekService from helper_bot.services.scoring.exceptions import ( DeepSeekAPIError, diff --git a/tests/test_dependencies_middleware.py b/tests/test_dependencies_middleware.py index fdd4c12..c345b94 100644 --- a/tests/test_dependencies_middleware.py +++ b/tests/test_dependencies_middleware.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest + from helper_bot.middlewares.dependencies_middleware import DependenciesMiddleware diff --git a/tests/test_improved_media_processing.py b/tests/test_improved_media_processing.py index d2baf83..05d78e7 100644 --- a/tests/test_improved_media_processing.py +++ b/tests/test_improved_media_processing.py @@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest from aiogram import types + from helper_bot.utils.helper_func import ( add_in_db_media, add_in_db_media_mediagroup, diff --git a/tests/test_keyboards_and_filters.py b/tests/test_keyboards_and_filters.py index c83aa37..74a5cb9 100644 --- a/tests/test_keyboards_and_filters.py +++ b/tests/test_keyboards_and_filters.py @@ -7,6 +7,7 @@ from aiogram.types import ( KeyboardButton, ReplyKeyboardMarkup, ) + from database.async_db import AsyncBotDB from helper_bot.filters.main import ChatTypeFilter from helper_bot.keyboards.keyboards import ( diff --git a/tests/test_main.py b/tests/test_main.py index 0a492c2..0991cbc 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -6,6 +6,7 @@ import asyncio from unittest.mock import AsyncMock, MagicMock, patch import pytest + from helper_bot.main import start_bot, start_bot_with_retry diff --git a/tests/test_message_repository.py b/tests/test_message_repository.py index 2fdbceb..b8f2557 100644 --- a/tests/test_message_repository.py +++ b/tests/test_message_repository.py @@ -3,6 +3,7 @@ from datetime import datetime from unittest.mock import AsyncMock, MagicMock import pytest + from database.models import UserMessage from database.repositories.message_repository import MessageRepository diff --git a/tests/test_message_repository_integration.py b/tests/test_message_repository_integration.py index 8e129ab..84c3b3a 100644 --- a/tests/test_message_repository_integration.py +++ b/tests/test_message_repository_integration.py @@ -4,6 +4,7 @@ import tempfile from datetime import datetime import pytest + from database.models import UserMessage from database.repositories.message_repository import MessageRepository diff --git a/tests/test_metrics_middleware.py b/tests/test_metrics_middleware.py index 999152a..80e1a4b 100644 --- a/tests/test_metrics_middleware.py +++ b/tests/test_metrics_middleware.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from aiogram.types import Message + from helper_bot.middlewares.metrics_middleware import ( DatabaseMetricsMiddleware, ErrorMetricsMiddleware, diff --git a/tests/test_post_repository.py b/tests/test_post_repository.py index dd982bd..797be28 100644 --- a/tests/test_post_repository.py +++ b/tests/test_post_repository.py @@ -3,6 +3,7 @@ from datetime import datetime from unittest.mock import AsyncMock, MagicMock import pytest + from database.models import MessageContentLink, PostContent, TelegramPost from database.repositories.post_repository import PostRepository diff --git a/tests/test_post_repository_integration.py b/tests/test_post_repository_integration.py index 43bb399..d485fec 100644 --- a/tests/test_post_repository_integration.py +++ b/tests/test_post_repository_integration.py @@ -4,6 +4,7 @@ import tempfile from datetime import datetime import pytest + from database.models import MessageContentLink, PostContent, TelegramPost from database.repositories.post_repository import PostRepository diff --git a/tests/test_post_service.py b/tests/test_post_service.py index 591fb6b..ffabd2b 100644 --- a/tests/test_post_service.py +++ b/tests/test_post_service.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest from aiogram import types + from database.models import TelegramPost, User from helper_bot.handlers.private.services import BotSettings, PostService diff --git a/tests/test_rag_client.py b/tests/test_rag_client.py index 9a812eb..241c4c6 100644 --- a/tests/test_rag_client.py +++ b/tests/test_rag_client.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest + from helper_bot.services.scoring.exceptions import ( InsufficientExamplesError, ScoringError, diff --git a/tests/test_rate_limit_middleware.py b/tests/test_rate_limit_middleware.py index 640b52f..ebf0c83 100644 --- a/tests/test_rate_limit_middleware.py +++ b/tests/test_rate_limit_middleware.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from aiogram.types import CallbackQuery, Message, Update + from helper_bot.middlewares.rate_limit_middleware import RateLimitMiddleware diff --git a/tests/test_rate_limit_monitor.py b/tests/test_rate_limit_monitor.py index 8f4bff3..ef55a23 100644 --- a/tests/test_rate_limit_monitor.py +++ b/tests/test_rate_limit_monitor.py @@ -7,6 +7,7 @@ from collections import deque from unittest.mock import patch import pytest + from helper_bot.utils.rate_limit_monitor import ( RateLimitMonitor, RateLimitStats, diff --git a/tests/test_rate_limiter.py b/tests/test_rate_limiter.py index 2a72d45..ed3e7bd 100644 --- a/tests/test_rate_limiter.py +++ b/tests/test_rate_limiter.py @@ -7,6 +7,7 @@ import time from unittest.mock import AsyncMock, MagicMock, patch import pytest + from helper_bot.config.rate_limit_config import RateLimitSettings, get_rate_limit_config from helper_bot.utils.rate_limit_monitor import ( RateLimitMonitor, diff --git a/tests/test_refactored_admin_handlers.py b/tests/test_refactored_admin_handlers.py index 3f212d6..7633715 100644 --- a/tests/test_refactored_admin_handlers.py +++ b/tests/test_refactored_admin_handlers.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, Mock, patch import pytest from aiogram import types from aiogram.fsm.context import FSMContext + from helper_bot.handlers.admin.exceptions import ( InvalidInputError, UserAlreadyBannedError, diff --git a/tests/test_refactored_group_handlers.py b/tests/test_refactored_group_handlers.py index 5a8b15b..464c95f 100644 --- a/tests/test_refactored_group_handlers.py +++ b/tests/test_refactored_group_handlers.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock import pytest from aiogram import types from aiogram.fsm.context import FSMContext + from helper_bot.handlers.group.constants import ERROR_MESSAGES, FSM_STATES from helper_bot.handlers.group.exceptions import ( NoReplyToMessageError, diff --git a/tests/test_refactored_private_handlers.py b/tests/test_refactored_private_handlers.py index 434a7e6..515eb2c 100644 --- a/tests/test_refactored_private_handlers.py +++ b/tests/test_refactored_private_handlers.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest from aiogram import types from aiogram.fsm.context import FSMContext + from helper_bot.handlers.private.constants import BUTTON_TEXTS, FSM_STATES from helper_bot.handlers.private.private_handlers import ( PrivateHandlers, diff --git a/tests/test_s3_storage.py b/tests/test_s3_storage.py index ac5bab8..214fc78 100644 --- a/tests/test_s3_storage.py +++ b/tests/test_s3_storage.py @@ -7,6 +7,7 @@ from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest + from helper_bot.utils.s3_storage import S3StorageService diff --git a/tests/test_scoring_services.py b/tests/test_scoring_services.py index 9dbce67..c7d427d 100644 --- a/tests/test_scoring_services.py +++ b/tests/test_scoring_services.py @@ -137,6 +137,7 @@ class TestVectorStore: """Создает VectorStore для тестов.""" try: import numpy as np + from helper_bot.services.scoring.vector_store import VectorStore return VectorStore(vector_dim=768, max_examples=100) diff --git a/tests/test_server_prometheus.py b/tests/test_server_prometheus.py index 5e6dc55..42631a5 100644 --- a/tests/test_server_prometheus.py +++ b/tests/test_server_prometheus.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from aiohttp import web + from helper_bot.server_prometheus import ( MetricsServer, start_metrics_server, diff --git a/tests/test_text_middleware.py b/tests/test_text_middleware.py index 6187382..63c64b1 100644 --- a/tests/test_text_middleware.py +++ b/tests/test_text_middleware.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest + from helper_bot.middlewares.text_middleware import BulkTextMiddleware diff --git a/tests/test_utils.py b/tests/test_utils.py index 53b3c4d..b57d253 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,8 +2,9 @@ import os from datetime import datetime from unittest.mock import AsyncMock, Mock, patch -import helper_bot.utils.messages as messages # Import for patching constants import pytest + +import helper_bot.utils.messages as messages # Import for patching constants from database.async_db import AsyncBotDB from helper_bot.utils.base_dependency_factory import ( BaseDependencyFactory, diff --git a/tests/test_voice_bot_architecture.py b/tests/test_voice_bot_architecture.py index 3788d51..9f7b7bc 100644 --- a/tests/test_voice_bot_architecture.py +++ b/tests/test_voice_bot_architecture.py @@ -3,6 +3,7 @@ from pathlib import Path from unittest.mock import AsyncMock, Mock, patch import pytest + from helper_bot.handlers.voice.exceptions import AudioProcessingError, VoiceMessageError from helper_bot.handlers.voice.services import VoiceBotService from helper_bot.handlers.voice.utils import ( diff --git a/tests/test_voice_constants.py b/tests/test_voice_constants.py index ab3eec1..b6774ef 100644 --- a/tests/test_voice_constants.py +++ b/tests/test_voice_constants.py @@ -1,4 +1,5 @@ import pytest + from helper_bot.handlers.voice.constants import ( BTN_LISTEN, BTN_SPEAK, diff --git a/tests/test_voice_exceptions.py b/tests/test_voice_exceptions.py index f94349b..38d8150 100644 --- a/tests/test_voice_exceptions.py +++ b/tests/test_voice_exceptions.py @@ -1,4 +1,5 @@ import pytest + from helper_bot.handlers.voice.exceptions import ( AudioProcessingError, VoiceBotError, diff --git a/tests/test_voice_handler.py b/tests/test_voice_handler.py index 304bfc8..af1fff9 100644 --- a/tests/test_voice_handler.py +++ b/tests/test_voice_handler.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest from aiogram import types from aiogram.fsm.context import FSMContext + from helper_bot.handlers.voice.constants import STATE_STANDUP_WRITE, STATE_START from helper_bot.handlers.voice.voice_handler import VoiceHandlers diff --git a/tests/test_voice_services.py b/tests/test_voice_services.py index 75de09a..8448d82 100644 --- a/tests/test_voice_services.py +++ b/tests/test_voice_services.py @@ -3,6 +3,7 @@ from pathlib import Path from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest + from helper_bot.handlers.voice.exceptions import AudioProcessingError, VoiceMessageError from helper_bot.handlers.voice.services import VoiceBotService diff --git a/tests/test_voice_utils.py b/tests/test_voice_utils.py index f744ac0..50de559 100644 --- a/tests/test_voice_utils.py +++ b/tests/test_voice_utils.py @@ -3,6 +3,7 @@ from unittest.mock import Mock, patch import pytest from aiogram import types + from helper_bot.handlers.voice.utils import ( format_time_ago, get_last_message_text, -- 2.49.1