Pull Request: dev-15 #17

Merged
kerrad merged 5 commits from dev-15 into master 2026-02-28 21:02:01 +00:00
18 changed files with 1296 additions and 144 deletions
Showing only changes of commit 694cf1c106 - Show all commits

View File

@@ -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.01.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 — независимо (можно параллельно с 13)
```
---
## Рекомендуемый порядок реализации
1. Этап 0 — подготовка
2. Этапы 1, 2, 3 — независимо, можно в любом порядке
3. Этап 4 — после готовности RAG
4. Этап 5 — после 4 или параллельно с 13
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, ветки, деплой

View File

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

View File

@@ -279,6 +279,34 @@ class AsyncBotDB:
"""Получает тексты отклоненных постов для обучения RAG.""" """Получает тексты отклоненных постов для обучения RAG."""
return await self.factory.posts.get_declined_posts_texts(limit) 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( async def set_user_blacklist(
self, self,
@@ -361,7 +389,8 @@ class AsyncBotDB:
"""Возвращает список пользователей в черном списке с учетом смещения и ограничения.""" """Возвращает список пользователей в черном списке с учетом смещения и ограничения."""
users = await self.factory.blacklist.get_all_users(offset, limit) users = await self.factory.blacklist.get_all_users(offset, limit)
return [ 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]: async def get_blacklist_users_by_id(self, user_id: int) -> Optional[tuple]:

View File

@@ -1,4 +1,4 @@
from typing import Optional from typing import Optional, Tuple
from database.base import DatabaseConnection from database.base import DatabaseConnection
from database.models import BlacklistHistoryRecord from database.models import BlacklistHistoryRecord
@@ -120,3 +120,55 @@ class BlacklistHistoryRepository(DatabaseConnection):
f"Ошибка обновления даты разбана в истории для user_id={user_id}: {str(e)}" f"Ошибка обновления даты разбана в истории для user_id={user_id}: {str(e)}"
) )
return False 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

View File

@@ -87,13 +87,14 @@ class BlacklistRepository(DatabaseConnection):
async def get_all_users( async def get_all_users(
self, offset: int = 0, limit: int = 10 self, offset: int = 0, limit: int = 10
) -> List[BlacklistUser]: ) -> List[BlacklistUser]:
"""Возвращает список пользователей в черном списке.""" """Возвращает список пользователей в черном списке, отсортированных по дате бана (новые первые)."""
query = """ query = """
SELECT user_id, message_for_user, date_to_unban, created_at, ban_author SELECT user_id, message_for_user, date_to_unban, created_at, ban_author
FROM blacklist 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 = [] users = []
for row in rows: for row in rows:
@@ -113,10 +114,11 @@ class BlacklistRepository(DatabaseConnection):
return users return users
async def get_all_users_no_limit(self) -> List[BlacklistUser]: async def get_all_users_no_limit(self) -> List[BlacklistUser]:
"""Возвращает список всех пользователей в черном списке без лимитов.""" """Возвращает список всех пользователей в черном списке без лимитов, отсортированных по дате бана (новые первые)."""
query = """ query = """
SELECT user_id, message_for_user, date_to_unban, created_at, ban_author SELECT user_id, message_for_user, date_to_unban, created_at, ban_author
FROM blacklist FROM blacklist
ORDER BY created_at DESC
""" """
rows = await self._execute_query_with_result(query) rows = await self._execute_query_with_result(query)

View File

@@ -545,3 +545,67 @@ class PostRepository(DatabaseConnection):
texts = [row[0] for row in rows if row[0]] texts = [row[0] for row in rows if row[0]]
self.logger.info(f"Получено {len(texts)} отклоненных постов для обучения") self.logger.info(f"Получено {len(texts)} отклоненных постов для обучения")
return 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

View File

@@ -138,7 +138,7 @@ async def get_banned_users(
keyboard = create_keyboard_with_pagination( keyboard = create_keyboard_with_pagination(
1, len(buttons_list), buttons_list, "unlock" 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: else:
await message.answer( await message.answer(
text="В списке заблокированных пользователей никого нет" text="В списке заблокированных пользователей никого нет"
@@ -216,9 +216,15 @@ async def get_ml_stats(message: types.Message, state: FSMContext, **kwargs):
# Fallback на синхронные данные (если API недоступен) # Fallback на синхронные данные (если API недоступен)
lines.append(f" • API URL: {rag.get('api_url', 'N/A')}") lines.append(f" • API URL: {rag.get('api_url', 'N/A')}")
if "enabled" in rag: if "enabled" in rag:
lines.append( if rag.get("enabled"):
f" • Статус: {'✅ Включен' if rag.get('enabled') else '❌ Отключен'}" lines.append(
) f" • Статус: ⚠️ Включен, но API не отвечает"
)
lines.append(
f" • Проверьте доступность сервиса и API ключ"
)
else:
lines.append(f" • Статус: ❌ Отключен")
lines.append("") lines.append("")

View File

@@ -255,6 +255,8 @@ async def change_page(call: CallbackQuery, bot_db: MagicData("bot_db"), **kwargs
logger.info(f"Переход на страницу {page_number}") logger.info(f"Переход на страницу {page_number}")
items_per_page = 9
if call.message.text == "Список пользователей которые последними обращались к боту": if call.message.text == "Список пользователей которые последними обращались к боту":
list_users = await bot_db.get_last_users(30) list_users = await bot_db.get_last_users(30)
keyboard = create_keyboard_with_pagination( keyboard = create_keyboard_with_pagination(
@@ -266,11 +268,13 @@ async def change_page(call: CallbackQuery, bot_db: MagicData("bot_db"), **kwargs
reply_markup=keyboard, reply_markup=keyboard,
) )
else: 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( await call.bot.edit_message_text(
chat_id=call.message.chat.id, chat_id=call.message.chat.id,
message_id=call.message.message_id, message_id=call.message.message_id,
text=message_user, text=message_user,
parse_mode="HTML",
) )
buttons = await get_banned_users_buttons(bot_db) buttons = await get_banned_users_buttons(bot_db)

View File

@@ -8,7 +8,7 @@ from aiogram.types import CallbackQuery
from helper_bot.keyboards.keyboards import create_keyboard_for_ban_reason from helper_bot.keyboards.keyboards import create_keyboard_for_ban_reason
from helper_bot.utils.helper_func import ( from helper_bot.utils.helper_func import (
delete_user_blacklist, delete_user_blacklist,
get_text_message, get_publish_text,
send_audio_message, send_audio_message,
send_media_group_to_channel, send_media_group_to_channel,
send_photo_message, send_photo_message,
@@ -137,7 +137,7 @@ class PostPublishService:
raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных") raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных")
# Формируем финальный текст с учетом is_anonymous # Формируем финальный текст с учетом is_anonymous
formatted_text = get_text_message( formatted_text = get_publish_text(
raw_text, user.first_name, user.username, is_anonymous raw_text, user.first_name, user.username, is_anonymous
) )
@@ -188,7 +188,7 @@ class PostPublishService:
raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных") raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных")
# Формируем финальный текст с учетом is_anonymous # Формируем финальный текст с учетом is_anonymous
formatted_text = get_text_message( formatted_text = get_publish_text(
raw_text, user.first_name, user.username, is_anonymous raw_text, user.first_name, user.username, is_anonymous
) )
@@ -247,7 +247,7 @@ class PostPublishService:
raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных") raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных")
# Формируем финальный текст с учетом is_anonymous # Формируем финальный текст с учетом is_anonymous
formatted_text = get_text_message( formatted_text = get_publish_text(
raw_text, user.first_name, user.username, is_anonymous raw_text, user.first_name, user.username, is_anonymous
) )
@@ -340,7 +340,7 @@ class PostPublishService:
raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных") raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных")
# Формируем финальный текст с учетом is_anonymous # Формируем финальный текст с учетом is_anonymous
formatted_text = get_text_message( formatted_text = get_publish_text(
raw_text, user.first_name, user.username, is_anonymous raw_text, user.first_name, user.username, is_anonymous
) )
@@ -452,7 +452,7 @@ class PostPublishService:
f"Пользователь {author_id} не найден в базе данных" f"Пользователь {author_id} не найден в базе данных"
) )
formatted_text = get_text_message( formatted_text = get_publish_text(
raw_text, user.first_name, user.username, is_anonymous raw_text, user.first_name, user.username, is_anonymous
) )
@@ -838,7 +838,7 @@ class BanService:
await self.db.set_user_blacklist( await self.db.set_user_blacklist(
user_id=author_id, user_id=author_id,
user_name=None, user_name=None,
message_for_user="Спам", message_for_user="Последний пост",
date_to_unban=date_to_unban, date_to_unban=date_to_unban,
ban_author=ban_author_id, ban_author=ban_author_id,
) )

View File

@@ -291,12 +291,33 @@ class PrivateHandlers:
"""Handle messages in admin chat states""" """Handle messages in admin chat states"""
# User service operations with metrics # User service operations with metrics
await self.user_service.update_user_activity(message.from_user.id) 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() current_date = datetime.now()
date = int(current_date.timestamp()) date = int(current_date.timestamp())
# Сохраняем message_id из результата send_message
await self.db.add_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") question = messages.get_message(get_first_name(message), "QUESTION")

View File

@@ -156,6 +156,92 @@ class UserService:
username = message.from_user.username or "Без никнейма" username = message.from_user.username or "Без никнейма"
return html.escape(full_name), html.escape(username) 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"<b>Сообщение пользователя:</b>\n\n"
f"<b>{safe_message_text}</b>"
)
return formatted_message
class PostService: class PostService:
"""Service for post-related operations""" """Service for post-related operations"""
@@ -236,6 +322,18 @@ class PostService:
f"PostService: Ошибка сохранения скоров для {message_id}: {e}" 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: async def _get_scores_with_error_handling(self, text: str) -> tuple:
""" """
Получает скоры для текста поста с обработкой ошибок. Получает скоры для текста поста с обработкой ошибок.
@@ -321,6 +419,37 @@ class PostService:
error_message, error_message,
) = await self._get_scores_with_error_handling(original_raw_text) ) = 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<b>Текст поста:</b>\n"{html.escape(truncated_text)}"'
similar_warning = (
f"\n\n⚠️ <b>Похожий пост за последние 24ч</b> "
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 text_for_post = original_raw_text
if error_message: if error_message:
@@ -347,9 +476,11 @@ class PostService:
message.from_user.username, message.from_user.username,
deepseek_score=deepseek_score, deepseek_score=deepseek_score,
rag_score=rag_score, rag_score=rag_score,
rag_confidence=rag_confidence, user_id=message.from_user.id,
rag_score_pos_only=rag_score_pos_only,
) )
# Добавляем предупреждение о похожем посте
if similar_warning:
post_text += similar_warning
# Определяем анонимность по исходному тексту (без сообщения об ошибке) # Определяем анонимность по исходному тексту (без сообщения об ошибке)
is_anonymous = determine_anonymity(original_raw_text) is_anonymous = determine_anonymity(original_raw_text)
@@ -401,8 +532,11 @@ class PostService:
markup, markup,
) )
elif content_type == "media_group": 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( await self._process_media_group_background(
message, message,
album, album,
@@ -411,6 +545,7 @@ class PostService:
is_anonymous, is_anonymous,
original_raw_text, original_raw_text,
ml_scores_json, ml_scores_json,
rag_score,
) )
return return
else: 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: except Exception as e:
logger.error( logger.error(
f"PostService: Критическая ошибка в _process_post_background для {content_type}: {e}" f"PostService: Критическая ошибка в _process_post_background для {content_type}: {e}"
@@ -462,6 +605,7 @@ class PostService:
is_anonymous: bool, is_anonymous: bool,
original_raw_text: str, original_raw_text: str,
ml_scores_json: str = None, ml_scores_json: str = None,
rag_score: float = None,
) -> None: ) -> None:
"""Обрабатывает медиагруппу в фоне""" """Обрабатывает медиагруппу в фоне"""
try: try:
@@ -495,6 +639,14 @@ class PostService:
self._save_scores_background(main_post_id, ml_scores_json) 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: for msg_id in media_group_message_ids:
await self.db.add_message_link(main_post_id, msg_id) await self.db.add_message_link(main_post_id, msg_id)
@@ -552,8 +704,7 @@ class PostService:
message.from_user.username, message.from_user.username,
deepseek_score=deepseek_score, deepseek_score=deepseek_score,
rag_score=rag_score, rag_score=rag_score,
rag_confidence=rag_confidence, user_id=message.from_user.id,
rag_score_pos_only=rag_score_pos_only,
) )
markup = get_reply_keyboard_for_post() markup = get_reply_keyboard_for_post()
@@ -611,8 +762,7 @@ class PostService:
message.from_user.username, message.from_user.username,
deepseek_score=deepseek_score, deepseek_score=deepseek_score,
rag_score=rag_score, rag_score=rag_score,
rag_confidence=rag_confidence, user_id=message.from_user.id,
rag_score_pos_only=rag_score_pos_only,
) )
markup = get_reply_keyboard_for_post() markup = get_reply_keyboard_for_post()
@@ -677,8 +827,7 @@ class PostService:
message.from_user.username, message.from_user.username,
deepseek_score=deepseek_score, deepseek_score=deepseek_score,
rag_score=rag_score, rag_score=rag_score,
rag_confidence=rag_confidence, user_id=message.from_user.id,
rag_score_pos_only=rag_score_pos_only,
) )
markup = get_reply_keyboard_for_post() markup = get_reply_keyboard_for_post()
@@ -770,8 +919,7 @@ class PostService:
message.from_user.username, message.from_user.username,
deepseek_score=deepseek_score, deepseek_score=deepseek_score,
rag_score=rag_score, rag_score=rag_score,
rag_confidence=rag_confidence, user_id=message.from_user.id,
rag_score_pos_only=rag_score_pos_only,
) )
markup = get_reply_keyboard_for_post() markup = get_reply_keyboard_for_post()
@@ -869,8 +1017,7 @@ class PostService:
message.from_user.username, message.from_user.username,
deepseek_score=deepseek_score, deepseek_score=deepseek_score,
rag_score=rag_score, rag_score=rag_score,
rag_confidence=rag_confidence, user_id=message.from_user.id,
rag_score_pos_only=rag_score_pos_only,
) )
is_anonymous = determine_anonymity(raw_caption) is_anonymous = determine_anonymity(raw_caption)

View File

@@ -4,7 +4,8 @@ HTTP клиент для взаимодействия с внешним RAG се
Использует REST API для получения скоров и отправки примеров. Использует REST API для получения скоров и отправки примеров.
""" """
from typing import Any, Dict, Optional from dataclasses import dataclass
from typing import Any, Dict, List, Optional
import httpx import httpx
@@ -15,6 +16,30 @@ from .base import ScoringResult
from .exceptions import InsufficientExamplesError, ScoringError, TextTooShortError 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: class RagApiClient:
""" """
HTTP клиент для взаимодействия с внешним RAG сервисом. HTTP клиент для взаимодействия с внешним RAG сервисом.
@@ -329,21 +354,39 @@ class RagApiClient:
Словарь со статистикой или пустой словарь при ошибке Словарь со статистикой или пустой словарь при ошибке
""" """
if not self._enabled: if not self._enabled:
logger.debug("RagApiClient: get_stats пропущен - клиент отключен")
return {} return {}
try: try:
logger.debug(f"RagApiClient: Запрос статистики от {self.api_url}/stats")
response = await self._client.get(f"{self.api_url}/stats") response = await self._client.get(f"{self.api_url}/stats")
if response.status_code == 200: 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: else:
logger.warning( logger.warning(
f"RagApiClient: Неожиданный статус при получении статистики: {response.status_code}" f"RagApiClient: Неожиданный статус при получении статистики: "
f"status={response.status_code}, body={response.text[:200]}"
) )
return {} return {}
except httpx.TimeoutException: except httpx.TimeoutException:
logger.warning(f"RagApiClient: Таймаут при получении статистики") logger.warning(
f"RagApiClient: Таймаут при получении статистики (timeout={self.timeout}s)"
)
return {} return {}
except httpx.RequestError as e: except httpx.RequestError as e:
logger.warning( logger.warning(
@@ -365,3 +408,135 @@ class RagApiClient:
"api_url": self.api_url, "api_url": self.api_url,
"timeout": self.timeout, "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

View File

@@ -221,3 +221,43 @@ class ScoringManager:
stats["deepseek"] = self.deepseek_service.get_stats() stats["deepseek"] = self.deepseek_service.get_stats()
return 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)

View File

@@ -138,6 +138,52 @@ def determine_anonymity(post_text: str) -> bool:
return False 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( def get_text_message(
post_text: str, post_text: str,
first_name: str, first_name: str,
@@ -147,10 +193,10 @@ def get_text_message(
rag_score: Optional[float] = None, rag_score: Optional[float] = None,
rag_confidence: Optional[float] = None, rag_confidence: Optional[float] = None,
rag_score_pos_only: Optional[float] = None, rag_score_pos_only: Optional[float] = None,
user_id: Optional[int] = None,
): ):
""" """
Форматирует текст сообщения для публикации в зависимости от наличия ключевых слов "анон" и "неанон" Форматирует текст сообщения для модерации (с полной информацией об авторе и скорами).
или переданного параметра is_anonymous.
Args: Args:
post_text: Текст сообщения post_text: Текст сообщения
@@ -161,64 +207,69 @@ def get_text_message(
rag_score: Скор от RAG/ruBERT neg/pos (0.0-1.0, опционально) rag_score: Скор от RAG/ruBERT neg/pos (0.0-1.0, опционально)
rag_confidence: Уверенность RAG модели (0.0-1.0, зависит от количества примеров) rag_confidence: Уверенность RAG модели (0.0-1.0, зависит от количества примеров)
rag_score_pos_only: Скор RAG только по положительным примерам (0.0-1.0, опционально) rag_score_pos_only: Скор RAG только по положительным примерам (0.0-1.0, опционально)
user_id: ID пользователя Telegram (опционально)
Returns: Returns:
str: - Сформированный текст сообщения. str: - Сформированный текст сообщения для модерации.
""" """
# Экранируем post_text для безопасного использования в HTML # Экранируем post_text для безопасного использования в HTML
safe_post_text = html.escape(str(post_text)) if post_text else "" safe_post_text = html.escape(str(post_text)) if post_text else ""
# Экранируем username для безопасного использования в HTML # Экранируем username для безопасного использования в HTML
safe_username = html.escape(username) if username else None safe_username = html.escape(username) if username else None
safe_first_name = html.escape(first_name) if first_name else "Пользователь"
# Формируем строку с информацией об авторе # Формируем шапку с информацией об авторе
if safe_username: if safe_username:
author_info = f"{first_name} @{safe_username}" header = f"👤 От: {safe_first_name} (@{safe_username})"
else: else:
author_info = f"{first_name} (Ник не указан)" header = f"👤 От: {safe_first_name} (Ник не указан)"
# Формируем базовый текст if user_id:
# Если передан is_anonymous, используем его, иначе определяем по тексту (legacy) 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<b>Текст поста:</b>\n{separator}\n{safe_post_text}"
# Определяем анонимность и формируем подвал
if is_anonymous is not None: if is_anonymous is not None:
if is_anonymous: if is_anonymous:
final_text = f"{safe_post_text}\n\nПост опубликован анонимно" post_block += f"\n\nПост опубликован анонимно"
else: else:
final_text = f"{safe_post_text}\n\nАвтор поста: {author_info}" post_block += f"\n\n<b>Автор поста:</b> {author_info}"
else: else:
# Legacy: определяем по тексту # Legacy: определяем по тексту
if "неанон" in post_text or "не анон" in post_text: if "неанон" in post_text or "не анон" in post_text:
final_text = f"{safe_post_text}\n\nАвтор поста: {author_info}" post_block += f"\n\n<b>Автор поста:</b> {author_info}"
elif "анон" in post_text: elif "анон" in post_text:
final_text = f"{safe_post_text}\n\nПост опубликован анонимно" post_block += f"\n\nПост опубликован анонимно"
else: else:
final_text = f"{safe_post_text}\n\nАвтор поста: {author_info}" post_block += f"\n\n<b>Автор поста:</b> {author_info}"
# Добавляем блок со скорами если есть post_block += f"\n{separator}"
if (
deepseek_score is not None # Добавляем блок со скорами если есть (без RAG pos only и уверенности)
or rag_score is not None if deepseek_score is not None or rag_score is not None:
or rag_score_pos_only is not None scores_lines = ["📊 <b>Уверенность в одобрении:</b>"]
):
scores_lines = ["\n📊 Уверенность в одобрении:"]
if deepseek_score is not None: if deepseek_score is not None:
scores_lines.append(f"DeepSeek: {deepseek_score:.2f}") scores_lines.append(f"DeepSeek: {deepseek_score:.2f}")
if rag_score is not None: if rag_score is not None:
logger.debug( logger.debug(
f"get_text_message: Форматирование rag_score - " f"get_text_message: Форматирование rag_score - "
f"rag_score={rag_score} (type: {type(rag_score).__name__}), " 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}" f"formatted_value={rag_score:.2f}"
) )
rag_line = f"RAG neg/pos: {rag_score:.2f}" scores_lines.append(f"RAG neg/pos: {rag_score:.2f}")
if rag_confidence is not None: post_block += "\n" + "\n".join(scores_lines)
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)
return final_text return post_block
@track_time("download_file", "helper_func") @track_time("download_file", "helper_func")
@@ -854,15 +905,14 @@ async def send_text_message(
): ):
from .rate_limiter import send_with_rate_limit 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(): async def _send_message():
if markup is None: 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: else:
return await message.bot.send_message( 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) sent_message = await send_with_rate_limit(_send_message, chat_id)
@@ -878,16 +928,17 @@ async def send_photo_message(
post_text: str, post_text: str,
markup: types.ReplyKeyboardMarkup = None, markup: types.ReplyKeyboardMarkup = None,
): ):
# Экранируем post_text для безопасного использования в HTML
safe_post_text = html.escape(str(post_text)) if post_text else ""
if markup is None: if markup is None:
sent_message = await message.bot.send_photo( 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: else:
sent_message = await message.bot.send_photo( 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 return sent_message
@@ -901,16 +952,17 @@ async def send_video_message(
post_text: str = "", post_text: str = "",
markup: types.ReplyKeyboardMarkup = None, markup: types.ReplyKeyboardMarkup = None,
): ):
# Экранируем post_text для безопасного использования в HTML
safe_post_text = html.escape(str(post_text)) if post_text else ""
if markup is None: if markup is None:
sent_message = await message.bot.send_video( 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: else:
sent_message = await message.bot.send_video( 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 return sent_message
@@ -943,16 +995,17 @@ async def send_audio_message(
post_text: str, post_text: str,
markup: types.ReplyKeyboardMarkup = None, markup: types.ReplyKeyboardMarkup = None,
): ):
# Экранируем post_text для безопасного использования в HTML
safe_post_text = html.escape(str(post_text)) if post_text else ""
if markup is None: if markup is None:
sent_message = await message.bot.send_audio( 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: else:
sent_message = await message.bot.send_audio( 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 return sent_message
@@ -1012,11 +1065,12 @@ async def get_banned_users_list(offset: int, bot_db):
message - текст сообщения message - текст сообщения
user_ids - лист кортежей [(user_name: user_id)] 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" message = "Список заблокированных пользователей:\n"
for user in users: for user in users:
user_id, ban_reason, unban_date = user user_id, ban_reason, unban_date, ban_date = user
# Получаем имя пользователя из таблицы users # Получаем имя пользователя из таблицы users
username = await bot_db.get_username(user_id) username = await bot_db.get_username(user_id)
full_name = await bot_db.get_full_name_by_id(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 "Причина не указана" html.escape(str(ban_reason)) if ban_reason else "Причина не указана"
) )
# Форматируем дату разбана в человекочитаемый формат # Форматируем дату бана в человекочитаемый формат
if unban_date: safe_ban_date = _format_timestamp_to_date(ban_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 = "Дата не указана"
message += f"**Пользователь:** {safe_user_name}\n" # Форматируем дату разбана в человекочитаемый формат
message += f"**Причина бана:** {safe_ban_reason}\n" safe_unban_date = _format_timestamp_to_date(unban_date, default="Навсегда")
message += f"**Дата разбана:** {safe_unban_date}\n\n"
message += f"<b>Пользователь:</b> {safe_user_name}\n"
message += f"<b>Причина бана:</b> {safe_ban_reason}\n"
message += f"<b>Дата бана:</b> {safe_ban_date}\n"
message += f"<b>Дата разбана:</b> {safe_unban_date}\n\n"
return message 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_time("get_banned_users_buttons", "helper_func")
@track_errors("helper_func", "get_banned_users_buttons") @track_errors("helper_func", "get_banned_users_buttons")
@db_query_time("get_banned_users_buttons", "users", "select") @db_query_time("get_banned_users_buttons", "users", "select")

View File

@@ -274,9 +274,9 @@ class TestBlacklistRepository:
# Нормализуем SQL запрос (убираем лишние пробелы и переносы строк) # Нормализуем SQL запрос (убираем лишние пробелы и переносы строк)
actual_query = " ".join(call_args[0][0].split()) 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 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( blacklist_repository.logger.info.assert_called_once_with(
@@ -310,7 +310,7 @@ class TestBlacklistRepository:
# Нормализуем SQL запрос (убираем лишние пробелы и переносы строк) # Нормализуем SQL запрос (убираем лишние пробелы и переносы строк)
actual_query = " ".join(call_args[0][0].split()) 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 actual_query == expected_query
# Проверяем, что параметры пустые (без лимитов) # Проверяем, что параметры пустые (без лимитов)
assert len(call_args[0]) == 1 # Только SQL запрос, без параметров assert len(call_args[0]) == 1 # Только SQL запрос, без параметров

View File

@@ -85,7 +85,7 @@ class TestPostPublishService:
return call return call
@patch("helper_bot.handlers.callback.services.send_text_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_text_success( async def test_publish_post_text_success(
self, mock_get_text, mock_send_text, service, mock_call_text, mock_db 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_photo_message")
@patch("helper_bot.handlers.callback.services.send_text_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( async def test_publish_post_photo_success(
self, mock_get_text, mock_send_text, mock_send_photo, service, mock_db 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_video_message")
@patch("helper_bot.handlers.callback.services.send_text_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( async def test_publish_post_video_success(
self, mock_get_text, mock_send_text, mock_send_video, service, mock_db 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_audio_message")
@patch("helper_bot.handlers.callback.services.send_text_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( async def test_publish_post_audio_success(
self, mock_get_text, mock_send_text, mock_send_audio, service, mock_db 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_media_group_to_channel")
@patch("helper_bot.handlers.callback.services.send_text_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_media_group_success( async def test_publish_media_group_success(
self, mock_get_text, mock_send_text, mock_send_media, service, mock_db self, mock_get_text, mock_send_text, mock_send_media, service, mock_db
): ):

View File

@@ -29,6 +29,11 @@ class TestPrivateHandlers:
db.add_message = AsyncMock() db.add_message = AsyncMock()
db.update_helper_message = AsyncMock() db.update_helper_message = AsyncMock()
db.update_user_activity = 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 return db
@pytest.fixture @pytest.fixture
@@ -257,6 +262,7 @@ class TestPrivateHandlers:
"""resend_message_in_group при PRE_CHAT переводит в START и отправляет question.""" """resend_message_in_group при PRE_CHAT переводит в START и отправляет question."""
handlers = create_private_handlers(mock_db, mock_settings) handlers = create_private_handlers(mock_db, mock_settings)
mock_state.get_state = AsyncMock(return_value=FSM_STATES["PRE_CHAT"]) 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: with pytest.MonkeyPatch().context() as m:
m.setattr( m.setattr(
"helper_bot.handlers.private.private_handlers.get_reply_keyboard", "helper_bot.handlers.private.private_handlers.get_reply_keyboard",
@@ -267,9 +273,7 @@ class TestPrivateHandlers:
lambda x, y: "Question?", lambda x, y: "Question?",
) )
await handlers.resend_message_in_group_for_message(mock_message, mock_state) await handlers.resend_message_in_group_for_message(mock_message, mock_state)
mock_message.forward.assert_called_once_with( mock_message.bot.send_message.assert_called_once()
chat_id=mock_settings.group_for_message
)
mock_state.set_state.assert_called_once_with(FSM_STATES["START"]) mock_state.set_state.assert_called_once_with(FSM_STATES["START"])
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -279,6 +283,7 @@ class TestPrivateHandlers:
"""resend_message_in_group при CHAT оставляет в CHAT и отправляет question с leave markup.""" """resend_message_in_group при CHAT оставляет в CHAT и отправляет question с leave markup."""
handlers = create_private_handlers(mock_db, mock_settings) handlers = create_private_handlers(mock_db, mock_settings)
mock_state.get_state = AsyncMock(return_value=FSM_STATES["CHAT"]) 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: with pytest.MonkeyPatch().context() as m:
m.setattr( m.setattr(
"helper_bot.handlers.private.private_handlers.get_reply_keyboard_leave_chat", "helper_bot.handlers.private.private_handlers.get_reply_keyboard_leave_chat",
@@ -289,9 +294,7 @@ class TestPrivateHandlers:
lambda x, y: "Question?", lambda x, y: "Question?",
) )
await handlers.resend_message_in_group_for_message(mock_message, mock_state) await handlers.resend_message_in_group_for_message(mock_message, mock_state)
mock_message.forward.assert_called_once_with( mock_message.bot.send_message.assert_called_once()
chat_id=mock_settings.group_for_message
)
mock_message.answer.assert_called() mock_message.answer.assert_called()
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@@ -665,7 +665,7 @@ class TestSendMessageFunctions:
assert result == mock_sent_message assert result == mock_sent_message
mock_message.bot.send_photo.assert_called_once_with( 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 @pytest.mark.asyncio
@@ -684,7 +684,7 @@ class TestSendMessageFunctions:
assert result == mock_sent_message assert result == mock_sent_message
mock_message.bot.send_video.assert_called_once_with( 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 = AsyncMock()
mock_db.get_banned_users_from_db_with_limits.return_value = [ mock_db.get_banned_users_from_db_with_limits.return_value = [
(123, "Spam", 1704067200), # user_id, ban_reason, unban_date (timestamp) # user_id, ban_reason, unban_date (timestamp), ban_date (timestamp)
(456, "Violation", 1704153600), (123, "Spam", 1704067200, 1703980800),
(456, "Violation", 1704153600, 1704067200),
] ]
mock_db.get_username.return_value = None mock_db.get_username.return_value = None
mock_db.get_full_name_by_id.return_value = "Test User" mock_db.get_full_name_by_id.return_value = "Test User"
@@ -734,18 +735,16 @@ class TestUtilityFunctions:
assert "Test User" in result assert "Test User" in result
assert "Spam" in result assert "Spam" in result
assert "Violation" in result assert "Violation" in result
assert "<b>Дата бана:</b>" in result
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_banned_users_list_with_string_timestamp(self): async def test_get_banned_users_list_with_string_timestamp(self):
"""Тест получения списка заблокированных пользователей со строковым timestamp""" """Тест получения списка заблокированных пользователей со строковым timestamp"""
mock_db = AsyncMock() mock_db = AsyncMock()
mock_db.get_banned_users_from_db_with_limits.return_value = [ mock_db.get_banned_users_from_db_with_limits.return_value = [
( # user_id, ban_reason, unban_date (string timestamp), ban_date (string timestamp)
123, (123, "Spam", "1704067200", "1703980800"),
"Spam", (456, "Violation", "1704153600", "1704067200"),
"1704067200",
), # user_id, ban_reason, unban_date (string timestamp)
(456, "Violation", "1704153600"),
] ]
mock_db.get_username.return_value = None mock_db.get_username.return_value = None
mock_db.get_full_name_by_id.return_value = "Test User" mock_db.get_full_name_by_id.return_value = "Test User"
@@ -756,6 +755,7 @@ class TestUtilityFunctions:
assert "Test User" in result assert "Test User" in result
assert "Spam" in result assert "Spam" in result
assert "Violation" in result assert "Violation" in result
assert "<b>Дата бана:</b>" in result
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_banned_users_buttons(self): async def test_get_banned_users_buttons(self):