Compare commits
62 Commits
dev-9
...
118189da82
| Author | SHA1 | Date | |
|---|---|---|---|
| 118189da82 | |||
| d963ea83ad | |||
| 937c54ecfb | |||
| c3b75a0eb7 | |||
| b8428a5bac | |||
| 3d6b4353f9 | |||
| d0c8dab24a | |||
| 31314c9c9b | |||
| b3cdadfd8e | |||
| 694cf1c106 | |||
|
|
e2a6944ed8 | ||
| 73c36061c7 | |||
| d87d4e492e | |||
| 68041037bd | |||
|
|
3933259674 | ||
| 849a033ce9 | |||
| 561c9074dd | |||
| 5f66c86d99 | |||
| 2a09971628 | |||
| c03bd75b5e | |||
| bb95127013 | |||
| b8249ebd47 | |||
| c72c876de7 | |||
| 49432acb24 | |||
| 5ff66993fa | |||
| 9a6ab9a045 | |||
| f8962225ee | |||
| 731e68a597 | |||
| bba5550e15 | |||
| a5faa4bdc6 | |||
| e87f4af82f | |||
|
|
90473008bc | ||
| 35767c289c | |||
| a949f7e7db | |||
| 81ac65f555 | |||
| 7d173e3474 | |||
| 5d7b051554 | |||
| be8af704ba | |||
| feee7f010c | |||
| 7f6f0f028c | |||
| e2b1353408 | |||
| 07e72c4d14 | |||
|
|
4af649dea7 | ||
| c53c036751 | |||
| d2d7c83575 | |||
| 5a90591564 | |||
| 0e2aef8c03 | |||
| fecac6091e | |||
|
|
42f168f329 | ||
| f1ebcf453e | |||
| 3b841fcbfa | |||
| 7269130777 | |||
| 477e2666a3 | |||
| 89022aedaf | |||
| c6ba90552d | |||
| 34251507da | |||
| 03ed2bcf4e | |||
| 09e894e48f | |||
|
|
422c36074e | ||
|
|
574d374eaa | ||
|
|
3f5a6045d8 | ||
|
|
87ba7b0040 |
88
.cursor/rules/architecture.md
Normal file
88
.cursor/rules/architecture.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
---
|
||||||
|
description: "Архитектурные паттерны и структура проекта Telegram бота на aiogram"
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Архитектура проекта
|
||||||
|
|
||||||
|
Этот проект - Telegram бот на **aiogram 3.10.0** с четкой архитектурой и разделением ответственности.
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
helper_bot/
|
||||||
|
├── handlers/ # Обработчики событий (admin, callback, group, private, voice)
|
||||||
|
│ ├── services.py # Бизнес-логика для каждого модуля
|
||||||
|
│ ├── exceptions.py # Кастомные исключения модуля
|
||||||
|
│ └── dependencies.py # Dependency injection для модуля
|
||||||
|
├── middlewares/ # Middleware для cross-cutting concerns
|
||||||
|
├── utils/ # Утилиты и вспомогательные функции
|
||||||
|
├── keyboards/ # Клавиатуры для бота
|
||||||
|
└── filters/ # Кастомные фильтры
|
||||||
|
|
||||||
|
database/
|
||||||
|
├── repositories/ # Репозитории для работы с БД (Repository pattern)
|
||||||
|
├── models.py # Модели данных
|
||||||
|
├── base.py # Базовый класс DatabaseConnection
|
||||||
|
└── async_db.py # AsyncBotDB - основной интерфейс к БД
|
||||||
|
```
|
||||||
|
|
||||||
|
## Архитектурные паттерны
|
||||||
|
|
||||||
|
### 1. Repository Pattern
|
||||||
|
- Все операции с БД выполняются через репозитории в `database/repositories/`
|
||||||
|
- Каждая сущность имеет свой репозиторий (UserRepository, PostRepository, etc.)
|
||||||
|
- Репозитории наследуются от `DatabaseConnection` из `database/base.py`
|
||||||
|
- Используется `RepositoryFactory` для создания репозиториев
|
||||||
|
|
||||||
|
### 2. Service Layer Pattern
|
||||||
|
- Бизнес-логика вынесена в сервисы (`handlers/*/services.py`)
|
||||||
|
- Handlers только обрабатывают события и вызывают сервисы
|
||||||
|
- Сервисы работают с репозиториями через `AsyncBotDB`
|
||||||
|
|
||||||
|
### 3. Dependency Injection
|
||||||
|
- Используется `BaseDependencyFactory` для управления зависимостями
|
||||||
|
- Глобальный экземпляр доступен через `get_global_instance()`
|
||||||
|
- Зависимости внедряются через `DependenciesMiddleware`
|
||||||
|
- Для каждого модуля handlers может быть свой `dependencies.py` с фабриками
|
||||||
|
|
||||||
|
### 4. Middleware Pattern
|
||||||
|
- Middleware регистрируются в `main.py` на уровне dispatcher
|
||||||
|
- Порядок регистрации важен: DependenciesMiddleware → MetricsMiddleware → BlacklistMiddleware → RateLimitMiddleware
|
||||||
|
- Middleware обрабатывают cross-cutting concerns (логирование, метрики, rate limiting)
|
||||||
|
|
||||||
|
## Принципы
|
||||||
|
|
||||||
|
1. **Разделение ответственности**: Handlers → Services → Repositories
|
||||||
|
2. **Асинхронность**: Все операции с БД и API асинхронные
|
||||||
|
3. **Типизация**: Используются type hints везде, где возможно
|
||||||
|
4. **Логирование**: Всегда через `logs.custom_logger.logger`
|
||||||
|
5. **Метрики**: Декораторы `@track_time`, `@track_errors`, `@db_query_time` для мониторинга
|
||||||
|
|
||||||
|
## Версия Python
|
||||||
|
|
||||||
|
Проект использует **Python 3.11.9** во всех окружениях:
|
||||||
|
- Локальная разработка: Python 3.11.9 (указана в `.python-version`)
|
||||||
|
- Docker (production): Python 3.11.9-alpine (указана в `Dockerfile`)
|
||||||
|
- Минимальная версия: Python 3.11 (указана в `pyproject.toml`)
|
||||||
|
|
||||||
|
**Важно:**
|
||||||
|
- При написании кода можно использовать фичи Python 3.11
|
||||||
|
- Доступны улучшенные type hints, match/case (Python 3.10+)
|
||||||
|
- Используйте type hints везде, где возможно
|
||||||
|
- `@dataclass` доступен (Python 3.7+)
|
||||||
|
|
||||||
|
**Структура проекта:**
|
||||||
|
- Docker файлы находятся в двух местах:
|
||||||
|
- `/prod/Dockerfile` - для инфраструктуры (Python 3.11.9-alpine)
|
||||||
|
- `/prod/bots/telegram-helper-bot/Dockerfile` - для бота (Python 3.11.9-alpine)
|
||||||
|
- При обновлении версии Python нужно обновить оба Dockerfile
|
||||||
|
|
||||||
|
**Для локальной разработки:**
|
||||||
|
Рекомендуется использовать `pyenv` для установки Python 3.11.9:
|
||||||
|
```bash
|
||||||
|
pyenv install 3.11.9
|
||||||
|
pyenv local 3.11.9
|
||||||
|
```
|
||||||
|
|
||||||
|
Подробнее см. `docs/PYTHON_VERSION_MANAGEMENT.md`
|
||||||
106
.cursor/rules/code-style.md
Normal file
106
.cursor/rules/code-style.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
---
|
||||||
|
description: "Стиль кода, соглашения по именованию и форматированию"
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Стиль кода и соглашения
|
||||||
|
|
||||||
|
## Именование
|
||||||
|
|
||||||
|
### Классы
|
||||||
|
- **PascalCase**: `UserRepository`, `AdminService`, `BaseDependencyFactory`
|
||||||
|
- Имена классов должны быть существительными
|
||||||
|
|
||||||
|
### Функции и методы
|
||||||
|
- **snake_case**: `get_user_info()`, `handle_message()`, `create_tables()`
|
||||||
|
- Имена функций должны быть глаголами или начинаться с глагола
|
||||||
|
|
||||||
|
### Переменные
|
||||||
|
- **snake_case**: `user_id`, `bot_db`, `settings`, `message_text`
|
||||||
|
- Константы в **UPPER_SNAKE_CASE**: `FSM_STATES`, `ERROR_MESSAGES`
|
||||||
|
|
||||||
|
### Модули и пакеты
|
||||||
|
- **snake_case**: `admin_handlers.py`, `user_repository.py`
|
||||||
|
- Имена модулей должны быть короткими и понятными
|
||||||
|
|
||||||
|
## Импорты
|
||||||
|
|
||||||
|
Структура импортов (в порядке приоритета):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 1. Standard library imports
|
||||||
|
import os
|
||||||
|
import asyncio
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
# 2. Third-party imports
|
||||||
|
from aiogram import Router, types
|
||||||
|
from aiogram.filters import Command
|
||||||
|
|
||||||
|
# 3. Local imports - модули проекта
|
||||||
|
from database.async_db import AsyncBotDB
|
||||||
|
from helper_bot.handlers.admin.services import AdminService
|
||||||
|
|
||||||
|
# 4. Local imports - utilities
|
||||||
|
from logs.custom_logger import logger
|
||||||
|
|
||||||
|
# 5. Local imports - metrics (если используются)
|
||||||
|
from helper_bot.utils.metrics import track_time, track_errors
|
||||||
|
```
|
||||||
|
|
||||||
|
## Type Hints
|
||||||
|
|
||||||
|
- Всегда используйте type hints для параметров функций и возвращаемых значений
|
||||||
|
- Используйте `Optional[T]` для значений, которые могут быть `None`
|
||||||
|
- Используйте `List[T]`, `Dict[K, V]` для коллекций
|
||||||
|
- Используйте `Annotated` для dependency injection в aiogram
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
```python
|
||||||
|
async def get_user_info(self, user_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Получение информации о пользователе."""
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Документация
|
||||||
|
|
||||||
|
### Docstrings
|
||||||
|
- Используйте docstrings для всех классов и публичных методов
|
||||||
|
- Формат: краткое описание в одну строку или многострочный с подробностями
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def add_user(self, user: User) -> None:
|
||||||
|
"""Добавление нового пользователя с защитой от дублирования."""
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Комментарии
|
||||||
|
- Комментарии на русском языке (как и весь код)
|
||||||
|
- Используйте комментарии для объяснения "почему", а не "что"
|
||||||
|
- Разделители секций: `# ============================================================================`
|
||||||
|
|
||||||
|
## Форматирование
|
||||||
|
|
||||||
|
- Используйте 4 пробела для отступов (не табы)
|
||||||
|
- Максимальная длина строки: 100-120 символов (гибко)
|
||||||
|
- Пустые строки между логическими блоками
|
||||||
|
- Пустая строка перед `return` в конце функции (если функция не короткая)
|
||||||
|
|
||||||
|
## Структура файлов handlers
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 1. Импорты (по категориям)
|
||||||
|
# 2. Создание роутера
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
# 3. Регистрация middleware (если нужно)
|
||||||
|
router.message.middleware(SomeMiddleware())
|
||||||
|
|
||||||
|
# 4. Handlers с декораторами
|
||||||
|
@router.message(...)
|
||||||
|
@track_time("handler_name", "module_name")
|
||||||
|
@track_errors("module_name", "handler_name")
|
||||||
|
async def handler_function(...):
|
||||||
|
"""Описание handler."""
|
||||||
|
...
|
||||||
|
```
|
||||||
217
.cursor/rules/database-patterns.md
Normal file
217
.cursor/rules/database-patterns.md
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
---
|
||||||
|
description: "Паттерны работы с базой данных, репозитории и модели"
|
||||||
|
globs: ["database/**/*.py", "**/repositories/*.py"]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Паттерны работы с базой данных
|
||||||
|
|
||||||
|
## Repository Pattern
|
||||||
|
|
||||||
|
Все операции с БД выполняются через репозитории. Каждый репозиторий:
|
||||||
|
- Наследуется от `DatabaseConnection` из `database/base.py`
|
||||||
|
- Работает с одной сущностью (User, Post, Blacklist, etc.)
|
||||||
|
- Содержит методы для CRUD операций
|
||||||
|
- Использует асинхронные методы `_execute_query()` и `_execute_query_with_result()`
|
||||||
|
|
||||||
|
### Структура репозитория
|
||||||
|
|
||||||
|
```python
|
||||||
|
from database.base import DatabaseConnection
|
||||||
|
from database.models import User
|
||||||
|
|
||||||
|
class UserRepository(DatabaseConnection):
|
||||||
|
"""Репозиторий для работы с пользователями."""
|
||||||
|
|
||||||
|
async def create_tables(self):
|
||||||
|
"""Создание таблицы пользователей."""
|
||||||
|
query = '''
|
||||||
|
CREATE TABLE IF NOT EXISTS our_users (
|
||||||
|
user_id INTEGER NOT NULL PRIMARY KEY,
|
||||||
|
...
|
||||||
|
)
|
||||||
|
'''
|
||||||
|
await self._execute_query(query)
|
||||||
|
self.logger.info("Таблица пользователей создана")
|
||||||
|
|
||||||
|
async def add_user(self, user: User) -> None:
|
||||||
|
"""Добавление нового пользователя."""
|
||||||
|
query = "INSERT OR IGNORE INTO our_users (...) VALUES (...)"
|
||||||
|
params = (...)
|
||||||
|
await self._execute_query(query, params)
|
||||||
|
self.logger.info(f"Пользователь добавлен: {user.user_id}")
|
||||||
|
|
||||||
|
async def get_user_by_id(self, user_id: int) -> Optional[User]:
|
||||||
|
"""Получение пользователя по ID."""
|
||||||
|
query = "SELECT * FROM our_users WHERE user_id = ?"
|
||||||
|
rows = await self._execute_query_with_result(query, (user_id,))
|
||||||
|
# Преобразование row в модель User
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Модели данных
|
||||||
|
|
||||||
|
- Модели определены в `database/models.py`
|
||||||
|
- Используются dataclasses или простые классы
|
||||||
|
- Модели передаются между слоями (Repository → Service → Handler)
|
||||||
|
|
||||||
|
## Работа с БД
|
||||||
|
|
||||||
|
### AsyncBotDB
|
||||||
|
- Основной интерфейс для работы с БД
|
||||||
|
- Использует `RepositoryFactory` для доступа к репозиториям
|
||||||
|
- Методы делегируют вызовы соответствующим репозиториям
|
||||||
|
|
||||||
|
### DatabaseConnection
|
||||||
|
- Базовый класс для всех репозиториев
|
||||||
|
- Предоставляет методы:
|
||||||
|
- `_get_connection()` - получение соединения
|
||||||
|
- `_execute_query()` - выполнение запроса без результата
|
||||||
|
- `_execute_query_with_result()` - выполнение запроса с результатом
|
||||||
|
- Автоматически управляет соединениями (открытие/закрытие)
|
||||||
|
- Настраивает PRAGMA для оптимизации SQLite
|
||||||
|
|
||||||
|
### Важные правила
|
||||||
|
|
||||||
|
1. **Всегда используйте параметризованные запросы** для защиты от SQL injection:
|
||||||
|
```python
|
||||||
|
# ✅ Правильно
|
||||||
|
query = "SELECT * FROM users WHERE user_id = ?"
|
||||||
|
await self._execute_query_with_result(query, (user_id,))
|
||||||
|
|
||||||
|
# ❌ Неправильно
|
||||||
|
query = f"SELECT * FROM users WHERE user_id = {user_id}"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Логируйте важные операции**:
|
||||||
|
```python
|
||||||
|
self.logger.info(f"Пользователь добавлен: {user_id}")
|
||||||
|
self.logger.error(f"Ошибка при добавлении пользователя: {e}")
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Используйте транзакции** для множественных операций (если нужно):
|
||||||
|
```python
|
||||||
|
async with await self._get_connection() as conn:
|
||||||
|
await conn.execute(...)
|
||||||
|
await conn.execute(...)
|
||||||
|
await conn.commit()
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Обрабатывайте None** при получении данных:
|
||||||
|
```python
|
||||||
|
rows = await self._execute_query_with_result(query, params)
|
||||||
|
if not rows:
|
||||||
|
return None
|
||||||
|
row = rows[0]
|
||||||
|
```
|
||||||
|
|
||||||
|
## RepositoryFactory
|
||||||
|
|
||||||
|
- Создает и кэширует экземпляры репозиториев
|
||||||
|
- Доступ через свойства: `factory.users`, `factory.posts`, etc.
|
||||||
|
- Используется в `AsyncBotDB` для доступа к репозиториям
|
||||||
|
|
||||||
|
## Миграции
|
||||||
|
|
||||||
|
### Обзор
|
||||||
|
|
||||||
|
Система миграций автоматически отслеживает и применяет изменения схемы БД. Миграции хранятся в `scripts/` и применяются автоматически при деплое.
|
||||||
|
|
||||||
|
### Создание миграции
|
||||||
|
|
||||||
|
1. **Создайте файл** в `scripts/` с понятным именем (например, `add_user_email_column.py`)
|
||||||
|
2. **Обязательные требования:**
|
||||||
|
- Функция `async def main(db_path: str)`
|
||||||
|
- Использует `aiosqlite` для работы с БД
|
||||||
|
- **Идемпотентна** - можно запускать несколько раз без ошибок
|
||||||
|
- Проверяет текущее состояние перед применением изменений
|
||||||
|
|
||||||
|
3. **Пример структуры:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
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
|
||||||
|
from logs.custom_logger import logger
|
||||||
|
|
||||||
|
DEFAULT_DB_PATH = "database/tg-bot-database.db"
|
||||||
|
|
||||||
|
|
||||||
|
async def main(db_path: str) -> None:
|
||||||
|
"""Основная функция миграции."""
|
||||||
|
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")
|
||||||
|
|
||||||
|
# Проверяем текущее состояние
|
||||||
|
cursor = await conn.execute("PRAGMA table_info(users)")
|
||||||
|
columns = await cursor.fetchall()
|
||||||
|
|
||||||
|
# Проверяем, нужно ли применять изменения
|
||||||
|
column_exists = any(col[1] == "email" for col in columns)
|
||||||
|
|
||||||
|
if not column_exists:
|
||||||
|
await conn.execute("ALTER TABLE users ADD COLUMN email TEXT")
|
||||||
|
await conn.commit()
|
||||||
|
logger.info("Колонка email добавлена")
|
||||||
|
else:
|
||||||
|
logger.info("Колонка email уже существует")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description="Добавление колонки email")
|
||||||
|
parser.add_argument(
|
||||||
|
"--db",
|
||||||
|
default=os.environ.get("DATABASE_PATH", DEFAULT_DB_PATH),
|
||||||
|
help="Путь к БД",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
asyncio.run(main(args.db))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Применение миграций
|
||||||
|
|
||||||
|
**Локально:**
|
||||||
|
```bash
|
||||||
|
python3 scripts/apply_migrations.py --dry-run # проверить
|
||||||
|
python3 scripts/apply_migrations.py # применить
|
||||||
|
```
|
||||||
|
|
||||||
|
**В продакшене:** Применяются автоматически при деплое через CI/CD (перед перезапуском контейнера).
|
||||||
|
|
||||||
|
### Важные правила
|
||||||
|
|
||||||
|
1. **Идемпотентность** - всегда проверяйте состояние перед изменением:
|
||||||
|
```python
|
||||||
|
# ✅ Правильно
|
||||||
|
cursor = await conn.execute("PRAGMA table_info(users)")
|
||||||
|
columns = await cursor.fetchall()
|
||||||
|
if not any(col[1] == "email" for col in columns):
|
||||||
|
await conn.execute("ALTER TABLE users ADD COLUMN email TEXT")
|
||||||
|
|
||||||
|
# ❌ Неправильно - упадет при повторном запуске
|
||||||
|
await conn.execute("ALTER TABLE users ADD COLUMN email TEXT")
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Порядок применения** - миграции применяются в алфавитном порядке по имени файла
|
||||||
|
|
||||||
|
3. **Исключения** - следующие скрипты не считаются миграциями:
|
||||||
|
- `apply_migrations.py`, `backfill_migrations.py`, `test_s3_connection.py`, `voice_cleanup.py`
|
||||||
|
|
||||||
|
### Регистрация существующих миграций
|
||||||
|
|
||||||
|
Если миграции уже применены, но не зарегистрированы:
|
||||||
|
```bash
|
||||||
|
python3 scripts/backfill_migrations.py # зарегистрировать все существующие
|
||||||
|
```
|
||||||
172
.cursor/rules/dependencies-and-utils.md
Normal file
172
.cursor/rules/dependencies-and-utils.md
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
---
|
||||||
|
description: "Работа с зависимостями, утилитами, метриками и внешними сервисами"
|
||||||
|
globs: ["helper_bot/utils/**/*.py", "helper_bot/config/**/*.py"]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Зависимости и утилиты
|
||||||
|
|
||||||
|
## BaseDependencyFactory
|
||||||
|
|
||||||
|
Центральный класс для управления зависимостями проекта.
|
||||||
|
|
||||||
|
### Использование
|
||||||
|
|
||||||
|
```python
|
||||||
|
from helper_bot.utils.base_dependency_factory import get_global_instance
|
||||||
|
|
||||||
|
# Получение глобального экземпляра
|
||||||
|
bdf = get_global_instance()
|
||||||
|
|
||||||
|
# Доступ к зависимостям
|
||||||
|
db = bdf.get_db() # AsyncBotDB
|
||||||
|
settings = bdf.get_settings() # dict с настройками
|
||||||
|
s3_storage = bdf.get_s3_storage() # S3StorageService или None
|
||||||
|
```
|
||||||
|
|
||||||
|
### Структура settings
|
||||||
|
|
||||||
|
Настройки загружаются из `.env` и структурированы:
|
||||||
|
|
||||||
|
```python
|
||||||
|
settings = {
|
||||||
|
'Telegram': {
|
||||||
|
'bot_token': str,
|
||||||
|
'listen_bot_token': str,
|
||||||
|
'preview_link': bool,
|
||||||
|
'main_public': str,
|
||||||
|
'group_for_posts': int,
|
||||||
|
'important_logs': int,
|
||||||
|
...
|
||||||
|
},
|
||||||
|
'Settings': {
|
||||||
|
'logs': bool,
|
||||||
|
'test': bool
|
||||||
|
},
|
||||||
|
'Metrics': {
|
||||||
|
'host': str,
|
||||||
|
'port': int
|
||||||
|
},
|
||||||
|
'S3': {
|
||||||
|
'enabled': bool,
|
||||||
|
'endpoint_url': str,
|
||||||
|
'access_key': str,
|
||||||
|
'secret_key': str,
|
||||||
|
'bucket_name': str,
|
||||||
|
'region': str
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Метрики
|
||||||
|
|
||||||
|
### Декораторы метрик
|
||||||
|
|
||||||
|
Используйте декораторы из `helper_bot.utils.metrics`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from helper_bot.utils.metrics import track_time, track_errors, db_query_time
|
||||||
|
|
||||||
|
@track_time("method_name", "module_name")
|
||||||
|
@track_errors("module_name", "method_name")
|
||||||
|
async def some_method():
|
||||||
|
"""Метод с отслеживанием времени и ошибок."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@db_query_time("method_name", "table_name", "operation")
|
||||||
|
async def db_method():
|
||||||
|
"""Метод БД с отслеживанием времени запросов."""
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Доступ к метрикам
|
||||||
|
|
||||||
|
```python
|
||||||
|
from helper_bot.utils.metrics import metrics
|
||||||
|
|
||||||
|
# Метрики доступны через Prometheus на порту из settings['Metrics']['port']
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
### RateLimiter
|
||||||
|
|
||||||
|
Используется для ограничения частоты запросов:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from helper_bot.utils.rate_limiter import RateLimiter
|
||||||
|
|
||||||
|
limiter = RateLimiter(...)
|
||||||
|
if await limiter.is_allowed(user_id):
|
||||||
|
# Разрешить действие
|
||||||
|
...
|
||||||
|
else:
|
||||||
|
# Отклонить действие
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### RateLimitMiddleware
|
||||||
|
|
||||||
|
Автоматически применяет rate limiting ко всем запросам через middleware.
|
||||||
|
|
||||||
|
## S3 Storage
|
||||||
|
|
||||||
|
### S3StorageService
|
||||||
|
|
||||||
|
Используется для хранения медиафайлов:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from helper_bot.utils.s3_storage import S3StorageService
|
||||||
|
|
||||||
|
# Получение через BaseDependencyFactory
|
||||||
|
s3_storage = bdf.get_s3_storage()
|
||||||
|
|
||||||
|
if s3_storage:
|
||||||
|
# Загрузка файла
|
||||||
|
url = await s3_storage.upload_file(file_path, object_key)
|
||||||
|
|
||||||
|
# Удаление файла
|
||||||
|
await s3_storage.delete_file(object_key)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Проверка доступности
|
||||||
|
|
||||||
|
Всегда проверяйте, что S3 включен:
|
||||||
|
|
||||||
|
```python
|
||||||
|
s3_storage = bdf.get_s3_storage()
|
||||||
|
if s3_storage:
|
||||||
|
# Работа с S3
|
||||||
|
...
|
||||||
|
else:
|
||||||
|
# Fallback логика
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Утилиты
|
||||||
|
|
||||||
|
### helper_func.py
|
||||||
|
|
||||||
|
Содержит вспомогательные функции для работы с:
|
||||||
|
- Датами и временем
|
||||||
|
- Форматированием данных
|
||||||
|
- Валидацией
|
||||||
|
- Преобразованием данных
|
||||||
|
|
||||||
|
Используйте эти функции вместо дублирования логики.
|
||||||
|
|
||||||
|
## Конфигурация
|
||||||
|
|
||||||
|
### rate_limit_config.py
|
||||||
|
|
||||||
|
Конфигурация rate limiting находится в `helper_bot/config/rate_limit_config.py`.
|
||||||
|
|
||||||
|
Используйте конфигурацию вместо хардкода значений.
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Всегда получайте зависимости через BaseDependencyFactory** - не создавайте экземпляры напрямую
|
||||||
|
2. **Используйте декораторы метрик** для всех важных методов
|
||||||
|
3. **Проверяйте доступность внешних сервисов** (S3) перед использованием
|
||||||
|
4. **Используйте утилиты** из `helper_func.py` вместо дублирования кода
|
||||||
|
5. **Читайте настройки из settings** вместо хардкода значений
|
||||||
|
6. **Логируйте важные операции** с внешними сервисами
|
||||||
410
.cursor/rules/deployment-guide.md
Normal file
410
.cursor/rules/deployment-guide.md
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
# Инструкция по деплою на продакшен
|
||||||
|
|
||||||
|
## Общая информация
|
||||||
|
|
||||||
|
**⚠️ ВАЖНО:** Креды для доступа к серверу (SSH хост, порт, пользователь) предоставляются пользователем в запросе и НЕ должны сохраняться в этой документации!
|
||||||
|
|
||||||
|
**Технологический стек:**
|
||||||
|
- Python: 3.11.9
|
||||||
|
- Docker: Alpine Linux
|
||||||
|
- База данных: SQLite
|
||||||
|
|
||||||
|
**Директория проекта на сервере:** `/home/prod/bots/telegram-helper-bot`
|
||||||
|
**Директория для команд инфраструктуры:** `/home/prod`
|
||||||
|
**База данных:** `/home/prod/bots/telegram-helper-bot/database/tg-bot-database.db`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 1: Подготовка на локальной машине
|
||||||
|
|
||||||
|
### 1.1. Проверка состояния репозитория
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status
|
||||||
|
git log --oneline -10
|
||||||
|
git diff master...HEAD --stat
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2. Пуш ветки в репозиторий
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push -u origin <branch-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3. Создание Release Notes
|
||||||
|
|
||||||
|
**ВАЖНО:** Release notes создаются ПОСЛЕ пуша, чтобы не попали в репозиторий!
|
||||||
|
|
||||||
|
1. Использовать шаблон из `.cursor/rules/release-notes-template.md`
|
||||||
|
2. Создать файл `RELEASE_NOTES_DEV-XX.md` в корне проекта
|
||||||
|
3. Собрать информацию о коммитах:
|
||||||
|
```bash
|
||||||
|
git log master..HEAD --pretty=format:"%h - %ai - %s" --reverse
|
||||||
|
```
|
||||||
|
4. Заполнить release notes по шаблону:
|
||||||
|
- Обзор (количество коммитов и основные направления)
|
||||||
|
- Ключевые изменения (каждый значимый коммит)
|
||||||
|
- Основные достижения (чекбоксы с эмодзи ✅)
|
||||||
|
- Временная шкала разработки
|
||||||
|
|
||||||
|
**Примечание:** Release notes используются для Pull Request, затем файл удаляется из рабочей директории.
|
||||||
|
|
||||||
|
### 1.4. Создание дополнительной документации (если требуется)
|
||||||
|
|
||||||
|
**ВАЖНО:** Документацию создавать ПОСЛЕ пуша, чтобы она не попала в репозиторий!
|
||||||
|
|
||||||
|
1. Создать или обновить инструкции по деплою
|
||||||
|
2. Обновить необходимую техническую документацию
|
||||||
|
3. Убедиться, что не добавляются файлы с секретами
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 2: Деплой на сервер (тестовая ветка)
|
||||||
|
|
||||||
|
**ВАЖНО:** Креды для подключения к серверу должны быть предоставлены пользователем в запросе.
|
||||||
|
|
||||||
|
### 2.1. Подключение к серверу и остановка инфраструктуры
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Подключиться к серверу через SSH
|
||||||
|
# Перейти в директорию для команд инфраструктуры
|
||||||
|
cd /home/prod
|
||||||
|
make down
|
||||||
|
```
|
||||||
|
|
||||||
|
**Проверка:** Убедиться, что все контейнеры остановлены.
|
||||||
|
|
||||||
|
### 2.2. Создание бэкапа базы данных
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/prod/bots/telegram-helper-bot/database
|
||||||
|
cp tg-bot-database.db tg-bot-database_$(date +%Y%m%d_%H%M%S).db
|
||||||
|
ls -lh tg-bot-database*.db
|
||||||
|
```
|
||||||
|
|
||||||
|
**ВАЖНО:** Всегда создавать бэкап с timestamp перед применением миграций!
|
||||||
|
|
||||||
|
### 2.3. Переключение на ветку и подтягивание изменений
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/prod/bots/telegram-helper-bot
|
||||||
|
git fetch origin
|
||||||
|
git checkout <branch-name>
|
||||||
|
git pull origin <branch-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4. Установка зависимостей (если изменились)
|
||||||
|
|
||||||
|
#### Если виртуальное окружение уже существует:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/prod/bots/telegram-helper-bot
|
||||||
|
.venv/bin/pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Если виртуального окружения нет:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/prod/bots/telegram-helper-bot
|
||||||
|
python3 -m venv .venv
|
||||||
|
.venv/bin/pip install --upgrade pip
|
||||||
|
.venv/bin/pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**Примечание:** Виртуальное окружение `.venv` на сервере используется только для применения миграций и утилит. Бот работает в Docker-контейнере.
|
||||||
|
|
||||||
|
### 2.5. Применение миграций базы данных
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/prod/bots/telegram-helper-bot
|
||||||
|
.venv/bin/python scripts/apply_migrations.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**ВАЖНО:**
|
||||||
|
- Миграции применяются **ПЕРЕД** запуском контейнеров
|
||||||
|
- База должна быть "спящей" (контейнеры выключены)
|
||||||
|
- Проверить вывод скрипта на наличие ошибок
|
||||||
|
|
||||||
|
**Вывод при успехе:**
|
||||||
|
```
|
||||||
|
📋 Найдено новых миграций: X
|
||||||
|
✅ Все миграции применены успешно (X шт.)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.6. Запуск инфраструктуры
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/prod
|
||||||
|
make up
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.7. Пересборка контейнера telegram-bot
|
||||||
|
|
||||||
|
Используем команду с гарантией применения изменений:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/prod
|
||||||
|
docker-compose up -d --build --force-recreate --no-deps telegram-bot
|
||||||
|
```
|
||||||
|
|
||||||
|
**Что делает команда:**
|
||||||
|
- `--build` - пересобирает образ
|
||||||
|
- `--force-recreate` - принудительно пересоздает контейнер
|
||||||
|
- `--no-deps` - не затрагивает зависимые сервисы
|
||||||
|
- Гарантирует доставку нового кода в контейнер без кэша
|
||||||
|
|
||||||
|
### 2.8. Проверка работоспособности
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Проверка статуса всех контейнеров
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
# Проверка логов telegram-bot
|
||||||
|
docker-compose logs telegram-bot --tail=50
|
||||||
|
|
||||||
|
# Или напрямую
|
||||||
|
docker logs bots_telegram_bot --tail=50
|
||||||
|
```
|
||||||
|
|
||||||
|
**Что проверять в логах:**
|
||||||
|
- ✅ База данных инициализирована
|
||||||
|
- ✅ Scoring Manager инициализирован (если используется)
|
||||||
|
- ✅ Metrics server запущен на порту 8080
|
||||||
|
- ✅ Нет критических ошибок
|
||||||
|
- ✅ Бот обрабатывает команды
|
||||||
|
|
||||||
|
**На этом этапе:** Ждать подтверждения от владельца о мердже ветки в master.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 3: Финальный деплой на master
|
||||||
|
|
||||||
|
**ВАЖНО:** Выполняется только после подтверждения мерджа ветки в master!
|
||||||
|
|
||||||
|
### 3.1. Остановка инфраструктуры
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/prod
|
||||||
|
make down
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2. Переключение на master и получение изменений
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/prod/bots/telegram-helper-bot
|
||||||
|
git checkout master
|
||||||
|
git pull origin master
|
||||||
|
```
|
||||||
|
|
||||||
|
**Проверка:** Убедиться, что получен коммит с мерджем.
|
||||||
|
|
||||||
|
### 3.3. Запуск инфраструктуры
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/prod
|
||||||
|
make up
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4. Пересборка контейнера telegram-bot с кодом master
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/prod
|
||||||
|
docker-compose up -d --build --force-recreate --no-deps telegram-bot
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.5. Финальная проверка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Статус контейнеров
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
# Логи
|
||||||
|
docker logs bots_telegram_bot --tail=50
|
||||||
|
```
|
||||||
|
|
||||||
|
**Проверить:**
|
||||||
|
- ✅ Все контейнеры в состоянии `Up` и `healthy`
|
||||||
|
- ✅ Логи не содержат критических ошибок
|
||||||
|
- ✅ Бот отвечает на команды в Telegram
|
||||||
|
|
||||||
|
### 3.6. Завершение работы
|
||||||
|
|
||||||
|
- Выйти с сервера
|
||||||
|
- Забыть все креды доступа (не сохранять их)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Проблема: Миграции не применяются
|
||||||
|
|
||||||
|
**Решение:**
|
||||||
|
1. Проверить, что контейнеры остановлены
|
||||||
|
2. Проверить права доступа к файлу БД
|
||||||
|
3. Посмотреть логи скрипта apply_migrations.py
|
||||||
|
4. Восстановить БД из бэкапа при необходимости:
|
||||||
|
```bash
|
||||||
|
cd /home/prod/bots/telegram-helper-bot/database
|
||||||
|
cp tg-bot-database_YYYYMMDD_HHMMSS.db tg-bot-database.db
|
||||||
|
```
|
||||||
|
|
||||||
|
### Проблема: Контейнер не запускается
|
||||||
|
|
||||||
|
**Решение:**
|
||||||
|
1. Проверить логи контейнера:
|
||||||
|
```bash
|
||||||
|
docker logs bots_telegram_bot --tail=100
|
||||||
|
```
|
||||||
|
2. Проверить переменные окружения в `.env`
|
||||||
|
3. Проверить healthcheck:
|
||||||
|
```bash
|
||||||
|
docker inspect bots_telegram_bot | grep -A 20 Health
|
||||||
|
```
|
||||||
|
|
||||||
|
### Проблема: Код не обновился в контейнере
|
||||||
|
|
||||||
|
**Решение:**
|
||||||
|
Использовать принудительную пересборку без кэша:
|
||||||
|
```bash
|
||||||
|
docker-compose down telegram-bot
|
||||||
|
docker-compose build --no-cache telegram-bot
|
||||||
|
docker-compose up -d telegram-bot
|
||||||
|
```
|
||||||
|
|
||||||
|
### Проблема: Нет виртуального окружения
|
||||||
|
|
||||||
|
**Решение:**
|
||||||
|
Создать виртуальное окружение:
|
||||||
|
```bash
|
||||||
|
# Установить python3-venv (если нужно)
|
||||||
|
sudo apt install -y python3.11-venv
|
||||||
|
|
||||||
|
# Создать venv
|
||||||
|
cd /home/prod/bots/telegram-helper-bot
|
||||||
|
python3 -m venv .venv
|
||||||
|
.venv/bin/pip install --upgrade pip
|
||||||
|
.venv/bin/pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Последовательность действий для агента
|
||||||
|
|
||||||
|
### Этап 1: Локальная подготовка
|
||||||
|
|
||||||
|
1. Проверить состояние репозитория (`git status`, `git log`)
|
||||||
|
2. Запушить ветку в репозиторий
|
||||||
|
3. Создать Release Notes по шаблону - **ПОСЛЕ пуша!**
|
||||||
|
4. Создать дополнительную документацию (если требуется) - **ПОСЛЕ пуша!**
|
||||||
|
5. Сообщить пользователю о готовности к деплою
|
||||||
|
|
||||||
|
### Этап 2: Деплой на dev-ветку
|
||||||
|
|
||||||
|
1. Подключиться к серверу (креды из запроса пользователя)
|
||||||
|
2. Остановить инфраструктуру (`make down` из `/home/prod`)
|
||||||
|
3. Создать бэкап БД с timestamp
|
||||||
|
4. Переключиться на dev-ветку и подтянуть изменения
|
||||||
|
5. Установить/обновить зависимости Python (если требуется)
|
||||||
|
6. Применить миграции БД (через `.venv/bin/python scripts/apply_migrations.py`)
|
||||||
|
7. Проверить успешность применения миграций
|
||||||
|
8. Запустить инфраструктуру (`make up`)
|
||||||
|
9. Пересобрать контейнер telegram-bot с `--build --force-recreate --no-deps`
|
||||||
|
10. Проверить статус контейнеров и логи
|
||||||
|
11. Сообщить пользователю о готовности и ждать подтверждения "ОК"
|
||||||
|
|
||||||
|
### Этап 3: Финальный деплой на master
|
||||||
|
|
||||||
|
**Выполняется только после получения "ОК" от пользователя (означает мердж в master)**
|
||||||
|
|
||||||
|
1. Остановить инфраструктуру
|
||||||
|
2. Переключиться на master и подтянуть изменения
|
||||||
|
3. Запустить инфраструктуру
|
||||||
|
4. Пересобрать контейнер telegram-bot с `--build --force-recreate --no-deps`
|
||||||
|
5. Проверить финальный статус и логи
|
||||||
|
6. Сообщить о успешном завершении деплоя
|
||||||
|
7. Завершить SSH-сессию и забыть все креды
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Важные команды
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Просмотр всех контейнеров
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
# Логи конкретного сервиса
|
||||||
|
docker-compose logs <service-name> --tail=N
|
||||||
|
|
||||||
|
# Перезапуск конкретного сервиса
|
||||||
|
docker-compose restart <service-name>
|
||||||
|
|
||||||
|
# Остановка всей инфраструктуры
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# Запуск всей инфраструктуры
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Пересборка без кэша
|
||||||
|
docker-compose build --no-cache <service-name>
|
||||||
|
|
||||||
|
# Принудительное пересоздание контейнера
|
||||||
|
docker-compose up -d --build --force-recreate --no-deps <service-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Git
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Проверка состояния
|
||||||
|
git status
|
||||||
|
git branch
|
||||||
|
|
||||||
|
# Переключение веток
|
||||||
|
git checkout <branch-name>
|
||||||
|
|
||||||
|
# Получение изменений
|
||||||
|
git fetch origin
|
||||||
|
git pull origin <branch-name>
|
||||||
|
|
||||||
|
# История коммитов
|
||||||
|
git log --oneline -N
|
||||||
|
git log master..HEAD --pretty=format:"%h - %ai - %s" --reverse
|
||||||
|
```
|
||||||
|
|
||||||
|
### Работа с БД
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Создание бэкапа с timestamp
|
||||||
|
cp tg-bot-database.db tg-bot-database_$(date +%Y%m%d_%H%M%S).db
|
||||||
|
|
||||||
|
# Просмотр бэкапов
|
||||||
|
ls -lh tg-bot-database*.db
|
||||||
|
|
||||||
|
# Восстановление из бэкапа
|
||||||
|
cp tg-bot-database_BACKUP.db tg-bot-database.db
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Примечания
|
||||||
|
|
||||||
|
1. **Виртуальное окружение на сервере** - используется только для запуска утилит и миграций. Бот работает в Docker-контейнере.
|
||||||
|
|
||||||
|
2. **Бэкапы БД** - создаются перед каждым применением миграций с timestamp в имени файла.
|
||||||
|
|
||||||
|
3. **Пересборка контейнера** - всегда использовать `--force-recreate --no-deps` для гарантии применения изменений.
|
||||||
|
|
||||||
|
4. **Миграции** - применяются только при остановленных контейнерах к "спящей" базе данных.
|
||||||
|
|
||||||
|
5. **Release notes** - создаются ПОСЛЕ пуша, чтобы не попадали в репозиторий. Используются для Pull Request, затем удаляются из рабочей директории.
|
||||||
|
|
||||||
|
6. **Документация после пуша** - вся документация и инструкции создаются ПОСЛЕ пуша в репозиторий, чтобы не попадали в коммиты и удалённый репозиторий.
|
||||||
|
|
||||||
|
7. **Креды доступа** - креды сервера предоставляются пользователем в запросе и НЕ сохраняются в документации. После завершения деплоя - забыть все креды.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Контакты
|
||||||
|
|
||||||
|
При возникновении проблем или вопросов обращаться к владельцу проекта.
|
||||||
207
.cursor/rules/error-handling.md
Normal file
207
.cursor/rules/error-handling.md
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
---
|
||||||
|
description: "Обработка ошибок, исключения и логирование"
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Обработка ошибок и исключений
|
||||||
|
|
||||||
|
## Иерархия исключений
|
||||||
|
|
||||||
|
Каждый модуль должен иметь свой файл `exceptions.py` с иерархией исключений:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Базовое исключение модуля
|
||||||
|
class ModuleError(Exception):
|
||||||
|
"""Базовое исключение для модуля"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Специализированные исключения
|
||||||
|
class UserNotFoundError(ModuleError):
|
||||||
|
"""Исключение при отсутствии пользователя"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class InvalidInputError(ModuleError):
|
||||||
|
"""Исключение при некорректном вводе данных"""
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
## Обработка в Handlers
|
||||||
|
|
||||||
|
### Паттерн try-except
|
||||||
|
|
||||||
|
```python
|
||||||
|
@router.message(...)
|
||||||
|
async def handler(message: types.Message, state: FSMContext, **kwargs):
|
||||||
|
try:
|
||||||
|
# Основная логика
|
||||||
|
service = SomeService(bot_db, settings)
|
||||||
|
result = await service.do_something()
|
||||||
|
await message.answer("Успех")
|
||||||
|
except UserNotFoundError as e:
|
||||||
|
# Обработка специфичной ошибки
|
||||||
|
await message.answer(f"Пользователь не найден: {str(e)}")
|
||||||
|
logger.warning(f"Пользователь не найден: {e}")
|
||||||
|
except InvalidInputError as e:
|
||||||
|
# Обработка ошибки валидации
|
||||||
|
await message.answer(f"Некорректный ввод: {str(e)}")
|
||||||
|
logger.warning(f"Некорректный ввод: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
# Обработка неожиданных ошибок
|
||||||
|
await handle_error(message, e, state)
|
||||||
|
logger.error(f"Неожиданная ошибка в handler: {e}", exc_info=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Декоратор error_handler
|
||||||
|
|
||||||
|
Некоторые модули используют декоратор `@error_handler` для автоматической обработки:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from .decorators import error_handler
|
||||||
|
|
||||||
|
@error_handler
|
||||||
|
@router.message(...)
|
||||||
|
async def handler(...):
|
||||||
|
# Код handler
|
||||||
|
# Ошибки автоматически логируются и отправляются в important_logs
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Обработка в Services
|
||||||
|
|
||||||
|
```python
|
||||||
|
class SomeService:
|
||||||
|
async def do_something(self):
|
||||||
|
try:
|
||||||
|
# Бизнес-логика
|
||||||
|
data = await self.bot_db.get_data()
|
||||||
|
if not data:
|
||||||
|
raise UserNotFoundError("Пользователь не найден")
|
||||||
|
return self._process(data)
|
||||||
|
except UserNotFoundError:
|
||||||
|
# Пробрасываем специфичные исключения дальше
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
# Логируем и пробрасываем неожиданные ошибки
|
||||||
|
logger.error(f"Ошибка в сервисе: {e}", exc_info=True)
|
||||||
|
raise
|
||||||
|
```
|
||||||
|
|
||||||
|
## Логирование
|
||||||
|
|
||||||
|
### Использование logger
|
||||||
|
|
||||||
|
Всегда используйте `logs.custom_logger.logger`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from logs.custom_logger import logger
|
||||||
|
|
||||||
|
# Информационные сообщения
|
||||||
|
logger.info(f"Пользователь {user_id} выполнил действие")
|
||||||
|
|
||||||
|
# Предупреждения
|
||||||
|
logger.warning(f"Попытка доступа к несуществующему ресурсу: {resource_id}")
|
||||||
|
|
||||||
|
# Ошибки
|
||||||
|
logger.error(f"Ошибка при выполнении операции: {e}")
|
||||||
|
|
||||||
|
# Ошибки с traceback
|
||||||
|
logger.error(f"Критическая ошибка: {e}", exc_info=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Уровни логирования
|
||||||
|
|
||||||
|
- `logger.debug()` - отладочная информация (детали выполнения, промежуточные значения, HTTP запросы(не используется в проекте))
|
||||||
|
- `logger.info()` - информационные сообщения о работе (успешные операции, важные события)
|
||||||
|
- `logger.warning()` - предупреждения о потенциальных проблемах (некритичные ошибки, таймауты)
|
||||||
|
- `logger.error()` - ошибки, требующие внимания (исключения, сбои)
|
||||||
|
- `logger.critical()` - критические ошибки
|
||||||
|
|
||||||
|
### Паттерн логирования в сервисах
|
||||||
|
|
||||||
|
При работе с внешними API и сервисами используйте следующий паттерн:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from logs.custom_logger import logger
|
||||||
|
|
||||||
|
class ApiClient:
|
||||||
|
async def calculate_score(self, text: str) -> Score:
|
||||||
|
# Логируем начало операции (debug)
|
||||||
|
logger.debug(f"ApiClient: Отправка запроса на расчет скора (text_preview='{text[:50]}')")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await self._client.post(url, json=data)
|
||||||
|
|
||||||
|
# Логируем статус ответа (debug)
|
||||||
|
logger.debug(f"ApiClient: Получен ответ (status={response.status_code})")
|
||||||
|
|
||||||
|
# Обрабатываем ответ
|
||||||
|
if response.status_code == 200:
|
||||||
|
result = response.json()
|
||||||
|
# Логируем успешный результат (info)
|
||||||
|
logger.info(f"ApiClient: Скор успешно получен (score={result['score']:.4f})")
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
# Логируем ошибку (error)
|
||||||
|
logger.error(f"ApiClient: Ошибка API (status={response.status_code})")
|
||||||
|
raise ApiError(f"Ошибка API: {response.status_code}")
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
# Логируем таймаут (error)
|
||||||
|
logger.error(f"ApiClient: Таймаут запроса (>{timeout}с)")
|
||||||
|
raise
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
# Логируем ошибку подключения (error)
|
||||||
|
logger.error(f"ApiClient: Ошибка подключения: {e}")
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
# Логируем неожиданные ошибки (error)
|
||||||
|
logger.error(f"ApiClient: Неожиданная ошибка: {e}", exc_info=True)
|
||||||
|
raise
|
||||||
|
```
|
||||||
|
|
||||||
|
**Принципы:**
|
||||||
|
- `logger.debug()` - для деталей выполнения (URL, параметры запроса, статус ответа)
|
||||||
|
- `logger.info()` - для успешных операций с важными результатами
|
||||||
|
- `logger.warning()` - для некритичных проблем (валидация, таймауты в неважных операциях)
|
||||||
|
- `logger.error()` - для всех ошибок перед пробросом исключения
|
||||||
|
- Всегда логируйте ошибки перед `raise`
|
||||||
|
- Используйте `exc_info=True` для критических ошибок
|
||||||
|
|
||||||
|
## Метрики ошибок
|
||||||
|
|
||||||
|
Декоратор `@track_errors` автоматически отслеживает ошибки:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@track_errors("module_name", "method_name")
|
||||||
|
async def some_method():
|
||||||
|
# Ошибки автоматически записываются в метрики
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Централизованная обработка
|
||||||
|
|
||||||
|
### В admin handlers
|
||||||
|
|
||||||
|
Используется функция `handle_admin_error()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from helper_bot.handlers.admin.utils import handle_admin_error
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Код
|
||||||
|
except Exception as e:
|
||||||
|
await handle_admin_error(message, e, state, "context_name")
|
||||||
|
```
|
||||||
|
|
||||||
|
### В других модулях
|
||||||
|
|
||||||
|
Создавайте аналогичные утилиты для централизованной обработки ошибок модуля.
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Всегда логируйте ошибки** перед пробросом или обработкой
|
||||||
|
2. **Используйте специфичные исключения** вместо общих `Exception`
|
||||||
|
3. **Пробрасывайте исключения** из сервисов в handlers для обработки
|
||||||
|
4. **Не глотайте исключения** без логирования
|
||||||
|
5. **Используйте `exc_info=True`** для логирования traceback критических ошибок
|
||||||
|
6. **Обрабатывайте ошибки на правильном уровне**: бизнес-логика в сервисах, пользовательские сообщения в handlers
|
||||||
215
.cursor/rules/handlers-patterns.md
Normal file
215
.cursor/rules/handlers-patterns.md
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
---
|
||||||
|
description: "Паттерны для создания handlers, services и обработки событий aiogram"
|
||||||
|
globs: ["helper_bot/handlers/**/*.py"]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Паттерны для Handlers
|
||||||
|
|
||||||
|
## Структура модуля handler
|
||||||
|
|
||||||
|
Каждый модуль handler (admin, callback, group, private, voice) должен содержать:
|
||||||
|
|
||||||
|
```
|
||||||
|
handlers/{module}/
|
||||||
|
├── __init__.py # Экспорт router
|
||||||
|
├── {module}_handlers.py # Основные handlers
|
||||||
|
├── services.py # Бизнес-логика
|
||||||
|
├── exceptions.py # Кастомные исключения
|
||||||
|
├── dependencies.py # Dependency injection (опционально)
|
||||||
|
├── constants.py # Константы (FSM states, messages)
|
||||||
|
└── utils.py # Вспомогательные функции (опционально)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Создание Router
|
||||||
|
|
||||||
|
```python
|
||||||
|
from aiogram import Router
|
||||||
|
|
||||||
|
# Создаем роутер
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
# Регистрируем middleware (если нужно)
|
||||||
|
router.message.middleware(SomeMiddleware())
|
||||||
|
|
||||||
|
# Экспортируем в __init__.py
|
||||||
|
# from .{module}_handlers import router
|
||||||
|
```
|
||||||
|
|
||||||
|
## Структура Handler
|
||||||
|
|
||||||
|
```python
|
||||||
|
from aiogram import Router, types
|
||||||
|
from aiogram.filters import Command, StateFilter
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from helper_bot.filters.main import ChatTypeFilter
|
||||||
|
from logs.custom_logger import logger
|
||||||
|
from helper_bot.utils.metrics import track_time, track_errors
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
@router.message(
|
||||||
|
ChatTypeFilter(chat_type=["private"]),
|
||||||
|
Command('command_name')
|
||||||
|
)
|
||||||
|
@track_time("handler_name", "module_name")
|
||||||
|
@track_errors("module_name", "handler_name")
|
||||||
|
async def handler_function(
|
||||||
|
message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
|
bot_db: AsyncBotDB, # Из DependenciesMiddleware
|
||||||
|
settings: dict, # Из DependenciesMiddleware
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
"""Описание handler."""
|
||||||
|
try:
|
||||||
|
# Логирование
|
||||||
|
logger.info(f"Обработка команды от пользователя: {message.from_user.id}")
|
||||||
|
|
||||||
|
# Получение данных из state (если нужно)
|
||||||
|
data = await state.get_data()
|
||||||
|
|
||||||
|
# Вызов сервиса для бизнес-логики
|
||||||
|
service = SomeService(bot_db, settings)
|
||||||
|
result = await service.do_something()
|
||||||
|
|
||||||
|
# Ответ пользователю
|
||||||
|
await message.answer("Результат")
|
||||||
|
|
||||||
|
# Обновление state (если нужно)
|
||||||
|
await state.set_state("NEW_STATE")
|
||||||
|
|
||||||
|
except SomeCustomException as e:
|
||||||
|
# Обработка кастомных исключений
|
||||||
|
await message.answer(f"Ошибка: {str(e)}")
|
||||||
|
logger.error(f"Ошибка в handler: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
# Обработка общих ошибок
|
||||||
|
await handle_error(message, e, state)
|
||||||
|
logger.error(f"Неожиданная ошибка: {e}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Service Layer
|
||||||
|
|
||||||
|
Бизнес-логика выносится в сервисы:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from logs.custom_logger import logger
|
||||||
|
from helper_bot.utils.metrics import track_time, track_errors
|
||||||
|
|
||||||
|
class SomeService:
|
||||||
|
"""Сервис для работы с ..."""
|
||||||
|
|
||||||
|
def __init__(self, bot_db: AsyncBotDB, settings: dict):
|
||||||
|
self.bot_db = bot_db
|
||||||
|
self.settings = settings
|
||||||
|
|
||||||
|
@track_time("method_name", "service_name")
|
||||||
|
@track_errors("service_name", "method_name")
|
||||||
|
async def do_something(self) -> SomeResult:
|
||||||
|
"""Описание метода."""
|
||||||
|
try:
|
||||||
|
# Работа с БД через bot_db
|
||||||
|
data = await self.bot_db.some_method()
|
||||||
|
|
||||||
|
# Бизнес-логика
|
||||||
|
result = self._process_data(data)
|
||||||
|
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка в сервисе: {e}")
|
||||||
|
raise
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependency Injection
|
||||||
|
|
||||||
|
### Через DependenciesMiddleware (глобально)
|
||||||
|
- `bot_db: AsyncBotDB` - доступен во всех handlers
|
||||||
|
- `settings: dict` - настройки из .env
|
||||||
|
- `bot: Bot` - экземпляр бота
|
||||||
|
- `dp: Dispatcher` - dispatcher
|
||||||
|
|
||||||
|
### Через MagicData (локально)
|
||||||
|
```python
|
||||||
|
from aiogram.filters import MagicData
|
||||||
|
from typing import Annotated
|
||||||
|
from helper_bot.handlers.admin.dependencies import BotDB, Settings
|
||||||
|
|
||||||
|
@router.message(
|
||||||
|
Command('admin'),
|
||||||
|
MagicData(bot_db=BotDB, settings=Settings)
|
||||||
|
)
|
||||||
|
async def handler(
|
||||||
|
message: types.Message,
|
||||||
|
bot_db: Annotated[AsyncBotDB, BotDB],
|
||||||
|
settings: Annotated[dict, Settings]
|
||||||
|
):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Через фабрики (для сервисов)
|
||||||
|
```python
|
||||||
|
# В dependencies.py
|
||||||
|
def get_some_service() -> SomeService:
|
||||||
|
"""Фабрика для SomeService"""
|
||||||
|
bdf = get_global_instance()
|
||||||
|
db = bdf.get_db()
|
||||||
|
settings = bdf.settings
|
||||||
|
return SomeService(db, settings)
|
||||||
|
|
||||||
|
# В handlers
|
||||||
|
@router.message(Command('cmd'))
|
||||||
|
async def handler(
|
||||||
|
message: types.Message,
|
||||||
|
service: Annotated[SomeService, get_some_service()]
|
||||||
|
):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## FSM (Finite State Machine)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Определение состояний в constants.py
|
||||||
|
FSM_STATES = {
|
||||||
|
"ADMIN": "ADMIN",
|
||||||
|
"AWAIT_INPUT": "AWAIT_INPUT",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
|
||||||
|
# Установка состояния
|
||||||
|
await state.set_state(FSM_STATES["ADMIN"])
|
||||||
|
|
||||||
|
# Получение состояния
|
||||||
|
current_state = await state.get_state()
|
||||||
|
|
||||||
|
# Сохранение данных
|
||||||
|
await state.update_data(key=value)
|
||||||
|
|
||||||
|
# Получение данных
|
||||||
|
data = await state.get_data()
|
||||||
|
value = data.get("key")
|
||||||
|
|
||||||
|
# Очистка состояния
|
||||||
|
await state.clear()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Фильтры
|
||||||
|
|
||||||
|
Используйте кастомные фильтры из `helper_bot.filters.main`:
|
||||||
|
- `ChatTypeFilter` - фильтр по типу чата (private, group, supergroup)
|
||||||
|
|
||||||
|
## Декораторы для метрик
|
||||||
|
|
||||||
|
Всегда добавляйте декораторы метрик к handlers и методам сервисов:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@track_time("handler_name", "module_name") # Измерение времени выполнения
|
||||||
|
@track_errors("module_name", "handler_name") # Отслеживание ошибок
|
||||||
|
@db_query_time("method_name", "table_name", "operation") # Для БД операций
|
||||||
|
```
|
||||||
|
|
||||||
|
## Обработка ошибок
|
||||||
|
|
||||||
|
- Используйте кастомные исключения из `exceptions.py`
|
||||||
|
- Обрабатывайте исключения в handlers
|
||||||
|
- Логируйте все ошибки через `logger.error()`
|
||||||
|
- Используйте декоратор `@error_handler` для автоматической обработки (если есть)
|
||||||
109
.cursor/rules/middleware-patterns.md
Normal file
109
.cursor/rules/middleware-patterns.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
---
|
||||||
|
description: "Паттерны создания и использования middleware в aiogram"
|
||||||
|
globs: ["helper_bot/middlewares/**/*.py"]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Паттерны Middleware
|
||||||
|
|
||||||
|
## Структура Middleware
|
||||||
|
|
||||||
|
Все middleware наследуются от `aiogram.BaseMiddleware`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from typing import Any, Dict
|
||||||
|
from aiogram import BaseMiddleware
|
||||||
|
from aiogram.types import TelegramObject
|
||||||
|
|
||||||
|
class CustomMiddleware(BaseMiddleware):
|
||||||
|
"""Описание middleware."""
|
||||||
|
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
handler: Callable,
|
||||||
|
event: TelegramObject,
|
||||||
|
data: Dict[str, Any]
|
||||||
|
) -> Any:
|
||||||
|
# Логика до обработки handler
|
||||||
|
...
|
||||||
|
|
||||||
|
# Вызов следующего handler в цепочке
|
||||||
|
result = await handler(event, data)
|
||||||
|
|
||||||
|
# Логика после обработки handler
|
||||||
|
...
|
||||||
|
|
||||||
|
return result
|
||||||
|
```
|
||||||
|
|
||||||
|
## Порядок регистрации Middleware
|
||||||
|
|
||||||
|
В `main.py` middleware регистрируются в следующем порядке (важно!):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 1. DependenciesMiddleware - внедрение зависимостей
|
||||||
|
dp.update.outer_middleware(DependenciesMiddleware())
|
||||||
|
|
||||||
|
# 2. MetricsMiddleware - сбор метрик
|
||||||
|
dp.update.outer_middleware(MetricsMiddleware())
|
||||||
|
|
||||||
|
# 3. BlacklistMiddleware - проверка черного списка
|
||||||
|
dp.update.outer_middleware(BlacklistMiddleware())
|
||||||
|
|
||||||
|
# 4. RateLimitMiddleware - ограничение частоты запросов
|
||||||
|
dp.update.outer_middleware(RateLimitMiddleware())
|
||||||
|
```
|
||||||
|
|
||||||
|
## DependenciesMiddleware
|
||||||
|
|
||||||
|
Внедряет глобальные зависимости во все handlers:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class DependenciesMiddleware(BaseMiddleware):
|
||||||
|
async def __call__(self, handler, event: TelegramObject, data: Dict[str, Any]) -> Any:
|
||||||
|
bdf = get_global_instance()
|
||||||
|
|
||||||
|
# Внедрение зависимостей
|
||||||
|
if 'bot_db' not in data:
|
||||||
|
data['bot_db'] = bdf.get_db()
|
||||||
|
if 'settings' not in data:
|
||||||
|
data['settings'] = bdf.settings
|
||||||
|
|
||||||
|
return await handler(event, data)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Обработка ошибок в Middleware
|
||||||
|
|
||||||
|
```python
|
||||||
|
class CustomMiddleware(BaseMiddleware):
|
||||||
|
async def __call__(self, handler, event, data):
|
||||||
|
try:
|
||||||
|
# Предобработка
|
||||||
|
...
|
||||||
|
result = await handler(event, data)
|
||||||
|
# Постобработка
|
||||||
|
...
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
# Обработка ошибок
|
||||||
|
logger.error(f"Ошибка в middleware: {e}")
|
||||||
|
# Решаем: пробрасывать дальше или обработать
|
||||||
|
raise
|
||||||
|
```
|
||||||
|
|
||||||
|
## Регистрация на уровне Router
|
||||||
|
|
||||||
|
Middleware можно регистрировать на уровне конкретного router:
|
||||||
|
|
||||||
|
```python
|
||||||
|
router = Router()
|
||||||
|
router.message.middleware(SomeMiddleware()) # Только для message handlers
|
||||||
|
router.callback_query.middleware(SomeMiddleware()) # Только для callback handlers
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Регистрируйте middleware в правильном порядке** - зависимости должны быть первыми
|
||||||
|
2. **Не изменяйте event** напрямую, используйте `data` для передачи информации
|
||||||
|
3. **Обрабатывайте ошибки** в middleware, но не глотайте их без логирования
|
||||||
|
4. **Используйте `outer_middleware`** для глобальной регистрации
|
||||||
|
5. **Используйте `router.middleware()`** для локальной регистрации на уровне модуля
|
||||||
8
.cursor/rules/middleware-patterns.mdc
Normal file
8
.cursor/rules/middleware-patterns.mdc
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
name: middleware-patterns
|
||||||
|
description: This is a new rule
|
||||||
|
---
|
||||||
|
|
||||||
|
# Overview
|
||||||
|
|
||||||
|
Insert overview text here. The agent will only see this should they choose to apply the rule.
|
||||||
75
.cursor/rules/my-custom-rule.mdc
Normal file
75
.cursor/rules/my-custom-rule.mdc
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
---
|
||||||
|
name: project-rule
|
||||||
|
description: Общее описание всех ролей и правил проекта
|
||||||
|
---
|
||||||
|
|
||||||
|
# Правила проекта Telegram Helper Bot
|
||||||
|
|
||||||
|
Этот файл объединяет все правила и паттерны разработки проекта. Для получения подробной информации по каждому разделу см. соответствующие файлы в `.cursor/rules/`.
|
||||||
|
|
||||||
|
## 📋 Список правил и шаблонов
|
||||||
|
|
||||||
|
### 1. Архитектура проекта
|
||||||
|
**Файл:** `.cursor/rules/architecture.md`
|
||||||
|
**Описание:** Архитектурные паттерны и структура проекта Telegram бота на aiogram 3.10.0
|
||||||
|
**Применение:** `alwaysApply: true` - применяется автоматически
|
||||||
|
|
||||||
|
### 2. Стиль кода
|
||||||
|
**Файл:** `.cursor/rules/code-style.md`
|
||||||
|
**Описание:** Стиль кода, соглашения по именованию и форматированию
|
||||||
|
**Применение:** `alwaysApply: true` - применяется автоматически
|
||||||
|
|
||||||
|
### 3. Паттерны работы с БД
|
||||||
|
**Файл:** `.cursor/rules/database-patterns.md`
|
||||||
|
**Описание:** Паттерны работы с базой данных, репозитории и модели
|
||||||
|
**Применение:** Применяется к файлам `database/**/*.py` и `**/repositories/*.py`
|
||||||
|
|
||||||
|
### 4. Зависимости и утилиты
|
||||||
|
**Файл:** `.cursor/rules/dependencies-and-utils.md`
|
||||||
|
**Описание:** Работа с зависимостями, утилитами, метриками и внешними сервисами
|
||||||
|
**Применение:** Применяется к файлам `helper_bot/utils/**/*.py` и `helper_bot/config/**/*.py`
|
||||||
|
|
||||||
|
### 5. Обработка ошибок
|
||||||
|
**Файл:** `.cursor/rules/error-handling.md`
|
||||||
|
**Описание:** Обработка ошибок, исключения и логирование
|
||||||
|
**Применение:** Опционально (не всегда применяется)
|
||||||
|
|
||||||
|
### 6. Паттерны Handlers
|
||||||
|
**Файл:** `.cursor/rules/handlers-patterns.md`
|
||||||
|
**Описание:** Паттерны для создания handlers, services и обработки событий aiogram
|
||||||
|
**Применение:** Применяется к файлам `helper_bot/handlers/**/*.py`
|
||||||
|
|
||||||
|
### 7. Паттерны Middleware
|
||||||
|
**Файл:** `.cursor/rules/middleware-patterns.md`
|
||||||
|
**Описание:** Паттерны создания и использования middleware в aiogram
|
||||||
|
**Применение:** Применяется к файлам `helper_bot/middlewares/**/*.py`
|
||||||
|
|
||||||
|
### 8. Паттерны тестирования
|
||||||
|
**Файл:** `.cursor/rules/testing.md`
|
||||||
|
**Описание:** Паттерны тестирования, структура тестов и использование pytest
|
||||||
|
**Применение:** Применяется к файлам `tests/**/*.py` и `test_*.py`
|
||||||
|
|
||||||
|
### 9. Шаблон Release Notes
|
||||||
|
**Файл:** `.cursor/rules/release-notes-template.md`
|
||||||
|
**Описание:** Инструкция по оформлению Release Notes
|
||||||
|
**Применение:** Используется при создании файлов Release Notes
|
||||||
|
|
||||||
|
## 🎯 Ключевые принципы проекта
|
||||||
|
|
||||||
|
1. **Архитектура:** Repository Pattern → Service Layer → Handlers
|
||||||
|
2. **Асинхронность:** Все операции с БД и API асинхронные
|
||||||
|
3. **Типизация:** Type hints везде, где возможно
|
||||||
|
4. **Логирование:** Всегда через `logs.custom_logger.logger`
|
||||||
|
5. **Метрики:** Декораторы `@track_time`, `@track_errors`, `@db_query_time`
|
||||||
|
6. **Версия Python:** Python 3.11.9 во всех окружениях
|
||||||
|
|
||||||
|
## 📚 Как использовать
|
||||||
|
|
||||||
|
При работе над проектом агент должен:
|
||||||
|
- Следовать архитектурным паттернам из `architecture.md`
|
||||||
|
- Применять стиль кода из `code-style.md`
|
||||||
|
- Использовать соответствующие паттерны в зависимости от контекста (handlers, middleware, database, testing)
|
||||||
|
- Обрабатывать ошибки согласно `error-handling.md`
|
||||||
|
- При создании Release Notes следовать `release-notes-template.md`
|
||||||
|
|
||||||
|
Все правила автоматически применяются в зависимости от контекста редактируемых файлов благодаря настройкам `alwaysApply` и `globs` в frontmatter каждого файла.
|
||||||
124
.cursor/rules/release-notes-template.md
Normal file
124
.cursor/rules/release-notes-template.md
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# Инструкция по оформлению Release Notes
|
||||||
|
|
||||||
|
## Назначение
|
||||||
|
Этот документ описывает структуру и формат для создания файлов Release Notes (например, `docs/RELEASE_NOTES_DEV-XX.md`).
|
||||||
|
|
||||||
|
## Структура документа
|
||||||
|
|
||||||
|
### 1. Заголовок
|
||||||
|
```markdown
|
||||||
|
# Release Notes: [название-ветки]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Обзор
|
||||||
|
Краткий абзац (1-2 предложения), описывающий:
|
||||||
|
- Количество коммитов в ветке
|
||||||
|
- Основные направления изменений
|
||||||
|
|
||||||
|
**Формат:**
|
||||||
|
```markdown
|
||||||
|
## Обзор
|
||||||
|
Ветка [название] содержит [N] коммитов с ключевыми улучшениями: [краткое перечисление основных изменений].
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Ключевые изменения
|
||||||
|
Основной раздел с пронумерованными подразделами для каждого значимого изменения.
|
||||||
|
|
||||||
|
**Структура каждого подраздела:**
|
||||||
|
```markdown
|
||||||
|
### [Номер]. [Название изменения]
|
||||||
|
|
||||||
|
**Коммит:** `[hash]`
|
||||||
|
|
||||||
|
**Что сделано:**
|
||||||
|
- [Краткое описание изменения 1]
|
||||||
|
- [Краткое описание изменения 2]
|
||||||
|
- [Краткое описание изменения 3]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Правила:**
|
||||||
|
- Каждое изменение = отдельный подраздел
|
||||||
|
- Название должно быть кратким и понятным
|
||||||
|
- В разделе "Что сделано" используй маркированные списки
|
||||||
|
- НЕ перечисляй затронутые файлы
|
||||||
|
- НЕ указывай статистику строк кода
|
||||||
|
- Фокусируйся на сути изменений, а не на технических деталях
|
||||||
|
- Разделяй подразделы горизонтальной линией `---`
|
||||||
|
|
||||||
|
### 4. Основные достижения
|
||||||
|
Раздел с чекбоксами, подводящий итоги релиза.
|
||||||
|
|
||||||
|
**Формат:**
|
||||||
|
```markdown
|
||||||
|
## 🎯 Основные достижения
|
||||||
|
|
||||||
|
✅ [Достижение 1]
|
||||||
|
✅ [Достижение 2]
|
||||||
|
✅ [Достижение 3]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Правила:**
|
||||||
|
- Используй эмодзи ✅ для каждого достижения
|
||||||
|
- Каждое достижение на отдельной строке
|
||||||
|
- Краткие формулировки (3-5 слов)
|
||||||
|
- Фокусируйся на ключевых фичах и улучшениях
|
||||||
|
|
||||||
|
### 5. Временная шкала разработки
|
||||||
|
Раздел с информацией о сроках разработки.
|
||||||
|
|
||||||
|
**Формат:**
|
||||||
|
```markdown
|
||||||
|
## 📅 Временная шкала разработки
|
||||||
|
|
||||||
|
**Последние изменения:** [дата]
|
||||||
|
**Основная разработка:** [период]
|
||||||
|
**Предыдущие улучшения:** [контекст предыдущих веток/изменений]
|
||||||
|
|
||||||
|
**Хронология коммитов:**
|
||||||
|
- `[hash]` - [дата и время] - [краткое описание]
|
||||||
|
- `[hash]` - [дата и время] - [краткое описание]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Правила:**
|
||||||
|
- Используй реальные даты из коммитов
|
||||||
|
- Формат даты: "DD месяц YYYY" (например, "25 января 2026")
|
||||||
|
- Для времени используй формат "HH:MM"
|
||||||
|
- Хронология должна быть в хронологическом порядке (от старых к новым)
|
||||||
|
|
||||||
|
## Стиль написания
|
||||||
|
|
||||||
|
### Общие правила:
|
||||||
|
- **Краткость**: Фокусируйся на сути, избегай избыточных деталей
|
||||||
|
- **Ясность**: Используй простые и понятные формулировки
|
||||||
|
- **Структурированность**: Информация должна быть легко читаемой и сканируемой
|
||||||
|
- **Без технических деталей**: Не перечисляй файлы, классы, методы (только если это ключевая фича)
|
||||||
|
- **Без статистики**: Не указывай количество строк кода, файлов и т.д.
|
||||||
|
|
||||||
|
### Язык:
|
||||||
|
- Используй прошедшее время для описания изменений ("Добавлена", "Реализована", "Обновлена")
|
||||||
|
- Избегай технического жаргона, если это не необходимо
|
||||||
|
- Используй активный залог
|
||||||
|
|
||||||
|
### Эмодзи:
|
||||||
|
- 🔥 для раздела "Ключевые изменения"
|
||||||
|
- 🎯 для раздела "Основные достижения"
|
||||||
|
- 📅 для раздела "Временная шкала разработки"
|
||||||
|
- ✅ для чекбоксов достижений
|
||||||
|
|
||||||
|
## Пример использования
|
||||||
|
|
||||||
|
При создании Release Notes для новой ветки:
|
||||||
|
|
||||||
|
1. Получи список коммитов: `git log [base-branch]..[target-branch] --oneline`
|
||||||
|
2. Для каждого значимого коммита создай подраздел в "Ключевые изменения"
|
||||||
|
3. Собери основные достижения в раздел "Основные достижения"
|
||||||
|
4. Добавь временную шкалу с реальными датами коммитов
|
||||||
|
5. Проверь, что документ следует структуре и стилю
|
||||||
|
|
||||||
|
## Важные замечания
|
||||||
|
|
||||||
|
- **НЕ включай** информацию о коммитах, которые уже были в базовой ветке (master/main)
|
||||||
|
- **НЕ перечисляй** все файлы, которые были изменены
|
||||||
|
- **НЕ указывай** статистику строк кода
|
||||||
|
- **Фокусируйся** на функциональных изменениях, а не на технических деталях реализации
|
||||||
|
- Используй **реальные даты** из коммитов, а не предполагаемые
|
||||||
197
.cursor/rules/testing.md
Normal file
197
.cursor/rules/testing.md
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
---
|
||||||
|
description: "Паттерны тестирования, структура тестов и использование pytest"
|
||||||
|
globs: ["tests/**/*.py", "test_*.py"]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Паттерны тестирования
|
||||||
|
|
||||||
|
## Структура тестов
|
||||||
|
|
||||||
|
Тесты находятся в директории `tests/` и используют pytest:
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/
|
||||||
|
├── conftest.py # Общие фикстуры
|
||||||
|
├── conftest_*.py # Специализированные фикстуры
|
||||||
|
├── mocks.py # Моки и заглушки
|
||||||
|
└── test_*.py # Тестовые файлы
|
||||||
|
```
|
||||||
|
|
||||||
|
## Конфигурация pytest
|
||||||
|
|
||||||
|
Настройки в `pyproject.toml`:
|
||||||
|
- `asyncio-mode=auto` - автоматический режим для async тестов
|
||||||
|
- Маркеры: `asyncio`, `slow`, `integration`, `unit`
|
||||||
|
- Фильтрация предупреждений
|
||||||
|
|
||||||
|
## Структура теста
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
from database.async_db import AsyncBotDB
|
||||||
|
from database.repositories.user_repository import UserRepository
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_user_repository_add_user(db_path):
|
||||||
|
"""Тест добавления пользователя."""
|
||||||
|
# Arrange
|
||||||
|
repo = UserRepository(db_path)
|
||||||
|
user = User(user_id=123, full_name="Test User")
|
||||||
|
|
||||||
|
# Act
|
||||||
|
await repo.add_user(user)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
result = await repo.get_user_by_id(123)
|
||||||
|
assert result is not None
|
||||||
|
assert result.full_name == "Test User"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Фикстуры
|
||||||
|
|
||||||
|
### Общие фикстуры (conftest.py)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db_path():
|
||||||
|
"""Создает временный файл БД для тестов."""
|
||||||
|
fd, path = tempfile.mkstemp(suffix='.db')
|
||||||
|
os.close(fd)
|
||||||
|
yield path
|
||||||
|
os.unlink(path)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def async_db(db_path):
|
||||||
|
"""Создает AsyncBotDB для тестов."""
|
||||||
|
db = AsyncBotDB(db_path)
|
||||||
|
await db.create_tables()
|
||||||
|
yield db
|
||||||
|
```
|
||||||
|
|
||||||
|
### Использование фикстур
|
||||||
|
|
||||||
|
```python
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_something(async_db):
|
||||||
|
# async_db уже инициализирован
|
||||||
|
result = await async_db.some_method()
|
||||||
|
assert result is not None
|
||||||
|
```
|
||||||
|
|
||||||
|
## Моки
|
||||||
|
|
||||||
|
Используйте `mocks.py` для общих моков:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
def mock_bot():
|
||||||
|
"""Создает мок бота."""
|
||||||
|
bot = MagicMock()
|
||||||
|
bot.send_message = AsyncMock()
|
||||||
|
return bot
|
||||||
|
```
|
||||||
|
|
||||||
|
## Маркеры
|
||||||
|
|
||||||
|
Используйте маркеры для категоризации тестов:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unit_test():
|
||||||
|
"""Быстрый unit тест."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_integration_test():
|
||||||
|
"""Медленный integration тест."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@pytest.mark.slow
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_slow_test():
|
||||||
|
"""Медленный тест."""
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Запуск с фильтрацией:
|
||||||
|
```bash
|
||||||
|
pytest -m "not slow" # Пропустить медленные тесты
|
||||||
|
pytest -m unit # Только unit тесты
|
||||||
|
```
|
||||||
|
|
||||||
|
## Тестирование Handlers
|
||||||
|
|
||||||
|
```python
|
||||||
|
from aiogram import Bot, Dispatcher
|
||||||
|
from aiogram.fsm.storage.memory import MemoryStorage
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handler(mock_bot, mock_message):
|
||||||
|
"""Тест handler."""
|
||||||
|
# Arrange
|
||||||
|
dp = Dispatcher(storage=MemoryStorage())
|
||||||
|
# Регистрация handler
|
||||||
|
...
|
||||||
|
|
||||||
|
# Act
|
||||||
|
await dp.feed_update(mock_update)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
mock_bot.send_message.assert_called_once()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Тестирование Services
|
||||||
|
|
||||||
|
```python
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_service_method(async_db):
|
||||||
|
"""Тест метода сервиса."""
|
||||||
|
service = SomeService(async_db, {})
|
||||||
|
result = await service.do_something()
|
||||||
|
assert result is not None
|
||||||
|
```
|
||||||
|
|
||||||
|
## Тестирование Repositories
|
||||||
|
|
||||||
|
```python
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repository_crud(db_path):
|
||||||
|
"""Тест CRUD операций репозитория."""
|
||||||
|
repo = SomeRepository(db_path)
|
||||||
|
await repo.create_tables()
|
||||||
|
|
||||||
|
# Create
|
||||||
|
entity = SomeEntity(...)
|
||||||
|
await repo.add(entity)
|
||||||
|
|
||||||
|
# Read
|
||||||
|
result = await repo.get_by_id(entity.id)
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
# Update
|
||||||
|
entity.field = "new_value"
|
||||||
|
await repo.update(entity)
|
||||||
|
|
||||||
|
# Delete
|
||||||
|
await repo.delete(entity.id)
|
||||||
|
result = await repo.get_by_id(entity.id)
|
||||||
|
assert result is None
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Используйте фикстуры** для переиспользования setup/teardown
|
||||||
|
2. **Изолируйте тесты** - каждый тест должен быть независимым
|
||||||
|
3. **Используйте временные БД** для тестов репозиториев
|
||||||
|
4. **Мокируйте внешние зависимости** (API, файловая система)
|
||||||
|
5. **Пишите понятные имена тестов** - они должны описывать что тестируется
|
||||||
|
6. **Используйте Arrange-Act-Assert** паттерн
|
||||||
|
7. **Тестируйте граничные случаи** и ошибки
|
||||||
95
.github/workflows/ci.yml
vendored
Normal file
95
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
name: CI pipeline
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ 'dev-*', 'feature-*', 'fix-*' ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ 'dev-*', 'feature-*', 'fix-*', 'main' ]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: Test & Code Quality
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python 3.11
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
cache: 'pip'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
|
||||||
|
- name: Code style check (isort + Black, one order — no conflict)
|
||||||
|
run: |
|
||||||
|
echo "🔍 Applying isort then black (pyproject.toml: isort profile=black)..."
|
||||||
|
python -m isort .
|
||||||
|
python -m black .
|
||||||
|
echo "🔍 Checking that repo is already formatted (no diff after isort+black)..."
|
||||||
|
git diff --exit-code || (
|
||||||
|
echo "❌ Code style drift. From THIS repo root (telegram-helper-bot) run:"
|
||||||
|
echo " python -m isort . && python -m black . && git add -A && git commit -m 'style: isort + black'"
|
||||||
|
exit 1
|
||||||
|
)
|
||||||
|
|
||||||
|
- name: Linting (flake8) - Critical errors
|
||||||
|
run: |
|
||||||
|
echo "🔍 Running flake8 linter (critical errors only)..."
|
||||||
|
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics || true
|
||||||
|
|
||||||
|
- name: Linting (flake8) - Warnings
|
||||||
|
run: |
|
||||||
|
echo "🔍 Running flake8 linter (warnings)..."
|
||||||
|
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics || true
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: |
|
||||||
|
echo "🧪 Running tests..."
|
||||||
|
python -m pytest tests/ -v --tb=short
|
||||||
|
|
||||||
|
- name: Send test success notification
|
||||||
|
if: success()
|
||||||
|
uses: appleboy/telegram-action@v1.0.0
|
||||||
|
with:
|
||||||
|
to: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||||
|
token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||||
|
message: |
|
||||||
|
✅ CI Tests Passed
|
||||||
|
|
||||||
|
📦 Repository: telegram-helper-bot
|
||||||
|
🌿 Branch: ${{ github.ref_name }}
|
||||||
|
📝 Commit: ${{ github.sha }}
|
||||||
|
👤 Author: ${{ github.actor }}
|
||||||
|
|
||||||
|
✅ All tests passed! Code quality checks completed successfully.
|
||||||
|
|
||||||
|
🔗 View details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Send test failure notification
|
||||||
|
if: failure()
|
||||||
|
uses: appleboy/telegram-action@v1.0.0
|
||||||
|
with:
|
||||||
|
to: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||||
|
token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||||
|
message: |
|
||||||
|
❌ CI Tests Failed
|
||||||
|
|
||||||
|
📦 Repository: telegram-helper-bot
|
||||||
|
🌿 Branch: ${{ github.ref_name }}
|
||||||
|
📝 Commit: ${{ github.sha }}
|
||||||
|
👤 Author: ${{ github.actor }}
|
||||||
|
|
||||||
|
❌ Tests failed! Deployment blocked. Please fix the issues and try again.
|
||||||
|
|
||||||
|
🔗 View details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
|
continue-on-error: true
|
||||||
385
.github/workflows/deploy.yml
vendored
Normal file
385
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
name: Deploy to Production
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
action:
|
||||||
|
description: 'Action to perform'
|
||||||
|
required: true
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- deploy
|
||||||
|
- rollback
|
||||||
|
rollback_commit:
|
||||||
|
description: 'Commit hash to rollback to (optional, uses last successful if empty)'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
dry_run:
|
||||||
|
description: 'Dry run (only for deploy — no SSH, only show planned steps)'
|
||||||
|
required: true
|
||||||
|
type: choice
|
||||||
|
default: no
|
||||||
|
options:
|
||||||
|
- no
|
||||||
|
- yes
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: Deploy to Production
|
||||||
|
if: |
|
||||||
|
github.event_name == 'push' ||
|
||||||
|
(github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'deploy')
|
||||||
|
env:
|
||||||
|
DRY_RUN: ${{ github.event.inputs.dry_run == 'yes' }}
|
||||||
|
concurrency:
|
||||||
|
group: production-deploy-telegram-helper-bot
|
||||||
|
cancel-in-progress: false
|
||||||
|
environment:
|
||||||
|
name: production
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: main
|
||||||
|
|
||||||
|
- name: Dry run (simulate deploy steps)
|
||||||
|
if: github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'yes'
|
||||||
|
run: |
|
||||||
|
echo "🔍 DRY RUN — no SSH, no changes on server"
|
||||||
|
echo "Would run on server:"
|
||||||
|
echo " 1. cd /home/prod/bots/telegram-helper-bot"
|
||||||
|
echo " 2. CURRENT_COMMIT=\$(git rev-parse HEAD); write to .deploy_history_telegram_helper_bot.txt"
|
||||||
|
echo " 3. git fetch origin main && git reset --hard origin/main"
|
||||||
|
echo " 4. python3 scripts/apply_migrations.py --db ... (if DB exists)"
|
||||||
|
echo " 5. docker-compose -f /home/prod/docker-compose.yml config (validate)"
|
||||||
|
echo " 6. docker-compose stop telegram-bot; build --pull telegram-bot; up -d telegram-bot"
|
||||||
|
echo " 7. sleep 10; check container bots_telegram_bot"
|
||||||
|
echo ""
|
||||||
|
echo "Secrets/vars required: SERVER_HOST, SERVER_USER, SSH_PRIVATE_KEY, SSH_PORT, TELEGRAM_BOT_TOKEN, TELEGRAM_TEST_BOT_TOKEN"
|
||||||
|
if [ -f docker-compose.yml ]; then
|
||||||
|
echo "✅ docker-compose.yml present in repo (validation would run on server from /home/prod)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Deploy to server
|
||||||
|
if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'yes'
|
||||||
|
uses: appleboy/ssh-action@v1.0.0
|
||||||
|
with:
|
||||||
|
host: ${{ vars.SERVER_HOST || secrets.SERVER_HOST }}
|
||||||
|
username: ${{ vars.SERVER_USER || secrets.SERVER_USER }}
|
||||||
|
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
port: ${{ vars.SSH_PORT || secrets.SSH_PORT || 22 }}
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
export TELEGRAM_BOT_TOKEN="${{ secrets.TELEGRAM_BOT_TOKEN }}"
|
||||||
|
export TELEGRAM_TEST_BOT_TOKEN="${{ secrets.TELEGRAM_TEST_BOT_TOKEN }}"
|
||||||
|
|
||||||
|
echo "🚀 Starting deployment to production..."
|
||||||
|
|
||||||
|
sudo chown -R deploy:deploy /home/prod/bots/telegram-helper-bot || true
|
||||||
|
cd /home/prod/bots/telegram-helper-bot
|
||||||
|
|
||||||
|
# Сохраняем информацию о коммите (до pull) — из репо telegram-helper-bot
|
||||||
|
CURRENT_COMMIT=$(git rev-parse HEAD)
|
||||||
|
COMMIT_MESSAGE=$(git log -1 --pretty=format:"%s" || echo "Unknown")
|
||||||
|
COMMIT_AUTHOR=$(git log -1 --pretty=format:"%an" || echo "Unknown")
|
||||||
|
TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
echo "📝 Current commit: $CURRENT_COMMIT"
|
||||||
|
echo "📝 Commit message: $COMMIT_MESSAGE"
|
||||||
|
echo "📝 Author: $COMMIT_AUTHOR"
|
||||||
|
|
||||||
|
# Записываем в историю деплоев
|
||||||
|
HISTORY_FILE="/home/prod/.deploy_history_telegram_helper_bot.txt"
|
||||||
|
HISTORY_SIZE="${DEPLOY_HISTORY_SIZE:-10}"
|
||||||
|
echo "${TIMESTAMP}|${CURRENT_COMMIT}|${COMMIT_MESSAGE}|${COMMIT_AUTHOR}|deploying" >> "$HISTORY_FILE"
|
||||||
|
tail -n "$HISTORY_SIZE" "$HISTORY_FILE" > "${HISTORY_FILE}.tmp" && mv "${HISTORY_FILE}.tmp" "$HISTORY_FILE"
|
||||||
|
|
||||||
|
# Обновляем код
|
||||||
|
echo "📥 Pulling latest changes from main..."
|
||||||
|
git fetch origin main
|
||||||
|
git reset --hard origin/main
|
||||||
|
sudo chown -R deploy:deploy /home/prod/bots/telegram-helper-bot || true
|
||||||
|
|
||||||
|
NEW_COMMIT=$(git rev-parse HEAD)
|
||||||
|
echo "✅ Code updated: $CURRENT_COMMIT → $NEW_COMMIT"
|
||||||
|
|
||||||
|
# Применяем миграции БД перед перезапуском контейнера
|
||||||
|
echo "🔄 Applying database migrations..."
|
||||||
|
DB_PATH="/home/prod/bots/telegram-helper-bot/database/tg-bot-database.db"
|
||||||
|
|
||||||
|
if [ -f "$DB_PATH" ]; then
|
||||||
|
cd /home/prod/bots/telegram-helper-bot
|
||||||
|
python3 scripts/apply_migrations.py --db "$DB_PATH" || {
|
||||||
|
echo "❌ Ошибка при применении миграций!"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
echo "✅ Миграции применены успешно"
|
||||||
|
else
|
||||||
|
echo "⚠️ База данных не найдена, пропускаем миграции (будет создана при первом запуске)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Валидация docker-compose
|
||||||
|
echo "🔍 Validating docker-compose configuration..."
|
||||||
|
cd /home/prod
|
||||||
|
docker-compose config > /dev/null || exit 1
|
||||||
|
echo "✅ docker-compose.yml is valid"
|
||||||
|
|
||||||
|
# Проверка дискового пространства
|
||||||
|
MIN_FREE_GB=5
|
||||||
|
AVAILABLE_SPACE=$(df -BG /home/prod 2>/dev/null | tail -1 | awk '{print $4}' | sed 's/G//' || echo "0")
|
||||||
|
echo "💾 Available disk space: ${AVAILABLE_SPACE}GB"
|
||||||
|
|
||||||
|
if [ "$AVAILABLE_SPACE" -lt "$MIN_FREE_GB" ]; then
|
||||||
|
echo "⚠️ Insufficient disk space! Cleaning up Docker resources..."
|
||||||
|
docker system prune -f --volumes || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Пересобираем и перезапускаем контейнер бота
|
||||||
|
echo "🔨 Rebuilding and restarting telegram-bot container..."
|
||||||
|
cd /home/prod
|
||||||
|
|
||||||
|
export TELEGRAM_BOT_TOKEN TELEGRAM_TEST_BOT_TOKEN
|
||||||
|
docker-compose stop telegram-bot || true
|
||||||
|
docker-compose build --pull telegram-bot
|
||||||
|
docker-compose up -d telegram-bot
|
||||||
|
|
||||||
|
echo "✅ Telegram bot container rebuilt and started"
|
||||||
|
|
||||||
|
# Ждем немного и проверяем healthcheck
|
||||||
|
echo "⏳ Waiting for container to start..."
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
if docker ps | grep -q bots_telegram_bot; then
|
||||||
|
echo "✅ Container is running"
|
||||||
|
else
|
||||||
|
echo "❌ Container failed to start!"
|
||||||
|
docker logs bots_telegram_bot --tail 50 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Update deploy history
|
||||||
|
if: always() && env.DRY_RUN != 'true'
|
||||||
|
uses: appleboy/ssh-action@v1.0.0
|
||||||
|
with:
|
||||||
|
host: ${{ vars.SERVER_HOST || secrets.SERVER_HOST }}
|
||||||
|
username: ${{ vars.SERVER_USER || secrets.SERVER_USER }}
|
||||||
|
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
port: ${{ vars.SSH_PORT || secrets.SSH_PORT || 22 }}
|
||||||
|
script: |
|
||||||
|
HISTORY_FILE="/home/prod/.deploy_history_telegram_helper_bot.txt"
|
||||||
|
|
||||||
|
if [ -f "$HISTORY_FILE" ]; then
|
||||||
|
DEPLOY_STATUS="failed"
|
||||||
|
if [ "${{ job.status }}" = "success" ]; then
|
||||||
|
DEPLOY_STATUS="success"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sed -i '$s/|deploying$/|'"$DEPLOY_STATUS"'/' "$HISTORY_FILE"
|
||||||
|
echo "✅ Deploy history updated: $DEPLOY_STATUS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Send deployment notification
|
||||||
|
if: always() && env.DRY_RUN != 'true'
|
||||||
|
uses: appleboy/telegram-action@v1.0.0
|
||||||
|
with:
|
||||||
|
to: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||||
|
token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||||
|
message: |
|
||||||
|
${{ job.status == 'success' && '✅' || '❌' }} Deployment: ${{ job.status }}
|
||||||
|
|
||||||
|
📦 Repository: telegram-helper-bot
|
||||||
|
🌿 Branch: main
|
||||||
|
📝 Commit: ${{ github.sha }}
|
||||||
|
👤 Author: ${{ github.actor }}
|
||||||
|
|
||||||
|
${{ job.status == 'success' && '✅ Deployment successful! Container restarted with migrations applied.' || '❌ Deployment failed! Check logs for details.' }}
|
||||||
|
|
||||||
|
🔗 View details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Get PR body from merged PR
|
||||||
|
if: job.status == 'success' && github.event_name == 'push' && env.DRY_RUN != 'true'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
echo "🔍 Searching for merged PR associated with commit ${{ github.sha }}..."
|
||||||
|
|
||||||
|
# Находим последний мерженный PR для main ветки по merge commit SHA
|
||||||
|
COMMIT_SHA="${{ github.sha }}"
|
||||||
|
PR_NUMBER=$(gh pr list --state merged --base main --limit 10 --json number,mergeCommit --jq ".[] | select(.mergeCommit.oid == \"$COMMIT_SHA\") | .number" | head -1)
|
||||||
|
|
||||||
|
# Если не нашли по merge commit, ищем последний мерженный PR
|
||||||
|
if [ -z "$PR_NUMBER" ]; then
|
||||||
|
echo "⚠️ PR not found by merge commit, trying to get latest merged PR..."
|
||||||
|
PR_NUMBER=$(gh pr list --state merged --base main --limit 1 --json number --jq '.[0].number')
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$PR_NUMBER" ] && [ "$PR_NUMBER" != "null" ]; then
|
||||||
|
echo "✅ Found PR #$PR_NUMBER"
|
||||||
|
PR_BODY=$(gh pr view $PR_NUMBER --json body --jq '.body // ""')
|
||||||
|
|
||||||
|
if [ -n "$PR_BODY" ] && [ "$PR_BODY" != "null" ]; then
|
||||||
|
echo "PR_BODY<<EOF" >> $GITHUB_ENV
|
||||||
|
echo "$PR_BODY" >> $GITHUB_ENV
|
||||||
|
echo "EOF" >> $GITHUB_ENV
|
||||||
|
echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV
|
||||||
|
echo "✅ PR body extracted successfully"
|
||||||
|
else
|
||||||
|
echo "⚠️ PR body is empty"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "⚠️ No merged PR found for this commit"
|
||||||
|
fi
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Send PR body to important logs
|
||||||
|
if: job.status == 'success' && github.event_name == 'push' && env.DRY_RUN != 'true' && env.PR_BODY != ''
|
||||||
|
uses: appleboy/telegram-action@v1.0.0
|
||||||
|
with:
|
||||||
|
to: ${{ secrets.IMPORTANT_LOGS_CHAT }}
|
||||||
|
token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||||
|
message: |
|
||||||
|
📋 Pull Request Description (PR #${{ env.PR_NUMBER }}):
|
||||||
|
|
||||||
|
${{ env.PR_BODY }}
|
||||||
|
|
||||||
|
🔗 PR: ${{ github.server_url }}/${{ github.repository }}/pull/${{ env.PR_NUMBER }}
|
||||||
|
📝 Commit: ${{ github.sha }}
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
rollback:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: Rollback to Previous Version
|
||||||
|
if: |
|
||||||
|
github.event_name == 'workflow_dispatch' &&
|
||||||
|
github.event.inputs.action == 'rollback'
|
||||||
|
environment:
|
||||||
|
name: production
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: main
|
||||||
|
|
||||||
|
- name: Rollback on server
|
||||||
|
uses: appleboy/ssh-action@v1.0.0
|
||||||
|
with:
|
||||||
|
host: ${{ vars.SERVER_HOST || secrets.SERVER_HOST }}
|
||||||
|
username: ${{ vars.SERVER_USER || secrets.SERVER_USER }}
|
||||||
|
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
port: ${{ vars.SSH_PORT || secrets.SSH_PORT || 22 }}
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
export TELEGRAM_BOT_TOKEN="${{ secrets.TELEGRAM_BOT_TOKEN }}"
|
||||||
|
export TELEGRAM_TEST_BOT_TOKEN="${{ secrets.TELEGRAM_TEST_BOT_TOKEN }}"
|
||||||
|
|
||||||
|
echo "🔄 Starting rollback..."
|
||||||
|
|
||||||
|
cd /home/prod
|
||||||
|
|
||||||
|
# Определяем коммит для отката
|
||||||
|
ROLLBACK_COMMIT="${{ github.event.inputs.rollback_commit }}"
|
||||||
|
HISTORY_FILE="/home/prod/.deploy_history_telegram_helper_bot.txt"
|
||||||
|
|
||||||
|
if [ -z "$ROLLBACK_COMMIT" ]; then
|
||||||
|
echo "📝 No commit specified, finding last successful deploy..."
|
||||||
|
if [ -f "$HISTORY_FILE" ]; then
|
||||||
|
ROLLBACK_COMMIT=$(grep "|success$" "$HISTORY_FILE" | tail -1 | cut -d'|' -f2 || echo "")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$ROLLBACK_COMMIT" ]; then
|
||||||
|
echo "❌ No successful deploy found in history!"
|
||||||
|
echo "💡 Please specify commit hash manually or check deploy history"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "📝 Rolling back to commit: $ROLLBACK_COMMIT"
|
||||||
|
|
||||||
|
# Проверяем, что коммит существует
|
||||||
|
cd /home/prod/bots/telegram-helper-bot
|
||||||
|
if ! git cat-file -e "$ROLLBACK_COMMIT" 2>/dev/null; then
|
||||||
|
echo "❌ Commit $ROLLBACK_COMMIT not found!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Сохраняем текущий коммит
|
||||||
|
CURRENT_COMMIT=$(git rev-parse HEAD)
|
||||||
|
COMMIT_MESSAGE=$(git log -1 --pretty=format:"%s" "$ROLLBACK_COMMIT" || echo "Rollback")
|
||||||
|
TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
echo "📝 Current commit: $CURRENT_COMMIT"
|
||||||
|
echo "📝 Target commit: $ROLLBACK_COMMIT"
|
||||||
|
echo "📝 Commit message: $COMMIT_MESSAGE"
|
||||||
|
|
||||||
|
# Исправляем права перед откатом
|
||||||
|
sudo chown -R deploy:deploy /home/prod/bots/telegram-helper-bot || true
|
||||||
|
|
||||||
|
# Откатываем код
|
||||||
|
echo "🔄 Rolling back code..."
|
||||||
|
git fetch origin main
|
||||||
|
git reset --hard "$ROLLBACK_COMMIT"
|
||||||
|
|
||||||
|
# Исправляем права после отката
|
||||||
|
sudo chown -R deploy:deploy /home/prod/bots/telegram-helper-bot || true
|
||||||
|
|
||||||
|
echo "✅ Code rolled back: $CURRENT_COMMIT → $ROLLBACK_COMMIT"
|
||||||
|
|
||||||
|
# Валидация docker-compose
|
||||||
|
echo "🔍 Validating docker-compose configuration..."
|
||||||
|
cd /home/prod
|
||||||
|
docker-compose config > /dev/null || exit 1
|
||||||
|
echo "✅ docker-compose.yml is valid"
|
||||||
|
|
||||||
|
# Проверка дискового пространства
|
||||||
|
MIN_FREE_GB=5
|
||||||
|
AVAILABLE_SPACE=$(df -BG /home/prod 2>/dev/null | tail -1 | awk '{print $4}' | sed 's/G//' || echo "0")
|
||||||
|
echo "💾 Available disk space: ${AVAILABLE_SPACE}GB"
|
||||||
|
|
||||||
|
if [ "$AVAILABLE_SPACE" -lt "$MIN_FREE_GB" ]; then
|
||||||
|
echo "⚠️ Insufficient disk space! Cleaning up Docker resources..."
|
||||||
|
docker system prune -f --volumes || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Пересобираем и перезапускаем контейнер
|
||||||
|
echo "🔨 Rebuilding and restarting telegram-bot container..."
|
||||||
|
cd /home/prod
|
||||||
|
|
||||||
|
export TELEGRAM_BOT_TOKEN TELEGRAM_TEST_BOT_TOKEN
|
||||||
|
docker-compose stop telegram-bot || true
|
||||||
|
docker-compose build --pull telegram-bot
|
||||||
|
docker-compose up -d telegram-bot
|
||||||
|
|
||||||
|
echo "✅ Telegram bot container rebuilt and started"
|
||||||
|
|
||||||
|
# Записываем в историю
|
||||||
|
echo "${TIMESTAMP}|${ROLLBACK_COMMIT}|Rollback to: ${COMMIT_MESSAGE}|github-actions|rolled_back" >> "$HISTORY_FILE"
|
||||||
|
HISTORY_SIZE="${DEPLOY_HISTORY_SIZE:-10}"
|
||||||
|
tail -n "$HISTORY_SIZE" "$HISTORY_FILE" > "${HISTORY_FILE}.tmp" && mv "${HISTORY_FILE}.tmp" "$HISTORY_FILE"
|
||||||
|
|
||||||
|
echo "✅ Rollback completed successfully"
|
||||||
|
|
||||||
|
- name: Send rollback notification
|
||||||
|
if: always()
|
||||||
|
uses: appleboy/telegram-action@v1.0.0
|
||||||
|
with:
|
||||||
|
to: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||||
|
token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||||
|
message: |
|
||||||
|
${{ job.status == 'success' && '🔄' || '❌' }} Rollback: ${{ job.status }}
|
||||||
|
|
||||||
|
📦 Repository: telegram-helper-bot
|
||||||
|
🌿 Branch: main
|
||||||
|
📝 Rolled back to: ${{ github.event.inputs.rollback_commit || 'Last successful commit' }}
|
||||||
|
👤 Triggered by: ${{ github.actor }}
|
||||||
|
|
||||||
|
${{ job.status == 'success' && '✅ Rollback completed successfully! Services restored to previous version.' || '❌ Rollback failed! Check logs for details.' }}
|
||||||
|
|
||||||
|
🔗 View details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -34,6 +34,9 @@ database/test.db
|
|||||||
test.db
|
test.db
|
||||||
*.db
|
*.db
|
||||||
|
|
||||||
|
# Случайно созданный файл при использовании SQLite :memory: не по назначению
|
||||||
|
:memory:
|
||||||
|
|
||||||
# IDE and editor files
|
# IDE and editor files
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
@@ -92,4 +95,7 @@ venv.bak/
|
|||||||
|
|
||||||
# Other files
|
# Other files
|
||||||
voice_users/
|
voice_users/
|
||||||
files/
|
files/
|
||||||
|
|
||||||
|
# ML models and vectors cache
|
||||||
|
data/
|
||||||
@@ -1 +1 @@
|
|||||||
3.9.6
|
3.11.9
|
||||||
|
|||||||
24
Dockerfile
24
Dockerfile
@@ -1,15 +1,14 @@
|
|||||||
###########################################
|
###########################################
|
||||||
# Этап 1: Сборщик (Builder)
|
# Этап 1: Сборщик (Builder)
|
||||||
###########################################
|
###########################################
|
||||||
FROM python:3.9-alpine as builder
|
FROM python:3.11.9-alpine as builder
|
||||||
|
|
||||||
# Устанавливаем инструменты для компиляции + linux-headers для psutil
|
# Устанавливаем инструменты для компиляции (если нужны для некоторых пакетов)
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
gcc \
|
gcc \
|
||||||
g++ \
|
|
||||||
musl-dev \
|
musl-dev \
|
||||||
python3-dev \
|
libffi-dev \
|
||||||
linux-headers # ← ЭТО КРИТИЧЕСКИ ВАЖНО ДЛЯ psutil
|
&& rm -rf /var/cache/apk/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
@@ -21,12 +20,7 @@ RUN pip install --no-cache-dir --target /install -r requirements.txt
|
|||||||
###########################################
|
###########################################
|
||||||
# Этап 2: Финальный образ (Runtime)
|
# Этап 2: Финальный образ (Runtime)
|
||||||
###########################################
|
###########################################
|
||||||
FROM python:3.9-alpine as runtime
|
FROM python:3.11.9-alpine as runtime
|
||||||
|
|
||||||
# Минимальные рантайм-зависимости
|
|
||||||
RUN apk add --no-cache \
|
|
||||||
libstdc++ \
|
|
||||||
sqlite-libs
|
|
||||||
|
|
||||||
# Создаем пользователя
|
# Создаем пользователя
|
||||||
RUN addgroup -g 1001 deploy && adduser -D -u 1001 -G deploy deploy
|
RUN addgroup -g 1001 deploy && adduser -D -u 1001 -G deploy deploy
|
||||||
@@ -34,16 +28,16 @@ RUN addgroup -g 1001 deploy && adduser -D -u 1001 -G deploy deploy
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Копируем зависимости
|
# Копируем зависимости
|
||||||
COPY --from=builder --chown=1001:1001 /install /usr/local/lib/python3.9/site-packages
|
COPY --from=builder --chown=deploy:deploy /install /usr/local/lib/python3.11/site-packages
|
||||||
|
|
||||||
# Создаем структуру папок
|
# Создаем структуру папок
|
||||||
RUN mkdir -p database logs voice_users && \
|
RUN mkdir -p database logs voice_users && \
|
||||||
chown -R 1001:1001 /app
|
chown -R deploy:deploy /app
|
||||||
|
|
||||||
# Копируем исходный код
|
# Копируем исходный код
|
||||||
COPY --chown=1001:1001 . .
|
COPY --chown=deploy:deploy . .
|
||||||
|
|
||||||
USER 1001
|
USER deploy
|
||||||
|
|
||||||
# Healthcheck
|
# Healthcheck
|
||||||
HEALTHCHECK --interval=30s --timeout=15s --start-period=10s --retries=5 \
|
HEALTHCHECK --interval=30s --timeout=15s --start-period=10s --retries=5 \
|
||||||
|
|||||||
@@ -1,171 +0,0 @@
|
|||||||
# Решение проблемы Flood Control в Telegram Bot
|
|
||||||
|
|
||||||
## Проблема
|
|
||||||
|
|
||||||
В логах бота наблюдались ошибки типа:
|
|
||||||
```
|
|
||||||
Flood control exceeded on method 'SendVoice' in chat 1322897572. Retry in 3 seconds.
|
|
||||||
```
|
|
||||||
|
|
||||||
Эти ошибки возникают при превышении лимитов Telegram Bot API:
|
|
||||||
- Не более 30 сообщений в секунду от одного бота глобально
|
|
||||||
- Не более 1 сообщения в секунду в один чат
|
|
||||||
- Дополнительные ограничения для разных типов сообщений
|
|
||||||
|
|
||||||
## Решение
|
|
||||||
|
|
||||||
Реализована комплексная система rate limiting, включающая:
|
|
||||||
|
|
||||||
### 1. Основные компоненты
|
|
||||||
|
|
||||||
#### `rate_limiter.py`
|
|
||||||
- **ChatRateLimiter**: Ограничивает скорость отправки сообщений для конкретного чата
|
|
||||||
- **GlobalRateLimiter**: Глобальные ограничения для всех чатов
|
|
||||||
- **RetryHandler**: Обработка повторных попыток с экспоненциальной задержкой
|
|
||||||
- **TelegramRateLimiter**: Основной класс, объединяющий все компоненты
|
|
||||||
|
|
||||||
#### `rate_limit_monitor.py`
|
|
||||||
- **RateLimitMonitor**: Мониторинг и статистика rate limiting
|
|
||||||
- Отслеживание успешных/неудачных запросов
|
|
||||||
- Анализ ошибок и производительности
|
|
||||||
- Статистика по чатам
|
|
||||||
|
|
||||||
#### `rate_limit_config.py`
|
|
||||||
- Конфигурации для разных окружений (development, production, strict)
|
|
||||||
- Адаптивные настройки на основе уровня ошибок
|
|
||||||
- Настройки для разных типов сообщений
|
|
||||||
|
|
||||||
#### `rate_limit_middleware.py`
|
|
||||||
- Middleware для автоматического применения rate limiting
|
|
||||||
- Перехват всех исходящих сообщений
|
|
||||||
- Прозрачная интеграция с существующим кодом
|
|
||||||
|
|
||||||
### 2. Ключевые особенности
|
|
||||||
|
|
||||||
#### Rate Limiting
|
|
||||||
- **Настраиваемая скорость**: 0.5 сообщений в секунду на чат (по умолчанию)
|
|
||||||
- **Burst protection**: Максимум 2 сообщения подряд
|
|
||||||
- **Глобальные ограничения**: 10 сообщений в секунду глобально
|
|
||||||
- **Адаптивные задержки**: Увеличение задержек при ошибках
|
|
||||||
|
|
||||||
#### Retry Mechanism
|
|
||||||
- **Экспоненциальная задержка**: Увеличение времени ожидания при повторных попытках
|
|
||||||
- **Максимальные ограничения**: Ограничение максимального времени ожидания
|
|
||||||
- **Умная обработка ошибок**: Разные стратегии для разных типов ошибок
|
|
||||||
|
|
||||||
#### Мониторинг
|
|
||||||
- **Детальная статистика**: Отслеживание всех запросов и ошибок
|
|
||||||
- **Анализ производительности**: Процент успеха, время ожидания, активность
|
|
||||||
- **Административные команды**: `/ratelimit_stats`, `/ratelimit_errors`, `/reset_ratelimit_stats`
|
|
||||||
|
|
||||||
### 3. Интеграция
|
|
||||||
|
|
||||||
#### Обновленные функции
|
|
||||||
```python
|
|
||||||
# helper_func.py
|
|
||||||
async def send_voice_message(chat_id, message, voice, markup=None):
|
|
||||||
from .rate_limiter import send_with_rate_limit
|
|
||||||
|
|
||||||
async def _send_voice():
|
|
||||||
if markup is None:
|
|
||||||
return await message.bot.send_voice(chat_id=chat_id, voice=voice)
|
|
||||||
else:
|
|
||||||
return await message.bot.send_voice(chat_id=chat_id, voice=voice, reply_markup=markup)
|
|
||||||
|
|
||||||
return await send_with_rate_limit(_send_voice, chat_id)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Middleware
|
|
||||||
```python
|
|
||||||
# voice_handler.py
|
|
||||||
from helper_bot.middlewares.rate_limit_middleware import MessageSendMiddleware
|
|
||||||
|
|
||||||
def _setup_middleware(self):
|
|
||||||
self.router.message.middleware(DependenciesMiddleware())
|
|
||||||
self.router.message.middleware(BlacklistMiddleware())
|
|
||||||
self.router.message.middleware(MessageSendMiddleware()) # Новый middleware
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Конфигурация
|
|
||||||
|
|
||||||
#### Production настройки (по умолчанию)
|
|
||||||
```python
|
|
||||||
PRODUCTION_CONFIG = RateLimitSettings(
|
|
||||||
messages_per_second=0.5, # 1 сообщение каждые 2 секунды
|
|
||||||
burst_limit=2, # Максимум 2 сообщения подряд
|
|
||||||
retry_after_multiplier=1.5,
|
|
||||||
max_retry_delay=30.0,
|
|
||||||
max_retries=3,
|
|
||||||
voice_message_delay=2.5, # Дополнительная задержка для голосовых
|
|
||||||
media_message_delay=2.0,
|
|
||||||
text_message_delay=1.5
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Адаптивная конфигурация
|
|
||||||
Система автоматически ужесточает ограничения при высоком уровне ошибок:
|
|
||||||
- При >10% ошибок: уменьшение скорости в 2 раза
|
|
||||||
- При <1% ошибок: увеличение скорости на 20%
|
|
||||||
|
|
||||||
### 5. Мониторинг и администрирование
|
|
||||||
|
|
||||||
#### Команды для администраторов
|
|
||||||
- `/ratelimit_stats` - Показать статистику rate limiting
|
|
||||||
- `/ratelimit_errors` - Показать недавние ошибки
|
|
||||||
- `/reset_ratelimit_stats` - Сбросить статистику
|
|
||||||
|
|
||||||
#### Пример вывода статистики
|
|
||||||
```
|
|
||||||
📊 Статистика Rate Limiting
|
|
||||||
|
|
||||||
🔢 Общая статистика:
|
|
||||||
• Всего запросов: 1250
|
|
||||||
• Процент успеха: 98.4%
|
|
||||||
• Процент ошибок: 1.6%
|
|
||||||
• Запросов в минуту: 12.5
|
|
||||||
• Среднее время ожидания: 1.2с
|
|
||||||
• Активных чатов: 45
|
|
||||||
• Ошибок за час: 3
|
|
||||||
|
|
||||||
🔍 Детальная статистика:
|
|
||||||
• Успешных запросов: 1230
|
|
||||||
• Неудачных запросов: 20
|
|
||||||
• RetryAfter ошибок: 15
|
|
||||||
• Других ошибок: 5
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Тестирование
|
|
||||||
|
|
||||||
Создан полный набор тестов в `test_rate_limiter.py`:
|
|
||||||
- Тесты всех компонентов
|
|
||||||
- Интеграционные тесты
|
|
||||||
- Тесты конфигурации
|
|
||||||
- Тесты мониторинга
|
|
||||||
|
|
||||||
Запуск тестов:
|
|
||||||
```bash
|
|
||||||
pytest tests/test_rate_limiter.py -v
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7. Преимущества решения
|
|
||||||
|
|
||||||
1. **Предотвращение ошибок**: Автоматическое соблюдение лимитов API
|
|
||||||
2. **Прозрачность**: Минимальные изменения в существующем коде
|
|
||||||
3. **Мониторинг**: Полная видимость производительности
|
|
||||||
4. **Адаптивность**: Автоматическая настройка под нагрузку
|
|
||||||
5. **Надежность**: Умная обработка ошибок и повторных попыток
|
|
||||||
6. **Масштабируемость**: Поддержка множества чатов
|
|
||||||
|
|
||||||
### 8. Рекомендации по использованию
|
|
||||||
|
|
||||||
1. **Мониторинг**: Регулярно проверяйте статистику через `/ratelimit_stats`
|
|
||||||
2. **Настройка**: При необходимости корректируйте конфигурацию под ваши нужды
|
|
||||||
3. **Алерты**: Настройте уведомления при высоком проценте ошибок
|
|
||||||
4. **Тестирование**: Проверяйте работу в тестовой среде перед продакшеном
|
|
||||||
|
|
||||||
### 9. Будущие улучшения
|
|
||||||
|
|
||||||
- Интеграция с системой метрик (Prometheus/Grafana)
|
|
||||||
- Автоматическое масштабирование ограничений
|
|
||||||
- A/B тестирование разных конфигураций
|
|
||||||
- Интеграция с системой алертов
|
|
||||||
@@ -9,18 +9,37 @@
|
|||||||
- async_db: основной класс AsyncBotDB
|
- async_db: основной класс AsyncBotDB
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from .async_db import AsyncBotDB
|
||||||
|
from .base import DatabaseConnection
|
||||||
from .models import (
|
from .models import (
|
||||||
User, BlacklistUser, UserMessage, TelegramPost, PostContent,
|
Admin,
|
||||||
MessageContentLink, Admin, Migration, AudioMessage, AudioListenRecord, AudioModerate
|
AudioListenRecord,
|
||||||
|
AudioMessage,
|
||||||
|
AudioModerate,
|
||||||
|
BlacklistUser,
|
||||||
|
MessageContentLink,
|
||||||
|
Migration,
|
||||||
|
PostContent,
|
||||||
|
TelegramPost,
|
||||||
|
User,
|
||||||
|
UserMessage,
|
||||||
)
|
)
|
||||||
from .repository_factory import RepositoryFactory
|
from .repository_factory import RepositoryFactory
|
||||||
from .base import DatabaseConnection
|
|
||||||
from .async_db import AsyncBotDB
|
|
||||||
|
|
||||||
# Для обратной совместимости экспортируем старый интерфейс
|
# Для обратной совместимости экспортируем старый интерфейс
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'User', 'BlacklistUser', 'UserMessage', 'TelegramPost', 'PostContent',
|
"User",
|
||||||
'MessageContentLink', 'Admin', 'Migration', 'AudioMessage', 'AudioListenRecord', 'AudioModerate',
|
"BlacklistUser",
|
||||||
'RepositoryFactory', 'DatabaseConnection', 'AsyncBotDB'
|
"UserMessage",
|
||||||
|
"TelegramPost",
|
||||||
|
"PostContent",
|
||||||
|
"MessageContentLink",
|
||||||
|
"Admin",
|
||||||
|
"Migration",
|
||||||
|
"AudioMessage",
|
||||||
|
"AudioListenRecord",
|
||||||
|
"AudioModerate",
|
||||||
|
"RepositoryFactory",
|
||||||
|
"DatabaseConnection",
|
||||||
|
"AsyncBotDB",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,359 +1,565 @@
|
|||||||
import aiosqlite
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, List, Dict, Any, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
from database.repository_factory import RepositoryFactory
|
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
from database.models import (
|
from database.models import (
|
||||||
User, BlacklistUser, UserMessage, TelegramPost, PostContent,
|
Admin,
|
||||||
Admin, AudioMessage
|
AudioMessage,
|
||||||
|
BlacklistHistoryRecord,
|
||||||
|
BlacklistUser,
|
||||||
|
PostContent,
|
||||||
|
TelegramPost,
|
||||||
|
User,
|
||||||
|
UserMessage,
|
||||||
)
|
)
|
||||||
|
from database.repository_factory import RepositoryFactory
|
||||||
|
|
||||||
|
|
||||||
class AsyncBotDB:
|
class AsyncBotDB:
|
||||||
"""Новый асинхронный класс для работы с базой данных с использованием репозиториев."""
|
"""Новый асинхронный класс для работы с базой данных с использованием репозиториев."""
|
||||||
|
|
||||||
def __init__(self, db_path: str):
|
def __init__(self, db_path: str):
|
||||||
self.factory = RepositoryFactory(db_path)
|
self.factory = RepositoryFactory(db_path)
|
||||||
self.logger = self.factory.users.logger
|
self.logger = self.factory.users.logger
|
||||||
|
|
||||||
async def create_tables(self):
|
async def create_tables(self):
|
||||||
"""Создание всех таблиц в базе данных."""
|
"""Создание всех таблиц в базе данных."""
|
||||||
await self.factory.create_all_tables()
|
await self.factory.create_all_tables()
|
||||||
self.logger.info("Все таблицы успешно созданы")
|
self.logger.info("Все таблицы успешно созданы")
|
||||||
|
|
||||||
# Методы для работы с пользователями
|
# Методы для работы с пользователями
|
||||||
async def user_exists(self, user_id: int) -> bool:
|
async def user_exists(self, user_id: int) -> bool:
|
||||||
"""Проверяет, существует ли пользователь в базе данных."""
|
"""Проверяет, существует ли пользователь в базе данных."""
|
||||||
return await self.factory.users.user_exists(user_id)
|
return await self.factory.users.user_exists(user_id)
|
||||||
|
|
||||||
async def add_user(self, user: User):
|
async def add_user(self, user: User):
|
||||||
"""Добавление нового пользователя."""
|
"""Добавление нового пользователя."""
|
||||||
await self.factory.users.add_user(user)
|
await self.factory.users.add_user(user)
|
||||||
|
|
||||||
async def get_user_info(self, user_id: int) -> Optional[Dict[str, Any]]:
|
async def get_user_info(self, user_id: int) -> Optional[Dict[str, Any]]:
|
||||||
"""Получение информации о пользователе."""
|
"""Получение информации о пользователе."""
|
||||||
user = await self.factory.users.get_user_info(user_id)
|
user = await self.factory.users.get_user_info(user_id)
|
||||||
if user:
|
if user:
|
||||||
return {
|
return {
|
||||||
'username': user.username,
|
"username": user.username,
|
||||||
'full_name': user.full_name,
|
"full_name": user.full_name,
|
||||||
'has_stickers': user.has_stickers,
|
"has_stickers": user.has_stickers,
|
||||||
'emoji': user.emoji
|
"emoji": user.emoji,
|
||||||
}
|
}
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_username(self, user_id: int) -> Optional[str]:
|
async def get_username(self, user_id: int) -> Optional[str]:
|
||||||
"""Возвращает username пользователя."""
|
"""Возвращает username пользователя."""
|
||||||
return await self.factory.users.get_username(user_id)
|
return await self.factory.users.get_username(user_id)
|
||||||
|
|
||||||
async def get_user_id_by_username(self, username: str) -> Optional[int]:
|
async def get_user_id_by_username(self, username: str) -> Optional[int]:
|
||||||
"""Возвращает user_id пользователя по username."""
|
"""Возвращает user_id пользователя по username."""
|
||||||
return await self.factory.users.get_user_id_by_username(username)
|
return await self.factory.users.get_user_id_by_username(username)
|
||||||
|
|
||||||
async def get_full_name_by_id(self, user_id: int) -> Optional[str]:
|
async def get_full_name_by_id(self, user_id: int) -> Optional[str]:
|
||||||
"""Возвращает full_name пользователя."""
|
"""Возвращает full_name пользователя."""
|
||||||
return await self.factory.users.get_full_name_by_id(user_id)
|
return await self.factory.users.get_full_name_by_id(user_id)
|
||||||
|
|
||||||
async def get_username_and_full_name(self, user_id: int) -> tuple[Optional[str], Optional[str]]:
|
async def get_username_and_full_name(
|
||||||
|
self, user_id: int
|
||||||
|
) -> tuple[Optional[str], Optional[str]]:
|
||||||
"""Возвращает username и full_name пользователя."""
|
"""Возвращает username и full_name пользователя."""
|
||||||
username = await self.get_username(user_id)
|
username = await self.get_username(user_id)
|
||||||
full_name = await self.get_full_name_by_id(user_id)
|
full_name = await self.get_full_name_by_id(user_id)
|
||||||
return username, full_name
|
return username, full_name
|
||||||
|
|
||||||
async def get_user_by_id(self, user_id: int) -> Optional[User]:
|
async def get_user_by_id(self, user_id: int) -> Optional[User]:
|
||||||
"""Получение пользователя по ID."""
|
"""Получение пользователя по ID."""
|
||||||
return await self.factory.users.get_user_by_id(user_id)
|
return await self.factory.users.get_user_by_id(user_id)
|
||||||
|
|
||||||
async def get_user_first_name(self, user_id: int) -> Optional[str]:
|
async def get_user_first_name(self, user_id: int) -> Optional[str]:
|
||||||
"""Возвращает first_name пользователя."""
|
"""Возвращает first_name пользователя."""
|
||||||
return await self.factory.users.get_user_first_name(user_id)
|
return await self.factory.users.get_user_first_name(user_id)
|
||||||
|
|
||||||
async def get_all_user_id(self) -> List[int]:
|
async def get_all_user_id(self) -> List[int]:
|
||||||
"""Возвращает список всех user_id."""
|
"""Возвращает список всех user_id."""
|
||||||
return await self.factory.users.get_all_user_ids()
|
return await self.factory.users.get_all_user_ids()
|
||||||
|
|
||||||
async def get_last_users(self, limit: int = 30) -> List[tuple]:
|
async def get_last_users(self, limit: int = 30) -> List[tuple]:
|
||||||
"""Получение последних пользователей."""
|
"""Получение последних пользователей."""
|
||||||
return await self.factory.users.get_last_users(limit)
|
return await self.factory.users.get_last_users(limit)
|
||||||
|
|
||||||
async def update_user_date(self, user_id: int):
|
async def update_user_date(self, user_id: int):
|
||||||
"""Обновление даты последнего изменения пользователя."""
|
"""Обновление даты последнего изменения пользователя."""
|
||||||
await self.factory.users.update_user_date(user_id)
|
await self.factory.users.update_user_date(user_id)
|
||||||
|
|
||||||
async def update_user_info(self, user_id: int, username: str = None, full_name: str = None):
|
async def update_user_info(
|
||||||
|
self, user_id: int, username: str = None, full_name: str = None
|
||||||
|
):
|
||||||
"""Обновление информации о пользователе."""
|
"""Обновление информации о пользователе."""
|
||||||
await self.factory.users.update_user_info(user_id, username, full_name)
|
await self.factory.users.update_user_info(user_id, username, full_name)
|
||||||
|
|
||||||
async def update_user_emoji(self, user_id: int, emoji: str):
|
async def update_user_emoji(self, user_id: int, emoji: str):
|
||||||
"""Обновление эмодзи пользователя."""
|
"""Обновление эмодзи пользователя."""
|
||||||
await self.factory.users.update_user_emoji(user_id, emoji)
|
await self.factory.users.update_user_emoji(user_id, emoji)
|
||||||
|
|
||||||
async def update_stickers_info(self, user_id: int):
|
async def update_stickers_info(self, user_id: int):
|
||||||
"""Обновление информации о стикерах."""
|
"""Обновление информации о стикерах."""
|
||||||
await self.factory.users.update_stickers_info(user_id)
|
await self.factory.users.update_stickers_info(user_id)
|
||||||
|
|
||||||
async def get_stickers_info(self, user_id: int) -> bool:
|
async def get_stickers_info(self, user_id: int) -> bool:
|
||||||
"""Получение информации о стикерах."""
|
"""Получение информации о стикерах."""
|
||||||
return await self.factory.users.get_stickers_info(user_id)
|
return await self.factory.users.get_stickers_info(user_id)
|
||||||
|
|
||||||
async def check_emoji_exists(self, emoji: str) -> bool:
|
async def check_emoji_exists(self, emoji: str) -> bool:
|
||||||
"""Проверка существования эмодзи."""
|
"""Проверка существования эмодзи."""
|
||||||
return await self.factory.users.check_emoji_exists(emoji)
|
return await self.factory.users.check_emoji_exists(emoji)
|
||||||
|
|
||||||
async def get_user_emoji(self, user_id: int) -> str:
|
async def get_user_emoji(self, user_id: int) -> str:
|
||||||
"""Получает эмодзи пользователя."""
|
"""Получает эмодзи пользователя."""
|
||||||
return await self.factory.users.get_user_emoji(user_id)
|
return await self.factory.users.get_user_emoji(user_id)
|
||||||
|
|
||||||
async def check_emoji_for_user(self, user_id: int) -> str:
|
async def check_emoji_for_user(self, user_id: int) -> str:
|
||||||
"""Проверяет, есть ли уже у пользователя назначенный emoji."""
|
"""Проверяет, есть ли уже у пользователя назначенный emoji."""
|
||||||
return await self.factory.users.check_emoji_for_user(user_id)
|
return await self.factory.users.check_emoji_for_user(user_id)
|
||||||
|
|
||||||
# Методы для работы с сообщениями
|
# Методы для работы с сообщениями
|
||||||
async def add_message(self, message_text: str, user_id: int, message_id: int, date: int = None):
|
async def add_message(
|
||||||
|
self, message_text: str, user_id: int, message_id: int, date: int = None
|
||||||
|
):
|
||||||
"""Добавление сообщения пользователя."""
|
"""Добавление сообщения пользователя."""
|
||||||
if date is None:
|
if date is None:
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
date = int(datetime.now().timestamp())
|
date = int(datetime.now().timestamp())
|
||||||
|
|
||||||
message = UserMessage(
|
message = UserMessage(
|
||||||
message_text=message_text,
|
message_text=message_text,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
telegram_message_id=message_id,
|
telegram_message_id=message_id,
|
||||||
date=date
|
date=date,
|
||||||
)
|
)
|
||||||
await self.factory.messages.add_message(message)
|
await self.factory.messages.add_message(message)
|
||||||
|
|
||||||
async def get_user_by_message_id(self, message_id: int) -> Optional[int]:
|
async def get_user_by_message_id(self, message_id: int) -> Optional[int]:
|
||||||
"""Получение пользователя по message_id."""
|
"""Получение пользователя по message_id."""
|
||||||
return await self.factory.messages.get_user_by_message_id(message_id)
|
return await self.factory.messages.get_user_by_message_id(message_id)
|
||||||
|
|
||||||
# Методы для работы с постами
|
# Методы для работы с постами
|
||||||
async def add_post(self, post: TelegramPost):
|
async def add_post(self, post: TelegramPost):
|
||||||
"""Добавление поста."""
|
"""Добавление поста."""
|
||||||
await self.factory.posts.add_post(post)
|
await self.factory.posts.add_post(post)
|
||||||
|
|
||||||
async def update_helper_message(self, message_id: int, helper_message_id: int):
|
async def update_helper_message(self, message_id: int, helper_message_id: int):
|
||||||
"""Обновление helper сообщения."""
|
"""Обновление helper сообщения."""
|
||||||
await self.factory.posts.update_helper_message(message_id, helper_message_id)
|
await self.factory.posts.update_helper_message(message_id, helper_message_id)
|
||||||
|
|
||||||
async def add_post_content(self, post_id: int, message_id: int, content_name: str, content_type: str):
|
async def add_post_content(
|
||||||
|
self, post_id: int, message_id: int, content_name: str, content_type: str
|
||||||
|
):
|
||||||
"""Добавление контента поста."""
|
"""Добавление контента поста."""
|
||||||
return await self.factory.posts.add_post_content(post_id, message_id, content_name, content_type)
|
return await self.factory.posts.add_post_content(
|
||||||
|
post_id, message_id, content_name, content_type
|
||||||
async def get_post_content_from_telegram_by_last_id(self, last_post_id: int) -> List[Tuple[str, str]]:
|
)
|
||||||
|
|
||||||
|
async def add_message_link(self, post_id: int, message_id: int) -> bool:
|
||||||
|
"""Добавляет связь между post_id и message_id в таблицу message_link_to_content."""
|
||||||
|
return await self.factory.posts.add_message_link(post_id, message_id)
|
||||||
|
|
||||||
|
async def get_post_content_from_telegram_by_last_id(
|
||||||
|
self, last_post_id: int
|
||||||
|
) -> List[Tuple[str, str]]:
|
||||||
"""Получает контент поста по helper_text_message_id."""
|
"""Получает контент поста по helper_text_message_id."""
|
||||||
return await self.factory.posts.get_post_content_by_helper_id(last_post_id)
|
return await self.factory.posts.get_post_content_by_helper_id(last_post_id)
|
||||||
|
|
||||||
async def get_post_text_from_telegram_by_last_id(self, last_post_id: int) -> Optional[str]:
|
async def get_post_content_by_helper_id(
|
||||||
|
self, helper_message_id: int
|
||||||
|
) -> List[Tuple[str, str]]:
|
||||||
|
"""Алиас для get_post_content_from_telegram_by_last_id (используется callback-сервисом)."""
|
||||||
|
return await self.get_post_content_from_telegram_by_last_id(helper_message_id)
|
||||||
|
|
||||||
|
async def get_post_content_by_message_id(
|
||||||
|
self, message_id: int
|
||||||
|
) -> List[Tuple[str, str]]:
|
||||||
|
"""Получает контент одиночного поста по message_id."""
|
||||||
|
return await self.factory.posts.get_post_content_by_message_id(message_id)
|
||||||
|
|
||||||
|
async def update_published_message_id(
|
||||||
|
self, original_message_id: int, published_message_id: int
|
||||||
|
):
|
||||||
|
"""Обновляет published_message_id для опубликованного поста."""
|
||||||
|
await self.factory.posts.update_published_message_id(
|
||||||
|
original_message_id, published_message_id
|
||||||
|
)
|
||||||
|
|
||||||
|
async def add_published_post_content(
|
||||||
|
self, published_message_id: int, content_path: str, content_type: str
|
||||||
|
):
|
||||||
|
"""Добавляет контент опубликованного поста."""
|
||||||
|
return await self.factory.posts.add_published_post_content(
|
||||||
|
published_message_id, content_path, content_type
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_published_post_content(
|
||||||
|
self, published_message_id: int
|
||||||
|
) -> List[Tuple[str, str]]:
|
||||||
|
"""Получает контент опубликованного поста."""
|
||||||
|
return await self.factory.posts.get_published_post_content(published_message_id)
|
||||||
|
|
||||||
|
async def get_post_text_from_telegram_by_last_id(
|
||||||
|
self, last_post_id: int
|
||||||
|
) -> Optional[str]:
|
||||||
"""Получает текст поста по helper_text_message_id."""
|
"""Получает текст поста по helper_text_message_id."""
|
||||||
return await self.factory.posts.get_post_text_by_helper_id(last_post_id)
|
return await self.factory.posts.get_post_text_by_helper_id(last_post_id)
|
||||||
|
|
||||||
async def get_post_ids_from_telegram_by_last_id(self, last_post_id: int) -> List[int]:
|
async def get_post_text_by_helper_id(self, helper_message_id: int) -> Optional[str]:
|
||||||
|
"""Алиас для get_post_text_from_telegram_by_last_id (используется callback-сервисом)."""
|
||||||
|
return await self.get_post_text_from_telegram_by_last_id(helper_message_id)
|
||||||
|
|
||||||
|
async def get_post_ids_from_telegram_by_last_id(
|
||||||
|
self, last_post_id: int
|
||||||
|
) -> List[int]:
|
||||||
"""Получает ID сообщений по helper_text_message_id."""
|
"""Получает ID сообщений по helper_text_message_id."""
|
||||||
return await self.factory.posts.get_post_ids_by_helper_id(last_post_id)
|
return await self.factory.posts.get_post_ids_by_helper_id(last_post_id)
|
||||||
|
|
||||||
|
async def get_post_ids_by_helper_id(self, helper_message_id: int) -> List[int]:
|
||||||
|
"""Алиас для get_post_ids_from_telegram_by_last_id (используется callback-сервисом)."""
|
||||||
|
return await self.get_post_ids_from_telegram_by_last_id(helper_message_id)
|
||||||
|
|
||||||
async def get_author_id_by_message_id(self, message_id: int) -> Optional[int]:
|
async def get_author_id_by_message_id(self, message_id: int) -> Optional[int]:
|
||||||
"""Получает ID автора по message_id."""
|
"""Получает ID автора по message_id."""
|
||||||
return await self.factory.posts.get_author_id_by_message_id(message_id)
|
return await self.factory.posts.get_author_id_by_message_id(message_id)
|
||||||
|
|
||||||
async def get_author_id_by_helper_message_id(self, helper_text_message_id: int) -> Optional[int]:
|
async def get_author_id_by_helper_message_id(
|
||||||
|
self, helper_text_message_id: int
|
||||||
|
) -> Optional[int]:
|
||||||
"""Получает ID автора по helper_text_message_id."""
|
"""Получает ID автора по helper_text_message_id."""
|
||||||
return await self.factory.posts.get_author_id_by_helper_message_id(helper_text_message_id)
|
return await self.factory.posts.get_author_id_by_helper_message_id(
|
||||||
|
helper_text_message_id
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_post_text_and_anonymity_by_message_id(
|
||||||
|
self, message_id: int
|
||||||
|
) -> tuple[Optional[str], Optional[bool]]:
|
||||||
|
"""Получает текст и is_anonymous поста по message_id."""
|
||||||
|
return await self.factory.posts.get_post_text_and_anonymity_by_message_id(
|
||||||
|
message_id
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_post_text_and_anonymity_by_helper_id(
|
||||||
|
self, helper_message_id: int
|
||||||
|
) -> tuple[Optional[str], Optional[bool]]:
|
||||||
|
"""Получает текст и is_anonymous поста по helper_text_message_id."""
|
||||||
|
return await self.factory.posts.get_post_text_and_anonymity_by_helper_id(
|
||||||
|
helper_message_id
|
||||||
|
)
|
||||||
|
|
||||||
|
async def update_status_by_message_id(self, message_id: int, status: str) -> int:
|
||||||
|
"""Обновление статуса поста по message_id (одиночные посты). Возвращает число обновлённых строк."""
|
||||||
|
return await self.factory.posts.update_status_by_message_id(message_id, status)
|
||||||
|
|
||||||
|
async def update_status_for_media_group_by_helper_id(
|
||||||
|
self, helper_message_id: int, status: str
|
||||||
|
) -> int:
|
||||||
|
"""Обновление статуса постов медиагруппы по helper_message_id. Возвращает число обновлённых строк."""
|
||||||
|
return await self.factory.posts.update_status_for_media_group_by_helper_id(
|
||||||
|
helper_message_id, status
|
||||||
|
)
|
||||||
|
|
||||||
|
# Методы для ML Scoring
|
||||||
|
async def get_post_text_by_message_id(self, message_id: int) -> Optional[str]:
|
||||||
|
"""Получает текст поста по message_id."""
|
||||||
|
return await self.factory.posts.get_post_text_by_message_id(message_id)
|
||||||
|
|
||||||
|
async def update_ml_scores(self, message_id: int, ml_scores_json: str) -> bool:
|
||||||
|
"""Обновляет ML-скоры для поста."""
|
||||||
|
return await self.factory.posts.update_ml_scores(message_id, ml_scores_json)
|
||||||
|
|
||||||
|
async def get_approved_posts_texts(self, limit: int = 1000) -> List[str]:
|
||||||
|
"""Получает тексты одобренных постов для обучения RAG."""
|
||||||
|
return await self.factory.posts.get_approved_posts_texts(limit)
|
||||||
|
|
||||||
|
async def get_declined_posts_texts(self, limit: int = 1000) -> List[str]:
|
||||||
|
"""Получает тексты отклоненных постов для обучения 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, user_id: int, user_name: str = None,
|
async def set_user_blacklist(
|
||||||
message_for_user: str = None, date_to_unban: int = None):
|
self,
|
||||||
"""Добавляет пользователя в черный список."""
|
user_id: int,
|
||||||
|
user_name: str = None,
|
||||||
|
message_for_user: str = None,
|
||||||
|
date_to_unban: int = None,
|
||||||
|
ban_author: Optional[int] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Добавляет пользователя в черный список.
|
||||||
|
Также создает запись в истории банов для отслеживания.
|
||||||
|
"""
|
||||||
blacklist_user = BlacklistUser(
|
blacklist_user = BlacklistUser(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
message_for_user=message_for_user,
|
message_for_user=message_for_user,
|
||||||
date_to_unban=date_to_unban
|
date_to_unban=date_to_unban,
|
||||||
|
ban_author=ban_author,
|
||||||
)
|
)
|
||||||
await self.factory.blacklist.add_user(blacklist_user)
|
await self.factory.blacklist.add_user(blacklist_user)
|
||||||
|
|
||||||
|
# Логируем в историю банов
|
||||||
|
try:
|
||||||
|
date_ban = int(datetime.now().timestamp())
|
||||||
|
history_record = BlacklistHistoryRecord(
|
||||||
|
user_id=user_id,
|
||||||
|
message_for_user=message_for_user,
|
||||||
|
date_ban=date_ban,
|
||||||
|
date_unban=None, # Будет установлено при разбане
|
||||||
|
ban_author=ban_author,
|
||||||
|
)
|
||||||
|
await self.factory.blacklist_history.add_record_on_ban(history_record)
|
||||||
|
except Exception as e:
|
||||||
|
# Ошибка записи в историю не должна ломать процесс бана
|
||||||
|
self.logger.error(
|
||||||
|
f"Ошибка записи в историю банов для user_id={user_id}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
async def delete_user_blacklist(self, user_id: int) -> bool:
|
async def delete_user_blacklist(self, user_id: int) -> bool:
|
||||||
"""Удаляет пользователя из черного списка."""
|
"""
|
||||||
|
Удаляет пользователя из черного списка.
|
||||||
|
Также обновляет запись в истории банов, устанавливая date_unban.
|
||||||
|
"""
|
||||||
|
# Сначала обновляем историю (если есть открытая запись)
|
||||||
|
try:
|
||||||
|
date_unban = int(datetime.now().timestamp())
|
||||||
|
await self.factory.blacklist_history.set_unban_date(user_id, date_unban)
|
||||||
|
except Exception as e:
|
||||||
|
# Ошибка записи в историю не должна ломать критический путь разбана
|
||||||
|
self.logger.error(
|
||||||
|
f"Ошибка обновления истории при разбане для user_id={user_id}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Удаляем из черного списка (критический путь)
|
||||||
return await self.factory.blacklist.remove_user(user_id)
|
return await self.factory.blacklist.remove_user(user_id)
|
||||||
|
|
||||||
async def check_user_in_blacklist(self, user_id: int) -> bool:
|
async def check_user_in_blacklist(self, user_id: int) -> bool:
|
||||||
"""Проверяет, существует ли запись с данным user_id в blacklist."""
|
"""Проверяет, существует ли запись с данным user_id в blacklist."""
|
||||||
return await self.factory.blacklist.user_exists(user_id)
|
return await self.factory.blacklist.user_exists(user_id)
|
||||||
|
|
||||||
async def get_blacklist_users(self, offset: int = 0, limit: int = 10) -> List[tuple]:
|
async def get_blacklist_users(
|
||||||
|
self, offset: int = 0, limit: int = 10
|
||||||
|
) -> List[tuple]:
|
||||||
"""Получение пользователей из черного списка."""
|
"""Получение пользователей из черного списка."""
|
||||||
users = await self.factory.blacklist.get_all_users(offset, limit)
|
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]
|
return [
|
||||||
|
(user.user_id, user.message_for_user, user.date_to_unban) for user in users
|
||||||
|
]
|
||||||
|
|
||||||
async def get_banned_users_from_db(self) -> List[tuple]:
|
async def get_banned_users_from_db(self) -> List[tuple]:
|
||||||
"""Возвращает список пользователей в черном списке."""
|
"""Возвращает список пользователей в черном списке."""
|
||||||
users = await self.factory.blacklist.get_all_users_no_limit()
|
users = await self.factory.blacklist.get_all_users_no_limit()
|
||||||
return [(user.user_id, user.message_for_user, user.date_to_unban) for user in users]
|
return [
|
||||||
|
(user.user_id, user.message_for_user, user.date_to_unban) for user in users
|
||||||
async def get_banned_users_from_db_with_limits(self, offset: int, limit: int) -> List[tuple]:
|
]
|
||||||
|
|
||||||
|
async def get_banned_users_from_db_with_limits(
|
||||||
|
self, offset: int, limit: int
|
||||||
|
) -> List[tuple]:
|
||||||
"""Возвращает список пользователей в черном списке с учетом смещения и ограничения."""
|
"""Возвращает список пользователей в черном списке с учетом смещения и ограничения."""
|
||||||
users = await self.factory.blacklist.get_all_users(offset, limit)
|
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]
|
return [
|
||||||
|
(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]:
|
||||||
"""Возвращает информацию о пользователе в черном списке по user_id."""
|
"""Возвращает информацию о пользователе в черном списке по user_id."""
|
||||||
user = await self.factory.blacklist.get_user(user_id)
|
user = await self.factory.blacklist.get_user(user_id)
|
||||||
if user:
|
if user:
|
||||||
return (user.user_id, user.message_for_user, user.date_to_unban)
|
return (user.user_id, user.message_for_user, user.date_to_unban)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_blacklist_count(self) -> int:
|
async def get_blacklist_count(self) -> int:
|
||||||
"""Получение количества пользователей в черном списке."""
|
"""Получение количества пользователей в черном списке."""
|
||||||
return await self.factory.blacklist.get_count()
|
return await self.factory.blacklist.get_count()
|
||||||
|
|
||||||
async def get_users_for_unblock_today(self, current_timestamp: int) -> Dict[int, int]:
|
async def get_users_for_unblock_today(
|
||||||
|
self, current_timestamp: int
|
||||||
|
) -> Dict[int, int]:
|
||||||
"""Возвращает список пользователей, у которых истек срок блокировки."""
|
"""Возвращает список пользователей, у которых истек срок блокировки."""
|
||||||
return await self.factory.blacklist.get_users_for_unblock_today(current_timestamp)
|
return await self.factory.blacklist.get_users_for_unblock_today(
|
||||||
|
current_timestamp
|
||||||
|
)
|
||||||
|
|
||||||
# Методы для работы с администраторами
|
# Методы для работы с администраторами
|
||||||
async def add_admin(self, user_id: int, role: str = "admin"):
|
async def add_admin(self, user_id: int, role: str = "admin"):
|
||||||
"""Добавление администратора."""
|
"""Добавление администратора."""
|
||||||
admin = Admin(user_id=user_id, role=role)
|
admin = Admin(user_id=user_id, role=role)
|
||||||
await self.factory.admins.add_admin(admin)
|
await self.factory.admins.add_admin(admin)
|
||||||
|
|
||||||
async def remove_admin(self, user_id: int):
|
async def remove_admin(self, user_id: int):
|
||||||
"""Удаление администратора."""
|
"""Удаление администратора."""
|
||||||
await self.factory.admins.remove_admin(user_id)
|
await self.factory.admins.remove_admin(user_id)
|
||||||
|
|
||||||
async def is_admin(self, user_id: int) -> bool:
|
async def is_admin(self, user_id: int) -> bool:
|
||||||
"""Проверка, является ли пользователь администратором."""
|
"""Проверка, является ли пользователь администратором."""
|
||||||
return await self.factory.admins.is_admin(user_id)
|
return await self.factory.admins.is_admin(user_id)
|
||||||
|
|
||||||
async def get_all_admins(self) -> list[Admin]:
|
async def get_all_admins(self) -> list[Admin]:
|
||||||
"""Получение всех администраторов."""
|
"""Получение всех администраторов."""
|
||||||
return await self.factory.admins.get_all_admins()
|
return await self.factory.admins.get_all_admins()
|
||||||
|
|
||||||
# Методы для работы с аудио
|
# Методы для работы с аудио
|
||||||
async def add_audio_record(self, file_name: str, author_id: int, date_added: str,
|
async def add_audio_record(
|
||||||
listen_count: int, file_id: str):
|
self,
|
||||||
|
file_name: str,
|
||||||
|
author_id: int,
|
||||||
|
date_added: str,
|
||||||
|
listen_count: int,
|
||||||
|
file_id: str,
|
||||||
|
):
|
||||||
"""Добавляет информацию о войсе пользователя."""
|
"""Добавляет информацию о войсе пользователя."""
|
||||||
audio = AudioMessage(
|
audio = AudioMessage(
|
||||||
file_name=file_name,
|
file_name=file_name,
|
||||||
author_id=author_id,
|
author_id=author_id,
|
||||||
date_added=date_added,
|
date_added=date_added,
|
||||||
listen_count=listen_count,
|
listen_count=listen_count,
|
||||||
file_id=file_id
|
file_id=file_id,
|
||||||
)
|
)
|
||||||
await self.factory.audio.add_audio_record(audio)
|
await self.factory.audio.add_audio_record(audio)
|
||||||
|
|
||||||
async def add_audio_record_simple(self, file_name: str, user_id: int, date_added) -> None:
|
async def add_audio_record_simple(
|
||||||
|
self, file_name: str, user_id: int, date_added
|
||||||
|
) -> None:
|
||||||
"""Добавляет простую запись об аудио файле."""
|
"""Добавляет простую запись об аудио файле."""
|
||||||
await self.factory.audio.add_audio_record_simple(file_name, user_id, date_added)
|
await self.factory.audio.add_audio_record_simple(file_name, user_id, date_added)
|
||||||
|
|
||||||
async def last_date_audio(self) -> Optional[str]:
|
async def last_date_audio(self) -> Optional[str]:
|
||||||
"""Получает дату последнего войса."""
|
"""Получает дату последнего войса."""
|
||||||
return await self.factory.audio.get_last_date_audio()
|
return await self.factory.audio.get_last_date_audio()
|
||||||
|
|
||||||
async def get_last_user_audio_record(self, user_id: int) -> bool:
|
async def get_last_user_audio_record(self, user_id: int) -> bool:
|
||||||
"""Получает данные о количестве записей пользователя."""
|
"""Получает данные о количестве записей пользователя."""
|
||||||
count = await self.factory.audio.get_user_audio_records_count(user_id)
|
count = await self.factory.audio.get_user_audio_records_count(user_id)
|
||||||
return bool(count)
|
return bool(count)
|
||||||
|
|
||||||
async def get_path_for_audio_record(self, user_id: int) -> Optional[str]:
|
async def get_path_for_audio_record(self, user_id: int) -> Optional[str]:
|
||||||
"""Получает данные о названии файла."""
|
"""Получает данные о названии файла."""
|
||||||
return await self.factory.audio.get_path_for_audio_record(user_id)
|
return await self.factory.audio.get_path_for_audio_record(user_id)
|
||||||
|
|
||||||
async def check_listen_audio(self, user_id: int) -> List[str]:
|
async def check_listen_audio(self, user_id: int) -> List[str]:
|
||||||
"""Проверяет прослушано ли аудио пользователем."""
|
"""Проверяет прослушано ли аудио пользователем."""
|
||||||
return await self.factory.audio.check_listen_audio(user_id)
|
return await self.factory.audio.check_listen_audio(user_id)
|
||||||
|
|
||||||
async def mark_listened_audio(self, file_name: str, user_id: int):
|
async def mark_listened_audio(self, file_name: str, user_id: int):
|
||||||
"""Отмечает аудио прослушанным для конкретного пользователя."""
|
"""Отмечает аудио прослушанным для конкретного пользователя."""
|
||||||
await self.factory.audio.mark_listened_audio(file_name, user_id)
|
await self.factory.audio.mark_listened_audio(file_name, user_id)
|
||||||
|
|
||||||
async def get_id_for_audio_record(self, user_id: int) -> int:
|
async def get_id_for_audio_record(self, user_id: int) -> int:
|
||||||
"""Получает следующий номер аудио сообщения пользователя."""
|
"""Получает следующий номер аудио сообщения пользователя."""
|
||||||
return await self.factory.audio.get_user_audio_records_count(user_id)
|
return await self.factory.audio.get_user_audio_records_count(user_id)
|
||||||
|
|
||||||
async def get_user_audio_records_count(self, user_id: int) -> int:
|
async def get_user_audio_records_count(self, user_id: int) -> int:
|
||||||
"""Получает количество аудио записей пользователя."""
|
"""Получает количество аудио записей пользователя."""
|
||||||
return await self.factory.audio.get_user_audio_records_count(user_id)
|
return await self.factory.audio.get_user_audio_records_count(user_id)
|
||||||
|
|
||||||
async def refresh_listen_audio(self, user_id: int):
|
async def refresh_listen_audio(self, user_id: int):
|
||||||
"""Очищает всю информацию о прослушанных аудио пользователем."""
|
"""Очищает всю информацию о прослушанных аудио пользователем."""
|
||||||
await self.factory.audio.refresh_listen_audio(user_id)
|
await self.factory.audio.refresh_listen_audio(user_id)
|
||||||
|
|
||||||
async def delete_listen_count_for_user(self, user_id: int):
|
async def delete_listen_count_for_user(self, user_id: int):
|
||||||
"""Удаляет данные о прослушанных пользователем аудио."""
|
"""Удаляет данные о прослушанных пользователем аудио."""
|
||||||
await self.factory.audio.delete_listen_count_for_user(user_id)
|
await self.factory.audio.delete_listen_count_for_user(user_id)
|
||||||
|
|
||||||
async def get_user_id_by_file_name(self, file_name: str) -> Optional[int]:
|
async def get_user_id_by_file_name(self, file_name: str) -> Optional[int]:
|
||||||
"""Получает user_id пользователя по имени файла."""
|
"""Получает user_id пользователя по имени файла."""
|
||||||
return await self.factory.audio.get_user_id_by_file_name(file_name)
|
return await self.factory.audio.get_user_id_by_file_name(file_name)
|
||||||
|
|
||||||
async def get_date_by_file_name(self, file_name: str) -> Optional[str]:
|
async def get_date_by_file_name(self, file_name: str) -> Optional[str]:
|
||||||
"""Получает дату добавления файла."""
|
"""Получает дату добавления файла."""
|
||||||
return await self.factory.audio.get_date_by_file_name(file_name)
|
return await self.factory.audio.get_date_by_file_name(file_name)
|
||||||
|
|
||||||
# Методы для voice bot
|
# Методы для voice bot
|
||||||
async def set_user_id_and_message_id_for_voice_bot(self, message_id: int, user_id: int) -> bool:
|
async def set_user_id_and_message_id_for_voice_bot(
|
||||||
|
self, message_id: int, user_id: int
|
||||||
|
) -> bool:
|
||||||
"""Устанавливает связь между message_id и user_id для voice bot."""
|
"""Устанавливает связь между message_id и user_id для voice bot."""
|
||||||
return await self.factory.audio.set_user_id_and_message_id_for_voice_bot(message_id, user_id)
|
return await self.factory.audio.set_user_id_and_message_id_for_voice_bot(
|
||||||
|
message_id, user_id
|
||||||
async def get_user_id_by_message_id_for_voice_bot(self, message_id: int) -> Optional[int]:
|
)
|
||||||
|
|
||||||
|
async def get_user_id_by_message_id_for_voice_bot(
|
||||||
|
self, message_id: int
|
||||||
|
) -> Optional[int]:
|
||||||
"""Получает user_id пользователя по message_id для voice bot."""
|
"""Получает user_id пользователя по message_id для voice bot."""
|
||||||
return await self.factory.audio.get_user_id_by_message_id_for_voice_bot(message_id)
|
return await self.factory.audio.get_user_id_by_message_id_for_voice_bot(
|
||||||
|
message_id
|
||||||
|
)
|
||||||
|
|
||||||
async def delete_audio_moderate_record(self, message_id: int) -> None:
|
async def delete_audio_moderate_record(self, message_id: int) -> None:
|
||||||
"""Удаляет запись из таблицы audio_moderate по message_id."""
|
"""Удаляет запись из таблицы audio_moderate по message_id."""
|
||||||
await self.factory.audio.delete_audio_moderate_record(message_id)
|
await self.factory.audio.delete_audio_moderate_record(message_id)
|
||||||
|
|
||||||
async def get_all_audio_records(self) -> List[Dict[str, Any]]:
|
async def get_all_audio_records(self) -> List[Dict[str, Any]]:
|
||||||
"""Получить все записи аудио сообщений."""
|
"""Получить все записи аудио сообщений."""
|
||||||
return await self.factory.audio.get_all_audio_records()
|
return await self.factory.audio.get_all_audio_records()
|
||||||
|
|
||||||
async def delete_audio_record_by_file_name(self, file_name: str) -> None:
|
async def delete_audio_record_by_file_name(self, file_name: str) -> None:
|
||||||
"""Удалить запись аудио сообщения по имени файла."""
|
"""Удалить запись аудио сообщения по имени файла."""
|
||||||
await self.factory.audio.delete_audio_record_by_file_name(file_name)
|
await self.factory.audio.delete_audio_record_by_file_name(file_name)
|
||||||
|
|
||||||
# Методы для миграций
|
# Методы для миграций
|
||||||
async def get_migration_version(self) -> int:
|
|
||||||
"""Получение текущей версии миграции."""
|
|
||||||
return await self.factory.migrations.get_migration_version()
|
|
||||||
|
|
||||||
async def get_current_version(self) -> Optional[int]:
|
|
||||||
"""Возвращает текущую последнюю версию миграции."""
|
|
||||||
return await self.factory.migrations.get_current_version()
|
|
||||||
|
|
||||||
async def update_version(self, new_version: int, script_name: str):
|
|
||||||
"""Обновляет версию миграций в таблице migrations."""
|
|
||||||
await self.factory.migrations.update_version(new_version, script_name)
|
|
||||||
|
|
||||||
async def create_table(self, sql_script: str):
|
async def create_table(self, sql_script: str):
|
||||||
"""Создает таблицу в базе. Используется в миграциях."""
|
"""Создает таблицу в базе. Используется в миграциях."""
|
||||||
await self.factory.migrations.create_table(sql_script)
|
await self.factory.migrations.create_table_from_sql(sql_script)
|
||||||
|
|
||||||
async def update_migration_version(self, version: int, script_name: str):
|
|
||||||
"""Обновление версии миграции."""
|
|
||||||
await self.factory.migrations.update_version(version, script_name)
|
|
||||||
|
|
||||||
# Методы для voice bot welcome tracking
|
# Методы для voice bot welcome tracking
|
||||||
async def check_voice_bot_welcome_received(self, user_id: int) -> bool:
|
async def check_voice_bot_welcome_received(self, user_id: int) -> bool:
|
||||||
"""Проверяет, получал ли пользователь приветственное сообщение от voice_bot."""
|
"""Проверяет, получал ли пользователь приветственное сообщение от voice_bot."""
|
||||||
return await self.factory.users.check_voice_bot_welcome_received(user_id)
|
return await self.factory.users.check_voice_bot_welcome_received(user_id)
|
||||||
|
|
||||||
async def mark_voice_bot_welcome_received(self, user_id: int) -> bool:
|
async def mark_voice_bot_welcome_received(self, user_id: int) -> bool:
|
||||||
"""Отмечает, что пользователь получил приветственное сообщение от voice_bot."""
|
"""Отмечает, что пользователь получил приветственное сообщение от voice_bot."""
|
||||||
return await self.factory.users.mark_voice_bot_welcome_received(user_id)
|
return await self.factory.users.mark_voice_bot_welcome_received(user_id)
|
||||||
|
|
||||||
# Методы для проверки целостности
|
# Методы для проверки целостности
|
||||||
async def check_database_integrity(self):
|
async def check_database_integrity(self):
|
||||||
"""Проверяет целостность базы данных и очищает WAL файлы."""
|
"""Проверяет целостность базы данных и очищает WAL файлы."""
|
||||||
await self.factory.check_database_integrity()
|
await self.factory.check_database_integrity()
|
||||||
|
|
||||||
async def cleanup_wal_files(self):
|
async def cleanup_wal_files(self):
|
||||||
"""Очищает WAL файлы и переключает на DELETE режим для предотвращения проблем с I/O."""
|
"""Очищает WAL файлы и переключает на DELETE режим для предотвращения проблем с I/O."""
|
||||||
await self.factory.cleanup_wal_files()
|
await self.factory.cleanup_wal_files()
|
||||||
|
|
||||||
async def close(self):
|
async def close(self):
|
||||||
"""Закрытие соединений."""
|
"""Закрытие соединений."""
|
||||||
# Соединения закрываются в каждом методе
|
# Соединения закрываются в каждом методе
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def fetch_one(self, query: str, params: tuple = ()) -> Optional[Dict[str, Any]]:
|
async def fetch_one(
|
||||||
|
self, query: str, params: tuple = ()
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
"""Выполняет SQL запрос и возвращает один результат."""
|
"""Выполняет SQL запрос и возвращает один результат."""
|
||||||
try:
|
try:
|
||||||
async with aiosqlite.connect(self.factory.db_path) as conn:
|
async with aiosqlite.connect(self.factory.db_path) as conn:
|
||||||
@@ -366,3 +572,32 @@ class AsyncBotDB:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error executing query: {e}")
|
self.logger.error(f"Error executing query: {e}")
|
||||||
return None
|
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()
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
import os
|
import os
|
||||||
import aiosqlite
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
from logs.custom_logger import logger
|
from logs.custom_logger import logger
|
||||||
|
|
||||||
|
|
||||||
class DatabaseConnection:
|
class DatabaseConnection:
|
||||||
"""Базовый класс для работы с базой данных."""
|
"""Базовый класс для работы с базой данных."""
|
||||||
|
|
||||||
def __init__(self, db_path: str):
|
def __init__(self, db_path: str):
|
||||||
self.db_path = os.path.abspath(db_path)
|
self.db_path = os.path.abspath(db_path)
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
self.logger.info(f'Инициация базы данных: {self.db_path}')
|
self.logger.info(f"Инициация базы данных: {self.db_path}")
|
||||||
|
|
||||||
async def _get_connection(self):
|
async def _get_connection(self):
|
||||||
"""Получение асинхронного соединения с базой данных."""
|
"""Получение асинхронного соединения с базой данных."""
|
||||||
try:
|
try:
|
||||||
@@ -27,7 +29,7 @@ class DatabaseConnection:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Ошибка при получении соединения: {e}")
|
self.logger.error(f"Ошибка при получении соединения: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def _execute_query(self, query: str, params: tuple = ()):
|
async def _execute_query(self, query: str, params: tuple = ()):
|
||||||
"""Выполнение запроса с автоматическим закрытием соединения."""
|
"""Выполнение запроса с автоматическим закрытием соединения."""
|
||||||
conn = None
|
conn = None
|
||||||
@@ -42,7 +44,7 @@ class DatabaseConnection:
|
|||||||
finally:
|
finally:
|
||||||
if conn:
|
if conn:
|
||||||
await conn.close()
|
await conn.close()
|
||||||
|
|
||||||
async def _execute_query_with_result(self, query: str, params: tuple = ()):
|
async def _execute_query_with_result(self, query: str, params: tuple = ()):
|
||||||
"""Выполнение запроса с результатом и автоматическим закрытием соединения."""
|
"""Выполнение запроса с результатом и автоматическим закрытием соединения."""
|
||||||
conn = None
|
conn = None
|
||||||
@@ -58,7 +60,7 @@ class DatabaseConnection:
|
|||||||
finally:
|
finally:
|
||||||
if conn:
|
if conn:
|
||||||
await conn.close()
|
await conn.close()
|
||||||
|
|
||||||
async def _execute_transaction(self, queries: list):
|
async def _execute_transaction(self, queries: list):
|
||||||
"""Выполнение транзакции с несколькими запросами."""
|
"""Выполнение транзакции с несколькими запросами."""
|
||||||
conn = None
|
conn = None
|
||||||
@@ -75,7 +77,7 @@ class DatabaseConnection:
|
|||||||
finally:
|
finally:
|
||||||
if conn:
|
if conn:
|
||||||
await conn.close()
|
await conn.close()
|
||||||
|
|
||||||
async def check_database_integrity(self):
|
async def check_database_integrity(self):
|
||||||
"""Проверяет целостность базы данных и очищает WAL файлы."""
|
"""Проверяет целостность базы данных и очищает WAL файлы."""
|
||||||
conn = None
|
conn = None
|
||||||
@@ -83,14 +85,16 @@ class DatabaseConnection:
|
|||||||
conn = await self._get_connection()
|
conn = await self._get_connection()
|
||||||
result = await conn.execute("PRAGMA integrity_check")
|
result = await conn.execute("PRAGMA integrity_check")
|
||||||
integrity_result = await result.fetchone()
|
integrity_result = await result.fetchone()
|
||||||
|
|
||||||
if integrity_result and integrity_result[0] == "ok":
|
if integrity_result and integrity_result[0] == "ok":
|
||||||
self.logger.info("Проверка целостности базы данных прошла успешно")
|
self.logger.info("Проверка целостности базы данных прошла успешно")
|
||||||
await conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
await conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||||
self.logger.info("WAL файлы очищены")
|
self.logger.info("WAL файлы очищены")
|
||||||
else:
|
else:
|
||||||
self.logger.warning(f"Проблемы с целостностью базы данных: {integrity_result}")
|
self.logger.warning(
|
||||||
|
f"Проблемы с целостностью базы данных: {integrity_result}"
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Ошибка при проверке целостности базы данных: {e}")
|
self.logger.error(f"Ошибка при проверке целостности базы данных: {e}")
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, List
|
from typing import List, Optional
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class User:
|
class User:
|
||||||
"""Модель пользователя."""
|
"""Модель пользователя."""
|
||||||
|
|
||||||
user_id: int
|
user_id: int
|
||||||
first_name: str
|
first_name: str
|
||||||
full_name: str
|
full_name: str
|
||||||
@@ -22,15 +23,32 @@ class User:
|
|||||||
@dataclass
|
@dataclass
|
||||||
class BlacklistUser:
|
class BlacklistUser:
|
||||||
"""Модель пользователя в черном списке."""
|
"""Модель пользователя в черном списке."""
|
||||||
|
|
||||||
user_id: int
|
user_id: int
|
||||||
message_for_user: Optional[str] = None
|
message_for_user: Optional[str] = None
|
||||||
date_to_unban: Optional[int] = None
|
date_to_unban: Optional[int] = None
|
||||||
created_at: Optional[int] = None
|
created_at: Optional[int] = None
|
||||||
|
ban_author: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BlacklistHistoryRecord:
|
||||||
|
"""Модель записи истории банов/разбанов."""
|
||||||
|
|
||||||
|
user_id: int
|
||||||
|
message_for_user: Optional[str] = None
|
||||||
|
date_ban: int = 0
|
||||||
|
date_unban: Optional[int] = None
|
||||||
|
ban_author: Optional[int] = None
|
||||||
|
id: Optional[int] = None
|
||||||
|
created_at: Optional[int] = None
|
||||||
|
updated_at: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class UserMessage:
|
class UserMessage:
|
||||||
"""Модель сообщения пользователя."""
|
"""Модель сообщения пользователя."""
|
||||||
|
|
||||||
message_text: str
|
message_text: str
|
||||||
user_id: int
|
user_id: int
|
||||||
telegram_message_id: int
|
telegram_message_id: int
|
||||||
@@ -40,16 +58,20 @@ class UserMessage:
|
|||||||
@dataclass
|
@dataclass
|
||||||
class TelegramPost:
|
class TelegramPost:
|
||||||
"""Модель поста из Telegram."""
|
"""Модель поста из Telegram."""
|
||||||
|
|
||||||
message_id: int
|
message_id: int
|
||||||
text: str
|
text: str
|
||||||
author_id: int
|
author_id: int
|
||||||
helper_text_message_id: Optional[int] = None
|
helper_text_message_id: Optional[int] = None
|
||||||
created_at: Optional[int] = None
|
created_at: Optional[int] = None
|
||||||
|
status: str = "suggest"
|
||||||
|
is_anonymous: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PostContent:
|
class PostContent:
|
||||||
"""Модель контента поста."""
|
"""Модель контента поста."""
|
||||||
|
|
||||||
message_id: int
|
message_id: int
|
||||||
content_name: str
|
content_name: str
|
||||||
content_type: str
|
content_type: str
|
||||||
@@ -58,6 +80,7 @@ class PostContent:
|
|||||||
@dataclass
|
@dataclass
|
||||||
class MessageContentLink:
|
class MessageContentLink:
|
||||||
"""Модель связи сообщения с контентом."""
|
"""Модель связи сообщения с контентом."""
|
||||||
|
|
||||||
post_id: int
|
post_id: int
|
||||||
message_id: int
|
message_id: int
|
||||||
|
|
||||||
@@ -65,6 +88,7 @@ class MessageContentLink:
|
|||||||
@dataclass
|
@dataclass
|
||||||
class Admin:
|
class Admin:
|
||||||
"""Модель администратора."""
|
"""Модель администратора."""
|
||||||
|
|
||||||
user_id: int
|
user_id: int
|
||||||
role: str = "admin"
|
role: str = "admin"
|
||||||
created_at: Optional[str] = None
|
created_at: Optional[str] = None
|
||||||
@@ -73,14 +97,15 @@ class Admin:
|
|||||||
@dataclass
|
@dataclass
|
||||||
class Migration:
|
class Migration:
|
||||||
"""Модель миграции."""
|
"""Модель миграции."""
|
||||||
version: int
|
|
||||||
script_name: str
|
script_name: str
|
||||||
created_at: Optional[str] = None
|
applied_at: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AudioMessage:
|
class AudioMessage:
|
||||||
"""Модель аудио сообщения."""
|
"""Модель аудио сообщения."""
|
||||||
|
|
||||||
file_name: str
|
file_name: str
|
||||||
author_id: int
|
author_id: int
|
||||||
date_added: str
|
date_added: str
|
||||||
@@ -91,6 +116,7 @@ class AudioMessage:
|
|||||||
@dataclass
|
@dataclass
|
||||||
class AudioListenRecord:
|
class AudioListenRecord:
|
||||||
"""Модель записи прослушивания аудио."""
|
"""Модель записи прослушивания аудио."""
|
||||||
|
|
||||||
file_name: str
|
file_name: str
|
||||||
user_id: int
|
user_id: int
|
||||||
is_listen: bool = False
|
is_listen: bool = False
|
||||||
@@ -99,5 +125,6 @@ class AudioListenRecord:
|
|||||||
@dataclass
|
@dataclass
|
||||||
class AudioModerate:
|
class AudioModerate:
|
||||||
"""Модель для voice bot."""
|
"""Модель для voice bot."""
|
||||||
|
|
||||||
message_id: int
|
message_id: int
|
||||||
user_id: int
|
user_id: int
|
||||||
|
|||||||
@@ -4,20 +4,33 @@
|
|||||||
Содержит репозитории для разных сущностей:
|
Содержит репозитории для разных сущностей:
|
||||||
- user_repository: работа с пользователями
|
- user_repository: работа с пользователями
|
||||||
- blacklist_repository: работа с черным списком
|
- blacklist_repository: работа с черным списком
|
||||||
|
- blacklist_history_repository: работа с историей банов/разбанов
|
||||||
- message_repository: работа с сообщениями
|
- message_repository: работа с сообщениями
|
||||||
- post_repository: работа с постами
|
- post_repository: работа с постами
|
||||||
- admin_repository: работа с администраторами
|
- admin_repository: работа с администраторами
|
||||||
- audio_repository: работа с аудио
|
- audio_repository: работа с аудио
|
||||||
|
- migration_repository: работа с миграциями БД
|
||||||
|
- bot_settings_repository: работа с настройками бота
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .user_repository import UserRepository
|
|
||||||
from .blacklist_repository import BlacklistRepository
|
|
||||||
from .message_repository import MessageRepository
|
|
||||||
from .post_repository import PostRepository
|
|
||||||
from .admin_repository import AdminRepository
|
from .admin_repository import AdminRepository
|
||||||
from .audio_repository import AudioRepository
|
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
|
||||||
|
from .user_repository import UserRepository
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'UserRepository', 'BlacklistRepository', 'MessageRepository', 'PostRepository',
|
"UserRepository",
|
||||||
'AdminRepository', 'AudioRepository'
|
"BlacklistRepository",
|
||||||
|
"BlacklistHistoryRepository",
|
||||||
|
"MessageRepository",
|
||||||
|
"PostRepository",
|
||||||
|
"AdminRepository",
|
||||||
|
"AudioRepository",
|
||||||
|
"MigrationRepository",
|
||||||
|
"BotSettingsRepository",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,74 +1,73 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from database.base import DatabaseConnection
|
from database.base import DatabaseConnection
|
||||||
from database.models import Admin
|
from database.models import Admin
|
||||||
|
|
||||||
|
|
||||||
class AdminRepository(DatabaseConnection):
|
class AdminRepository(DatabaseConnection):
|
||||||
"""Репозиторий для работы с администраторами."""
|
"""Репозиторий для работы с администраторами."""
|
||||||
|
|
||||||
async def create_tables(self):
|
async def create_tables(self):
|
||||||
"""Создание таблицы администраторов."""
|
"""Создание таблицы администраторов."""
|
||||||
# Включаем поддержку внешних ключей
|
# Включаем поддержку внешних ключей
|
||||||
await self._execute_query("PRAGMA foreign_keys = ON")
|
await self._execute_query("PRAGMA foreign_keys = ON")
|
||||||
|
|
||||||
query = '''
|
query = """
|
||||||
CREATE TABLE IF NOT EXISTS admins (
|
CREATE TABLE IF NOT EXISTS admins (
|
||||||
user_id INTEGER NOT NULL PRIMARY KEY,
|
user_id INTEGER NOT NULL PRIMARY KEY,
|
||||||
role TEXT DEFAULT 'admin',
|
role TEXT DEFAULT 'admin',
|
||||||
created_at INTEGER DEFAULT (strftime('%s', 'now')),
|
created_at INTEGER DEFAULT (strftime('%s', 'now')),
|
||||||
FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE
|
||||||
)
|
)
|
||||||
'''
|
"""
|
||||||
await self._execute_query(query)
|
await self._execute_query(query)
|
||||||
self.logger.info("Таблица администраторов создана")
|
self.logger.info("Таблица администраторов создана")
|
||||||
|
|
||||||
async def add_admin(self, admin: Admin) -> None:
|
async def add_admin(self, admin: Admin) -> None:
|
||||||
"""Добавление администратора."""
|
"""Добавление администратора."""
|
||||||
query = "INSERT INTO admins (user_id, role) VALUES (?, ?)"
|
query = "INSERT INTO admins (user_id, role) VALUES (?, ?)"
|
||||||
params = (admin.user_id, admin.role)
|
params = (admin.user_id, admin.role)
|
||||||
|
|
||||||
await self._execute_query(query, params)
|
await self._execute_query(query, params)
|
||||||
self.logger.info(f"Администратор добавлен: user_id={admin.user_id}, role={admin.role}")
|
self.logger.info(
|
||||||
|
f"Администратор добавлен: user_id={admin.user_id}, role={admin.role}"
|
||||||
|
)
|
||||||
|
|
||||||
async def remove_admin(self, user_id: int) -> None:
|
async def remove_admin(self, user_id: int) -> None:
|
||||||
"""Удаление администратора."""
|
"""Удаление администратора."""
|
||||||
query = "DELETE FROM admins WHERE user_id = ?"
|
query = "DELETE FROM admins WHERE user_id = ?"
|
||||||
await self._execute_query(query, (user_id,))
|
await self._execute_query(query, (user_id,))
|
||||||
self.logger.info(f"Администратор удален: user_id={user_id}")
|
self.logger.info(f"Администратор удален: user_id={user_id}")
|
||||||
|
|
||||||
async def is_admin(self, user_id: int) -> bool:
|
async def is_admin(self, user_id: int) -> bool:
|
||||||
"""Проверка, является ли пользователь администратором."""
|
"""Проверка, является ли пользователь администратором."""
|
||||||
query = "SELECT 1 FROM admins WHERE user_id = ?"
|
query = "SELECT 1 FROM admins WHERE user_id = ?"
|
||||||
rows = await self._execute_query_with_result(query, (user_id,))
|
rows = await self._execute_query_with_result(query, (user_id,))
|
||||||
row = rows[0] if rows else None
|
row = rows[0] if rows else None
|
||||||
return bool(row)
|
return bool(row)
|
||||||
|
|
||||||
async def get_admin(self, user_id: int) -> Optional[Admin]:
|
async def get_admin(self, user_id: int) -> Optional[Admin]:
|
||||||
"""Получение информации об администраторе."""
|
"""Получение информации об администраторе."""
|
||||||
query = "SELECT user_id, role, created_at FROM admins WHERE user_id = ?"
|
query = "SELECT user_id, role, created_at FROM admins WHERE user_id = ?"
|
||||||
rows = await self._execute_query_with_result(query, (user_id,))
|
rows = await self._execute_query_with_result(query, (user_id,))
|
||||||
row = rows[0] if rows else None
|
row = rows[0] if rows else None
|
||||||
|
|
||||||
if row:
|
if row:
|
||||||
return Admin(
|
return Admin(
|
||||||
user_id=row[0],
|
user_id=row[0], role=row[1], created_at=row[2] if len(row) > 2 else None
|
||||||
role=row[1],
|
|
||||||
created_at=row[2] if len(row) > 2 else None
|
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_all_admins(self) -> list[Admin]:
|
async def get_all_admins(self) -> list[Admin]:
|
||||||
"""Получение всех администраторов."""
|
"""Получение всех администраторов."""
|
||||||
query = "SELECT user_id, role, created_at FROM admins ORDER BY created_at DESC"
|
query = "SELECT user_id, role, created_at FROM admins ORDER BY created_at DESC"
|
||||||
rows = await self._execute_query_with_result(query)
|
rows = await self._execute_query_with_result(query)
|
||||||
|
|
||||||
admins = []
|
admins = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
admin = Admin(
|
admin = Admin(
|
||||||
user_id=row[0],
|
user_id=row[0], role=row[1], created_at=row[2] if len(row) > 2 else None
|
||||||
role=row[1],
|
|
||||||
created_at=row[2] if len(row) > 2 else None
|
|
||||||
)
|
)
|
||||||
admins.append(admin)
|
admins.append(admin)
|
||||||
|
|
||||||
return admins
|
return admins
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
from typing import Optional, List, Dict, Any
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from database.base import DatabaseConnection
|
from database.base import DatabaseConnection
|
||||||
from database.models import AudioMessage, AudioListenRecord, AudioModerate
|
from database.models import AudioListenRecord, AudioMessage, AudioModerate
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
|
|
||||||
class AudioRepository(DatabaseConnection):
|
class AudioRepository(DatabaseConnection):
|
||||||
"""Репозиторий для работы с аудио сообщениями."""
|
"""Репозиторий для работы с аудио сообщениями."""
|
||||||
|
|
||||||
async def enable_foreign_keys(self):
|
async def enable_foreign_keys(self):
|
||||||
"""Включает поддержку внешних ключей."""
|
"""Включает поддержку внешних ключей."""
|
||||||
await self._execute_query("PRAGMA foreign_keys = ON;")
|
await self._execute_query("PRAGMA foreign_keys = ON;")
|
||||||
|
|
||||||
async def create_tables(self):
|
async def create_tables(self):
|
||||||
"""Создание таблиц для аудио."""
|
"""Создание таблиц для аудио."""
|
||||||
# Таблица аудио сообщений
|
# Таблица аудио сообщений
|
||||||
audio_query = '''
|
audio_query = """
|
||||||
CREATE TABLE IF NOT EXISTS audio_message_reference (
|
CREATE TABLE IF NOT EXISTS audio_message_reference (
|
||||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
file_name TEXT NOT NULL UNIQUE,
|
file_name TEXT NOT NULL UNIQUE,
|
||||||
@@ -22,33 +23,33 @@ class AudioRepository(DatabaseConnection):
|
|||||||
date_added INTEGER NOT NULL,
|
date_added INTEGER NOT NULL,
|
||||||
FOREIGN KEY (author_id) REFERENCES our_users (user_id) ON DELETE CASCADE
|
FOREIGN KEY (author_id) REFERENCES our_users (user_id) ON DELETE CASCADE
|
||||||
)
|
)
|
||||||
'''
|
"""
|
||||||
await self._execute_query(audio_query)
|
await self._execute_query(audio_query)
|
||||||
|
|
||||||
# Таблица прослушивания аудио
|
# Таблица прослушивания аудио
|
||||||
listen_query = '''
|
listen_query = """
|
||||||
CREATE TABLE IF NOT EXISTS user_audio_listens (
|
CREATE TABLE IF NOT EXISTS user_audio_listens (
|
||||||
file_name TEXT NOT NULL,
|
file_name TEXT NOT NULL,
|
||||||
user_id INTEGER NOT NULL,
|
user_id INTEGER NOT NULL,
|
||||||
PRIMARY KEY (file_name, user_id),
|
PRIMARY KEY (file_name, user_id),
|
||||||
FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE
|
||||||
)
|
)
|
||||||
'''
|
"""
|
||||||
await self._execute_query(listen_query)
|
await self._execute_query(listen_query)
|
||||||
|
|
||||||
# Таблица для voice bot
|
# Таблица для voice bot
|
||||||
voice_query = '''
|
voice_query = """
|
||||||
CREATE TABLE IF NOT EXISTS audio_moderate (
|
CREATE TABLE IF NOT EXISTS audio_moderate (
|
||||||
user_id INTEGER NOT NULL,
|
user_id INTEGER NOT NULL,
|
||||||
message_id INTEGER,
|
message_id INTEGER,
|
||||||
PRIMARY KEY (user_id, message_id),
|
PRIMARY KEY (user_id, message_id),
|
||||||
FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE
|
||||||
)
|
)
|
||||||
'''
|
"""
|
||||||
await self._execute_query(voice_query)
|
await self._execute_query(voice_query)
|
||||||
|
|
||||||
self.logger.info("Таблицы для аудио созданы")
|
self.logger.info("Таблицы для аудио созданы")
|
||||||
|
|
||||||
async def add_audio_record(self, audio: AudioMessage) -> None:
|
async def add_audio_record(self, audio: AudioMessage) -> None:
|
||||||
"""Добавляет информацию о войсе пользователя."""
|
"""Добавляет информацию о войсе пользователя."""
|
||||||
query = """
|
query = """
|
||||||
@@ -62,13 +63,17 @@ class AudioRepository(DatabaseConnection):
|
|||||||
date_timestamp = int(audio.date_added.timestamp())
|
date_timestamp = int(audio.date_added.timestamp())
|
||||||
else:
|
else:
|
||||||
date_timestamp = audio.date_added
|
date_timestamp = audio.date_added
|
||||||
|
|
||||||
params = (audio.file_name, audio.author_id, date_timestamp)
|
params = (audio.file_name, audio.author_id, date_timestamp)
|
||||||
|
|
||||||
await self._execute_query(query, params)
|
await self._execute_query(query, params)
|
||||||
self.logger.info(f"Аудио добавлено: file_name={audio.file_name}, author_id={audio.author_id}")
|
self.logger.info(
|
||||||
|
f"Аудио добавлено: file_name={audio.file_name}, author_id={audio.author_id}"
|
||||||
async def add_audio_record_simple(self, file_name: str, user_id: int, date_added) -> None:
|
)
|
||||||
|
|
||||||
|
async def add_audio_record_simple(
|
||||||
|
self, file_name: str, user_id: int, date_added
|
||||||
|
) -> None:
|
||||||
"""Добавляет информацию о войсе пользователя (упрощенная версия)."""
|
"""Добавляет информацию о войсе пользователя (упрощенная версия)."""
|
||||||
query = """
|
query = """
|
||||||
INSERT INTO audio_message_reference (file_name, author_id, date_added)
|
INSERT INTO audio_message_reference (file_name, author_id, date_added)
|
||||||
@@ -81,30 +86,30 @@ class AudioRepository(DatabaseConnection):
|
|||||||
date_timestamp = int(date_added.timestamp())
|
date_timestamp = int(date_added.timestamp())
|
||||||
else:
|
else:
|
||||||
date_timestamp = date_added
|
date_timestamp = date_added
|
||||||
|
|
||||||
params = (file_name, user_id, date_timestamp)
|
params = (file_name, user_id, date_timestamp)
|
||||||
|
|
||||||
await self._execute_query(query, params)
|
await self._execute_query(query, params)
|
||||||
self.logger.info(f"Аудио добавлено: file_name={file_name}, user_id={user_id}")
|
self.logger.info(f"Аудио добавлено: file_name={file_name}, user_id={user_id}")
|
||||||
|
|
||||||
async def get_last_date_audio(self) -> Optional[int]:
|
async def get_last_date_audio(self) -> Optional[int]:
|
||||||
"""Получает дату последнего войса."""
|
"""Получает дату последнего войса."""
|
||||||
query = "SELECT date_added FROM audio_message_reference ORDER BY date_added DESC LIMIT 1"
|
query = "SELECT date_added FROM audio_message_reference ORDER BY date_added DESC LIMIT 1"
|
||||||
rows = await self._execute_query_with_result(query)
|
rows = await self._execute_query_with_result(query)
|
||||||
row = rows[0] if rows else None
|
row = rows[0] if rows else None
|
||||||
|
|
||||||
if row:
|
if row:
|
||||||
self.logger.info(f"Последняя дата аудио: {row[0]}")
|
self.logger.info(f"Последняя дата аудио: {row[0]}")
|
||||||
return row[0]
|
return row[0]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_user_audio_records_count(self, user_id: int) -> int:
|
async def get_user_audio_records_count(self, user_id: int) -> int:
|
||||||
"""Получает количество записей пользователя."""
|
"""Получает количество записей пользователя."""
|
||||||
query = "SELECT COUNT(*) FROM audio_message_reference WHERE author_id = ?"
|
query = "SELECT COUNT(*) FROM audio_message_reference WHERE author_id = ?"
|
||||||
rows = await self._execute_query_with_result(query, (user_id,))
|
rows = await self._execute_query_with_result(query, (user_id,))
|
||||||
row = rows[0] if rows else None
|
row = rows[0] if rows else None
|
||||||
return row[0] if row else 0
|
return row[0] if row else 0
|
||||||
|
|
||||||
async def get_path_for_audio_record(self, user_id: int) -> Optional[str]:
|
async def get_path_for_audio_record(self, user_id: int) -> Optional[str]:
|
||||||
"""Получает название последнего файла пользователя."""
|
"""Получает название последнего файла пользователя."""
|
||||||
query = """
|
query = """
|
||||||
@@ -114,7 +119,7 @@ class AudioRepository(DatabaseConnection):
|
|||||||
rows = await self._execute_query_with_result(query, (user_id,))
|
rows = await self._execute_query_with_result(query, (user_id,))
|
||||||
row = rows[0] if rows else None
|
row = rows[0] if rows else None
|
||||||
return row[0] if row else None
|
return row[0] if row else None
|
||||||
|
|
||||||
async def check_listen_audio(self, user_id: int) -> List[str]:
|
async def check_listen_audio(self, user_id: int) -> List[str]:
|
||||||
"""Проверяет непрослушанные аудио для пользователя."""
|
"""Проверяет непрослушанные аудио для пользователя."""
|
||||||
query = """
|
query = """
|
||||||
@@ -124,115 +129,129 @@ class AudioRepository(DatabaseConnection):
|
|||||||
WHERE l.user_id = ? AND l.file_name IS NOT NULL
|
WHERE l.user_id = ? AND l.file_name IS NOT NULL
|
||||||
"""
|
"""
|
||||||
listened_files = await self._execute_query_with_result(query, (user_id,))
|
listened_files = await self._execute_query_with_result(query, (user_id,))
|
||||||
|
|
||||||
# Получаем все аудио, кроме созданных пользователем
|
# Получаем все аудио, кроме созданных пользователем
|
||||||
all_audio_query = 'SELECT file_name FROM audio_message_reference WHERE author_id <> ?'
|
all_audio_query = (
|
||||||
|
"SELECT file_name FROM audio_message_reference WHERE author_id <> ?"
|
||||||
|
)
|
||||||
all_files = await self._execute_query_with_result(all_audio_query, (user_id,))
|
all_files = await self._execute_query_with_result(all_audio_query, (user_id,))
|
||||||
|
|
||||||
# Находим непрослушанные
|
# Находим непрослушанные
|
||||||
listened_set = {row[0] for row in listened_files}
|
listened_set = {row[0] for row in listened_files}
|
||||||
all_set = {row[0] for row in all_files}
|
all_set = {row[0] for row in all_files}
|
||||||
new_files = list(all_set - listened_set)
|
new_files = list(all_set - listened_set)
|
||||||
|
|
||||||
self.logger.info(f"Найдено {len(new_files)} непрослушанных аудио для пользователя {user_id}")
|
self.logger.info(
|
||||||
|
f"Найдено {len(new_files)} непрослушанных аудио для пользователя {user_id}"
|
||||||
|
)
|
||||||
return new_files
|
return new_files
|
||||||
|
|
||||||
async def mark_listened_audio(self, file_name: str, user_id: int) -> None:
|
async def mark_listened_audio(self, file_name: str, user_id: int) -> None:
|
||||||
"""Отмечает аудио прослушанным для пользователя."""
|
"""Отмечает аудио прослушанным для пользователя."""
|
||||||
query = "INSERT OR IGNORE INTO user_audio_listens (file_name, user_id) VALUES (?, ?)"
|
query = "INSERT OR IGNORE INTO user_audio_listens (file_name, user_id) VALUES (?, ?)"
|
||||||
params = (file_name, user_id)
|
params = (file_name, user_id)
|
||||||
|
|
||||||
await self._execute_query(query, params)
|
await self._execute_query(query, params)
|
||||||
self.logger.info(f"Аудио {file_name} отмечено как прослушанное для пользователя {user_id}")
|
self.logger.info(
|
||||||
|
f"Аудио {file_name} отмечено как прослушанное для пользователя {user_id}"
|
||||||
|
)
|
||||||
|
|
||||||
async def get_user_id_by_file_name(self, file_name: str) -> Optional[int]:
|
async def get_user_id_by_file_name(self, file_name: str) -> Optional[int]:
|
||||||
"""Получает user_id пользователя по имени файла."""
|
"""Получает user_id пользователя по имени файла."""
|
||||||
query = "SELECT author_id FROM audio_message_reference WHERE file_name = ?"
|
query = "SELECT author_id FROM audio_message_reference WHERE file_name = ?"
|
||||||
rows = await self._execute_query_with_result(query, (file_name,))
|
rows = await self._execute_query_with_result(query, (file_name,))
|
||||||
row = rows[0] if rows else None
|
row = rows[0] if rows else None
|
||||||
|
|
||||||
if row:
|
if row:
|
||||||
user_id = row[0]
|
user_id = row[0]
|
||||||
self.logger.info(f"Получен user_id {user_id} для файла {file_name}")
|
self.logger.info(f"Получен user_id {user_id} для файла {file_name}")
|
||||||
return user_id
|
return user_id
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_date_by_file_name(self, file_name: str) -> Optional[str]:
|
async def get_date_by_file_name(self, file_name: str) -> Optional[str]:
|
||||||
"""Получает дату добавления файла."""
|
"""Получает дату добавления файла."""
|
||||||
query = "SELECT date_added FROM audio_message_reference WHERE file_name = ?"
|
query = "SELECT date_added FROM audio_message_reference WHERE file_name = ?"
|
||||||
rows = await self._execute_query_with_result(query, (file_name,))
|
rows = await self._execute_query_with_result(query, (file_name,))
|
||||||
row = rows[0] if rows else None
|
row = rows[0] if rows else None
|
||||||
|
|
||||||
if row:
|
if row:
|
||||||
date_added = row[0]
|
date_added = row[0]
|
||||||
# Преобразуем UNIX timestamp в читаемую дату
|
# Преобразуем UNIX timestamp в читаемую дату (UTC для одинакового результата везде)
|
||||||
readable_date = datetime.fromtimestamp(date_added).strftime('%d.%m.%Y %H:%M')
|
readable_date = datetime.fromtimestamp(
|
||||||
|
date_added, tz=timezone.utc
|
||||||
|
).strftime("%d.%m.%Y %H:%M")
|
||||||
self.logger.info(f"Получена дата {readable_date} для файла {file_name}")
|
self.logger.info(f"Получена дата {readable_date} для файла {file_name}")
|
||||||
return readable_date
|
return readable_date
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def refresh_listen_audio(self, user_id: int) -> None:
|
async def refresh_listen_audio(self, user_id: int) -> None:
|
||||||
"""Очищает всю информацию о прослушанных аудио пользователем."""
|
"""Очищает всю информацию о прослушанных аудио пользователем."""
|
||||||
query = "DELETE FROM user_audio_listens WHERE user_id = ?"
|
query = "DELETE FROM user_audio_listens WHERE user_id = ?"
|
||||||
await self._execute_query(query, (user_id,))
|
await self._execute_query(query, (user_id,))
|
||||||
self.logger.info(f"Очищены записи прослушивания для пользователя {user_id}")
|
self.logger.info(f"Очищены записи прослушивания для пользователя {user_id}")
|
||||||
|
|
||||||
async def delete_listen_count_for_user(self, user_id: int) -> None:
|
async def delete_listen_count_for_user(self, user_id: int) -> None:
|
||||||
"""Удаляет данные о прослушанных пользователем аудио."""
|
"""Удаляет данные о прослушанных пользователем аудио."""
|
||||||
query = "DELETE FROM user_audio_listens WHERE user_id = ?"
|
query = "DELETE FROM user_audio_listens WHERE user_id = ?"
|
||||||
await self._execute_query(query, (user_id,))
|
await self._execute_query(query, (user_id,))
|
||||||
self.logger.info(f"Удалены записи прослушивания для пользователя {user_id}")
|
self.logger.info(f"Удалены записи прослушивания для пользователя {user_id}")
|
||||||
|
|
||||||
# Методы для voice bot
|
# Методы для voice bot
|
||||||
async def set_user_id_and_message_id_for_voice_bot(self, message_id: int, user_id: int) -> bool:
|
async def set_user_id_and_message_id_for_voice_bot(
|
||||||
|
self, message_id: int, user_id: int
|
||||||
|
) -> bool:
|
||||||
"""Устанавливает связь между message_id и user_id для voice bot."""
|
"""Устанавливает связь между message_id и user_id для voice bot."""
|
||||||
try:
|
try:
|
||||||
query = "INSERT OR IGNORE INTO audio_moderate (user_id, message_id) VALUES (?, ?)"
|
query = "INSERT OR IGNORE INTO audio_moderate (user_id, message_id) VALUES (?, ?)"
|
||||||
params = (user_id, message_id)
|
params = (user_id, message_id)
|
||||||
|
|
||||||
await self._execute_query(query, params)
|
await self._execute_query(query, params)
|
||||||
self.logger.info(f"Связь установлена: message_id={message_id}, user_id={user_id}")
|
self.logger.info(
|
||||||
|
f"Связь установлена: message_id={message_id}, user_id={user_id}"
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Ошибка установки связи: {e}")
|
self.logger.error(f"Ошибка установки связи: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def get_user_id_by_message_id_for_voice_bot(self, message_id: int) -> Optional[int]:
|
async def get_user_id_by_message_id_for_voice_bot(
|
||||||
|
self, message_id: int
|
||||||
|
) -> Optional[int]:
|
||||||
"""Получает user_id пользователя по message_id для voice bot."""
|
"""Получает user_id пользователя по message_id для voice bot."""
|
||||||
query = "SELECT user_id FROM audio_moderate WHERE message_id = ?"
|
query = "SELECT user_id FROM audio_moderate WHERE message_id = ?"
|
||||||
rows = await self._execute_query_with_result(query, (message_id,))
|
rows = await self._execute_query_with_result(query, (message_id,))
|
||||||
row = rows[0] if rows else None
|
row = rows[0] if rows else None
|
||||||
|
|
||||||
if row:
|
if row:
|
||||||
user_id = row[0]
|
user_id = row[0]
|
||||||
self.logger.info(f"Получен user_id {user_id} для message_id {message_id}")
|
self.logger.info(f"Получен user_id {user_id} для message_id {message_id}")
|
||||||
return user_id
|
return user_id
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def delete_audio_moderate_record(self, message_id: int) -> None:
|
async def delete_audio_moderate_record(self, message_id: int) -> None:
|
||||||
"""Удаляет запись из таблицы audio_moderate по message_id."""
|
"""Удаляет запись из таблицы audio_moderate по message_id."""
|
||||||
query = "DELETE FROM audio_moderate WHERE message_id = ?"
|
query = "DELETE FROM audio_moderate WHERE message_id = ?"
|
||||||
await self._execute_query(query, (message_id,))
|
await self._execute_query(query, (message_id,))
|
||||||
self.logger.info(f"Удалена запись из audio_moderate для message_id {message_id}")
|
self.logger.info(
|
||||||
|
f"Удалена запись из audio_moderate для message_id {message_id}"
|
||||||
|
)
|
||||||
|
|
||||||
async def get_all_audio_records(self) -> List[Dict[str, Any]]:
|
async def get_all_audio_records(self) -> List[Dict[str, Any]]:
|
||||||
"""Получить все записи аудио сообщений."""
|
"""Получить все записи аудио сообщений."""
|
||||||
query = "SELECT file_name, author_id, date_added FROM audio_message_reference"
|
query = "SELECT file_name, author_id, date_added FROM audio_message_reference"
|
||||||
rows = await self._execute_query_with_result(query)
|
rows = await self._execute_query_with_result(query)
|
||||||
|
|
||||||
records = []
|
records = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
records.append({
|
records.append(
|
||||||
'file_name': row[0],
|
{"file_name": row[0], "author_id": row[1], "date_added": row[2]}
|
||||||
'author_id': row[1],
|
)
|
||||||
'date_added': row[2]
|
|
||||||
})
|
|
||||||
|
|
||||||
self.logger.info(f"Получено {len(records)} записей аудио сообщений")
|
self.logger.info(f"Получено {len(records)} записей аудио сообщений")
|
||||||
return records
|
return records
|
||||||
|
|
||||||
async def delete_audio_record_by_file_name(self, file_name: str) -> None:
|
async def delete_audio_record_by_file_name(self, file_name: str) -> None:
|
||||||
"""Удалить запись аудио сообщения по имени файла."""
|
"""Удалить запись аудио сообщения по имени файла."""
|
||||||
query = "DELETE FROM audio_message_reference WHERE file_name = ?"
|
query = "DELETE FROM audio_message_reference WHERE file_name = ?"
|
||||||
await self._execute_query(query, (file_name,))
|
await self._execute_query(query, (file_name,))
|
||||||
self.logger.info(f"Удалена запись аудио сообщения: {file_name}")
|
self.logger.info(f"Удалена запись аудио сообщения: {file_name}")
|
||||||
|
|||||||
174
database/repositories/blacklist_history_repository.py
Normal file
174
database/repositories/blacklist_history_repository.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
from database.base import DatabaseConnection
|
||||||
|
from database.models import BlacklistHistoryRecord
|
||||||
|
|
||||||
|
|
||||||
|
class BlacklistHistoryRepository(DatabaseConnection):
|
||||||
|
"""Репозиторий для работы с историей банов/разбанов."""
|
||||||
|
|
||||||
|
async def create_tables(self):
|
||||||
|
"""Создание таблицы истории банов/разбанов."""
|
||||||
|
query = """
|
||||||
|
CREATE TABLE IF NOT EXISTS blacklist_history (
|
||||||
|
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
message_for_user TEXT,
|
||||||
|
date_ban INTEGER NOT NULL,
|
||||||
|
date_unban INTEGER,
|
||||||
|
ban_author INTEGER,
|
||||||
|
created_at INTEGER DEFAULT (strftime('%s', 'now')),
|
||||||
|
updated_at INTEGER DEFAULT (strftime('%s', 'now')),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES our_users(user_id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (ban_author) REFERENCES our_users(user_id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
await self._execute_query(query)
|
||||||
|
|
||||||
|
# Создаем индексы
|
||||||
|
await self._execute_query(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_blacklist_history_user_id ON blacklist_history(user_id)"
|
||||||
|
)
|
||||||
|
await self._execute_query(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_blacklist_history_date_ban ON blacklist_history(date_ban)"
|
||||||
|
)
|
||||||
|
await self._execute_query(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_blacklist_history_date_unban ON blacklist_history(date_unban)"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.logger.info("Таблица истории банов/разбанов создана")
|
||||||
|
|
||||||
|
async def add_record_on_ban(self, record: BlacklistHistoryRecord) -> None:
|
||||||
|
"""Добавляет запись о бане в историю."""
|
||||||
|
query = """
|
||||||
|
INSERT INTO blacklist_history (
|
||||||
|
user_id, message_for_user, date_ban, date_unban, ban_author, created_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
"""
|
||||||
|
# Используем текущее время, если не указано
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
current_timestamp = int(datetime.now().timestamp())
|
||||||
|
|
||||||
|
params = (
|
||||||
|
record.user_id,
|
||||||
|
record.message_for_user,
|
||||||
|
record.date_ban,
|
||||||
|
record.date_unban,
|
||||||
|
record.ban_author,
|
||||||
|
record.created_at if record.created_at is not None else current_timestamp,
|
||||||
|
record.updated_at if record.updated_at is not None else current_timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._execute_query(query, params)
|
||||||
|
self.logger.info(
|
||||||
|
f"Запись о бане добавлена в историю: user_id={record.user_id}, "
|
||||||
|
f"date_ban={record.date_ban}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_unban_date(self, user_id: int, date_unban: int) -> bool:
|
||||||
|
"""
|
||||||
|
Обновляет date_unban и updated_at в последней записи (date_unban IS NULL) для пользователя.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID пользователя
|
||||||
|
date_unban: Timestamp даты разбана
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если запись обновлена, False если не найдена открытая запись
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
current_timestamp = int(datetime.now().timestamp())
|
||||||
|
|
||||||
|
# SQLite не поддерживает ORDER BY в UPDATE, поэтому используем подзапрос
|
||||||
|
# Сначала проверяем, есть ли открытая запись
|
||||||
|
check_query = """
|
||||||
|
SELECT id FROM blacklist_history
|
||||||
|
WHERE user_id = ? AND date_unban IS NULL
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
rows = await self._execute_query_with_result(check_query, (user_id,))
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
self.logger.warning(
|
||||||
|
f"Не найдена открытая запись в истории для обновления: user_id={user_id}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Обновляем найденную запись
|
||||||
|
update_query = """
|
||||||
|
UPDATE blacklist_history
|
||||||
|
SET date_unban = ?,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
"""
|
||||||
|
|
||||||
|
record_id = rows[0][0]
|
||||||
|
params = (date_unban, current_timestamp, record_id)
|
||||||
|
await self._execute_query(update_query, params)
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
f"Дата разбана обновлена в истории: user_id={user_id}, date_unban={date_unban}"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(
|
||||||
|
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
|
||||||
@@ -1,113 +1,155 @@
|
|||||||
from typing import Optional, List, Dict
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from database.base import DatabaseConnection
|
from database.base import DatabaseConnection
|
||||||
from database.models import BlacklistUser
|
from database.models import BlacklistUser
|
||||||
|
|
||||||
|
|
||||||
class BlacklistRepository(DatabaseConnection):
|
class BlacklistRepository(DatabaseConnection):
|
||||||
"""Репозиторий для работы с черным списком."""
|
"""Репозиторий для работы с черным списком."""
|
||||||
|
|
||||||
async def create_tables(self):
|
async def create_tables(self):
|
||||||
"""Создание таблицы черного списка."""
|
"""Создание таблицы черного списка."""
|
||||||
query = '''
|
query = """
|
||||||
CREATE TABLE IF NOT EXISTS blacklist (
|
CREATE TABLE IF NOT EXISTS blacklist (
|
||||||
user_id INTEGER NOT NULL PRIMARY KEY,
|
user_id INTEGER NOT NULL PRIMARY KEY,
|
||||||
message_for_user TEXT,
|
message_for_user TEXT,
|
||||||
date_to_unban INTEGER,
|
date_to_unban INTEGER,
|
||||||
created_at INTEGER DEFAULT (strftime('%s', 'now')),
|
created_at INTEGER DEFAULT (strftime('%s', 'now')),
|
||||||
FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE
|
ban_author INTEGER,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (ban_author) REFERENCES our_users (user_id) ON DELETE SET NULL
|
||||||
)
|
)
|
||||||
'''
|
"""
|
||||||
await self._execute_query(query)
|
await self._execute_query(query)
|
||||||
self.logger.info("Таблица черного списка создана")
|
self.logger.info("Таблица черного списка создана")
|
||||||
|
|
||||||
async def add_user(self, blacklist_user: BlacklistUser) -> None:
|
async def add_user(self, blacklist_user: BlacklistUser) -> None:
|
||||||
"""Добавляет пользователя в черный список."""
|
"""Добавляет пользователя в черный список."""
|
||||||
query = """
|
query = """
|
||||||
INSERT INTO blacklist (user_id, message_for_user, date_to_unban)
|
INSERT INTO blacklist (user_id, message_for_user, date_to_unban, ban_author)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
"""
|
"""
|
||||||
params = (blacklist_user.user_id, blacklist_user.message_for_user, blacklist_user.date_to_unban)
|
params = (
|
||||||
|
blacklist_user.user_id,
|
||||||
|
blacklist_user.message_for_user,
|
||||||
|
blacklist_user.date_to_unban,
|
||||||
|
blacklist_user.ban_author,
|
||||||
|
)
|
||||||
|
|
||||||
await self._execute_query(query, params)
|
await self._execute_query(query, params)
|
||||||
self.logger.info(f"Пользователь добавлен в черный список: user_id={blacklist_user.user_id}")
|
self.logger.info(
|
||||||
|
f"Пользователь добавлен в черный список: user_id={blacklist_user.user_id}"
|
||||||
|
)
|
||||||
|
|
||||||
async def remove_user(self, user_id: int) -> bool:
|
async def remove_user(self, user_id: int) -> bool:
|
||||||
"""Удаляет пользователя из черного списка."""
|
"""Удаляет пользователя из черного списка."""
|
||||||
try:
|
try:
|
||||||
query = "DELETE FROM blacklist WHERE user_id = ?"
|
query = "DELETE FROM blacklist WHERE user_id = ?"
|
||||||
await self._execute_query(query, (user_id,))
|
await self._execute_query(query, (user_id,))
|
||||||
self.logger.info(f"Пользователь с идентификатором {user_id} успешно удален из черного списка.")
|
self.logger.info(
|
||||||
|
f"Пользователь с идентификатором {user_id} успешно удален из черного списка."
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Ошибка удаления пользователя с идентификатором {user_id} "
|
self.logger.error(
|
||||||
f"из таблицы blacklist. Ошибка: {str(e)}")
|
f"Ошибка удаления пользователя с идентификатором {user_id} "
|
||||||
|
f"из таблицы blacklist. Ошибка: {str(e)}"
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def user_exists(self, user_id: int) -> bool:
|
async def user_exists(self, user_id: int) -> bool:
|
||||||
"""Проверяет, существует ли запись с данным user_id в blacklist."""
|
"""Проверяет, существует ли запись с данным user_id в blacklist."""
|
||||||
query = "SELECT 1 FROM blacklist WHERE user_id = ?"
|
query = "SELECT 1 FROM blacklist WHERE user_id = ?"
|
||||||
rows = await self._execute_query_with_result(query, (user_id,))
|
rows = await self._execute_query_with_result(query, (user_id,))
|
||||||
self.logger.info(f"Существует ли пользователь: user_id={user_id} Итог: {rows}")
|
self.logger.info(f"Существует ли пользователь: user_id={user_id} Итог: {rows}")
|
||||||
return bool(rows)
|
return bool(rows)
|
||||||
|
|
||||||
async def get_user(self, user_id: int) -> Optional[BlacklistUser]:
|
async def get_user(self, user_id: int) -> Optional[BlacklistUser]:
|
||||||
"""Возвращает информацию о пользователе в черном списке по user_id."""
|
"""Возвращает информацию о пользователе в черном списке по user_id."""
|
||||||
query = "SELECT user_id, message_for_user, date_to_unban, created_at FROM blacklist WHERE user_id = ?"
|
query = """
|
||||||
|
SELECT user_id, message_for_user, date_to_unban, created_at, ban_author
|
||||||
|
FROM blacklist
|
||||||
|
WHERE user_id = ?
|
||||||
|
"""
|
||||||
rows = await self._execute_query_with_result(query, (user_id,))
|
rows = await self._execute_query_with_result(query, (user_id,))
|
||||||
row = rows[0] if rows else None
|
row = rows[0] if rows else None
|
||||||
|
|
||||||
if row:
|
if row:
|
||||||
return BlacklistUser(
|
return BlacklistUser(
|
||||||
user_id=row[0],
|
user_id=row[0],
|
||||||
message_for_user=row[1],
|
message_for_user=row[1],
|
||||||
date_to_unban=row[2],
|
date_to_unban=row[2],
|
||||||
created_at=row[3]
|
created_at=row[3],
|
||||||
|
ban_author=row[4] if len(row) > 4 else None,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_all_users(self, offset: int = 0, limit: int = 10) -> List[BlacklistUser]:
|
async def get_all_users(
|
||||||
"""Возвращает список пользователей в черном списке."""
|
self, offset: int = 0, limit: int = 10
|
||||||
query = "SELECT user_id, message_for_user, date_to_unban, created_at FROM blacklist LIMIT ?, ?"
|
) -> List[BlacklistUser]:
|
||||||
rows = await self._execute_query_with_result(query, (offset, limit))
|
"""Возвращает список пользователей в черном списке, отсортированных по дате бана (новые первые)."""
|
||||||
|
query = """
|
||||||
|
SELECT user_id, message_for_user, date_to_unban, created_at, ban_author
|
||||||
|
FROM blacklist
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
"""
|
||||||
|
rows = await self._execute_query_with_result(query, (limit, offset))
|
||||||
|
|
||||||
users = []
|
users = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
users.append(BlacklistUser(
|
users.append(
|
||||||
user_id=row[0],
|
BlacklistUser(
|
||||||
message_for_user=row[1],
|
user_id=row[0],
|
||||||
date_to_unban=row[2],
|
message_for_user=row[1],
|
||||||
created_at=row[3]
|
date_to_unban=row[2],
|
||||||
))
|
created_at=row[3],
|
||||||
|
ban_author=row[4] if len(row) > 4 else None,
|
||||||
self.logger.info(f"Получен список пользователей в черном списке (offset={offset}, limit={limit}): {len(users)}")
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
f"Получен список пользователей в черном списке (offset={offset}, limit={limit}): {len(users)}"
|
||||||
|
)
|
||||||
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 = "SELECT user_id, message_for_user, date_to_unban, created_at FROM blacklist"
|
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)
|
rows = await self._execute_query_with_result(query)
|
||||||
|
|
||||||
users = []
|
users = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
users.append(BlacklistUser(
|
users.append(
|
||||||
user_id=row[0],
|
BlacklistUser(
|
||||||
message_for_user=row[1],
|
user_id=row[0],
|
||||||
date_to_unban=row[2],
|
message_for_user=row[1],
|
||||||
created_at=row[3]
|
date_to_unban=row[2],
|
||||||
))
|
created_at=row[3],
|
||||||
|
ban_author=row[4] if len(row) > 4 else None,
|
||||||
self.logger.info(f"Получен список всех пользователей в черном списке: {len(users)}")
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
f"Получен список всех пользователей в черном списке: {len(users)}"
|
||||||
|
)
|
||||||
return users
|
return users
|
||||||
|
|
||||||
async def get_users_for_unblock_today(self, current_timestamp: int) -> Dict[int, int]:
|
async def get_users_for_unblock_today(
|
||||||
|
self, current_timestamp: int
|
||||||
|
) -> Dict[int, int]:
|
||||||
"""Возвращает список пользователей, у которых истек срок блокировки."""
|
"""Возвращает список пользователей, у которых истек срок блокировки."""
|
||||||
query = "SELECT user_id FROM blacklist WHERE date_to_unban IS NOT NULL AND date_to_unban <= ?"
|
query = "SELECT user_id FROM blacklist WHERE date_to_unban IS NOT NULL AND date_to_unban <= ?"
|
||||||
rows = await self._execute_query_with_result(query, (current_timestamp,))
|
rows = await self._execute_query_with_result(query, (current_timestamp,))
|
||||||
|
|
||||||
users = {user_id: user_id for user_id, in rows}
|
users = {user_id: user_id for user_id, in rows}
|
||||||
self.logger.info(f"Получен список пользователей для разблокировки: {users}")
|
self.logger.info(f"Получен список пользователей для разблокировки: {users}")
|
||||||
return users
|
return users
|
||||||
|
|
||||||
async def get_count(self) -> int:
|
async def get_count(self) -> int:
|
||||||
"""Получение количества пользователей в черном списке."""
|
"""Получение количества пользователей в черном списке."""
|
||||||
query = "SELECT COUNT(*) FROM blacklist"
|
query = "SELECT COUNT(*) FROM blacklist"
|
||||||
|
|||||||
160
database/repositories/bot_settings_repository.py
Normal file
160
database/repositories/bot_settings_repository.py
Normal file
@@ -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
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from database.base import DatabaseConnection
|
from database.base import DatabaseConnection
|
||||||
from database.models import UserMessage
|
from database.models import UserMessage
|
||||||
|
|
||||||
|
|
||||||
class MessageRepository(DatabaseConnection):
|
class MessageRepository(DatabaseConnection):
|
||||||
"""Репозиторий для работы с сообщениями пользователей."""
|
"""Репозиторий для работы с сообщениями пользователей."""
|
||||||
|
|
||||||
async def create_tables(self):
|
async def create_tables(self):
|
||||||
"""Создание таблицы сообщений пользователей."""
|
"""Создание таблицы сообщений пользователей."""
|
||||||
query = '''
|
query = """
|
||||||
CREATE TABLE IF NOT EXISTS user_messages (
|
CREATE TABLE IF NOT EXISTS user_messages (
|
||||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
message_text TEXT,
|
message_text TEXT,
|
||||||
@@ -18,24 +19,31 @@ class MessageRepository(DatabaseConnection):
|
|||||||
date INTEGER NOT NULL,
|
date INTEGER NOT NULL,
|
||||||
FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE
|
||||||
)
|
)
|
||||||
'''
|
"""
|
||||||
await self._execute_query(query)
|
await self._execute_query(query)
|
||||||
self.logger.info("Таблица сообщений пользователей создана")
|
self.logger.info("Таблица сообщений пользователей создана")
|
||||||
|
|
||||||
async def add_message(self, message: UserMessage) -> None:
|
async def add_message(self, message: UserMessage) -> None:
|
||||||
"""Добавление сообщения пользователя."""
|
"""Добавление сообщения пользователя."""
|
||||||
if message.date is None:
|
if message.date is None:
|
||||||
message.date = int(datetime.now().timestamp())
|
message.date = int(datetime.now().timestamp())
|
||||||
|
|
||||||
query = """
|
query = """
|
||||||
INSERT INTO user_messages (message_text, user_id, telegram_message_id, date)
|
INSERT INTO user_messages (message_text, user_id, telegram_message_id, date)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
"""
|
"""
|
||||||
params = (message.message_text, message.user_id, message.telegram_message_id, message.date)
|
params = (
|
||||||
|
message.message_text,
|
||||||
|
message.user_id,
|
||||||
|
message.telegram_message_id,
|
||||||
|
message.date,
|
||||||
|
)
|
||||||
|
|
||||||
await self._execute_query(query, params)
|
await self._execute_query(query, params)
|
||||||
self.logger.info(f"Новое сообщение добавлено: telegram_message_id={message.telegram_message_id}")
|
self.logger.info(
|
||||||
|
f"Новое сообщение добавлено: telegram_message_id={message.telegram_message_id}"
|
||||||
|
)
|
||||||
|
|
||||||
async def get_user_by_message_id(self, message_id: int) -> Optional[int]:
|
async def get_user_by_message_id(self, message_id: int) -> Optional[int]:
|
||||||
"""Получение пользователя по message_id."""
|
"""Получение пользователя по message_id."""
|
||||||
query = "SELECT user_id FROM user_messages WHERE telegram_message_id = ?"
|
query = "SELECT user_id FROM user_messages WHERE telegram_message_id = ?"
|
||||||
|
|||||||
80
database/repositories/migration_repository.py
Normal file
80
database/repositories/migration_repository.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""Репозиторий для работы с миграциями базы данных."""
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
from database.base import DatabaseConnection
|
||||||
|
|
||||||
|
|
||||||
|
class MigrationRepository(DatabaseConnection):
|
||||||
|
"""Репозиторий для управления миграциями базы данных."""
|
||||||
|
|
||||||
|
async def create_table(self):
|
||||||
|
"""Создает таблицу migrations, если она не существует."""
|
||||||
|
query = """
|
||||||
|
CREATE TABLE IF NOT EXISTS migrations (
|
||||||
|
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
script_name TEXT NOT NULL UNIQUE,
|
||||||
|
applied_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
await self._execute_query(query)
|
||||||
|
self.logger.info("Таблица migrations создана или уже существует")
|
||||||
|
|
||||||
|
async def get_applied_migrations(self) -> list[str]:
|
||||||
|
"""Возвращает список имен примененных скриптов миграций."""
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = await self._get_connection()
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"SELECT script_name FROM migrations ORDER BY applied_at"
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
await cursor.close()
|
||||||
|
return [row[0] for row in rows]
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Ошибка при получении списка миграций: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
async def is_migration_applied(self, script_name: str) -> bool:
|
||||||
|
"""Проверяет, применена ли миграция."""
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = await self._get_connection()
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM migrations WHERE script_name = ?", (script_name,)
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
await cursor.close()
|
||||||
|
return row[0] > 0 if row else False
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Ошибка при проверке миграции {script_name}: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
async def mark_migration_applied(self, script_name: str) -> None:
|
||||||
|
"""Отмечает миграцию как примененную."""
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = await self._get_connection()
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO migrations (script_name) VALUES (?)", (script_name,)
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
self.logger.info(f"Миграция {script_name} отмечена как примененная")
|
||||||
|
except aiosqlite.IntegrityError:
|
||||||
|
self.logger.warning(f"Миграция {script_name} уже была применена ранее")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Ошибка при отметке миграции {script_name}: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
async def create_table_from_sql(self, sql_script: str) -> None:
|
||||||
|
"""Создает таблицу из SQL скрипта. Используется в миграциях."""
|
||||||
|
await self._execute_query(sql_script)
|
||||||
@@ -1,29 +1,60 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, List, Tuple
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
from database.base import DatabaseConnection
|
from database.base import DatabaseConnection
|
||||||
from database.models import TelegramPost, PostContent, MessageContentLink
|
from database.models import MessageContentLink, PostContent, TelegramPost
|
||||||
|
|
||||||
|
|
||||||
class PostRepository(DatabaseConnection):
|
class PostRepository(DatabaseConnection):
|
||||||
"""Репозиторий для работы с постами из Telegram."""
|
"""Репозиторий для работы с постами из Telegram."""
|
||||||
|
|
||||||
async def create_tables(self):
|
async def create_tables(self):
|
||||||
"""Создание таблиц для постов."""
|
"""Создание таблиц для постов."""
|
||||||
# Таблица постов из Telegram
|
# Таблица постов из Telegram
|
||||||
post_query = '''
|
post_query = """
|
||||||
CREATE TABLE IF NOT EXISTS post_from_telegram_suggest (
|
CREATE TABLE IF NOT EXISTS post_from_telegram_suggest (
|
||||||
message_id INTEGER NOT NULL PRIMARY KEY,
|
message_id INTEGER NOT NULL PRIMARY KEY,
|
||||||
text TEXT,
|
text TEXT,
|
||||||
helper_text_message_id INTEGER,
|
helper_text_message_id INTEGER,
|
||||||
author_id INTEGER,
|
author_id INTEGER,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'suggest',
|
||||||
|
is_anonymous INTEGER,
|
||||||
|
published_message_id INTEGER,
|
||||||
FOREIGN KEY (author_id) REFERENCES our_users (user_id) ON DELETE CASCADE
|
FOREIGN KEY (author_id) REFERENCES our_users (user_id) ON DELETE CASCADE
|
||||||
)
|
)
|
||||||
'''
|
"""
|
||||||
await self._execute_query(post_query)
|
await self._execute_query(post_query)
|
||||||
|
|
||||||
|
# Добавляем поле published_message_id если его нет (для существующих БД)
|
||||||
|
try:
|
||||||
|
check_column_query = """
|
||||||
|
SELECT name FROM pragma_table_info('post_from_telegram_suggest')
|
||||||
|
WHERE name = 'published_message_id'
|
||||||
|
"""
|
||||||
|
existing_columns = await self._execute_query_with_result(check_column_query)
|
||||||
|
if not existing_columns:
|
||||||
|
await self._execute_query(
|
||||||
|
"ALTER TABLE post_from_telegram_suggest ADD COLUMN published_message_id INTEGER"
|
||||||
|
)
|
||||||
|
self.logger.info(
|
||||||
|
"Столбец published_message_id добавлен в post_from_telegram_suggest"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# Если проверка не удалась, пытаемся добавить столбец (может быть уже существует)
|
||||||
|
try:
|
||||||
|
await self._execute_query(
|
||||||
|
"ALTER TABLE post_from_telegram_suggest ADD COLUMN published_message_id INTEGER"
|
||||||
|
)
|
||||||
|
self.logger.info(
|
||||||
|
"Столбец published_message_id добавлен в post_from_telegram_suggest (fallback)"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Столбец уже существует, игнорируем ошибку
|
||||||
|
pass
|
||||||
|
|
||||||
# Таблица контента постов
|
# Таблица контента постов
|
||||||
content_query = '''
|
content_query = """
|
||||||
CREATE TABLE IF NOT EXISTS content_post_from_telegram (
|
CREATE TABLE IF NOT EXISTS content_post_from_telegram (
|
||||||
message_id INTEGER NOT NULL,
|
message_id INTEGER NOT NULL,
|
||||||
content_name TEXT NOT NULL,
|
content_name TEXT NOT NULL,
|
||||||
@@ -31,62 +62,202 @@ class PostRepository(DatabaseConnection):
|
|||||||
PRIMARY KEY (message_id, content_name),
|
PRIMARY KEY (message_id, content_name),
|
||||||
FOREIGN KEY (message_id) REFERENCES post_from_telegram_suggest (message_id) ON DELETE CASCADE
|
FOREIGN KEY (message_id) REFERENCES post_from_telegram_suggest (message_id) ON DELETE CASCADE
|
||||||
)
|
)
|
||||||
'''
|
"""
|
||||||
await self._execute_query(content_query)
|
await self._execute_query(content_query)
|
||||||
|
|
||||||
# Таблица связи сообщений с контентом
|
# Таблица связи сообщений с контентом
|
||||||
link_query = '''
|
link_query = """
|
||||||
CREATE TABLE IF NOT EXISTS message_link_to_content (
|
CREATE TABLE IF NOT EXISTS message_link_to_content (
|
||||||
post_id INTEGER NOT NULL,
|
post_id INTEGER NOT NULL,
|
||||||
message_id INTEGER NOT NULL,
|
message_id INTEGER NOT NULL,
|
||||||
PRIMARY KEY (post_id, message_id),
|
PRIMARY KEY (post_id, message_id),
|
||||||
FOREIGN KEY (post_id) REFERENCES post_from_telegram_suggest (message_id) ON DELETE CASCADE
|
FOREIGN KEY (post_id) REFERENCES post_from_telegram_suggest (message_id) ON DELETE CASCADE
|
||||||
)
|
)
|
||||||
'''
|
"""
|
||||||
await self._execute_query(link_query)
|
await self._execute_query(link_query)
|
||||||
|
|
||||||
|
# Таблица контента опубликованных постов
|
||||||
|
published_content_query = """
|
||||||
|
CREATE TABLE IF NOT EXISTS published_post_content (
|
||||||
|
published_message_id INTEGER NOT NULL,
|
||||||
|
content_name TEXT NOT NULL,
|
||||||
|
content_type TEXT,
|
||||||
|
published_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (published_message_id, content_name)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
await self._execute_query(published_content_query)
|
||||||
|
|
||||||
|
# Создаем индексы
|
||||||
|
try:
|
||||||
|
await self._execute_query(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_published_post_content_message_id ON published_post_content(published_message_id)"
|
||||||
|
)
|
||||||
|
await self._execute_query(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_post_from_telegram_suggest_published ON post_from_telegram_suggest(published_message_id)"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Индексы уже существуют, игнорируем ошибку
|
||||||
|
pass
|
||||||
|
|
||||||
self.logger.info("Таблицы для постов созданы")
|
self.logger.info("Таблицы для постов созданы")
|
||||||
|
|
||||||
async def add_post(self, post: TelegramPost) -> None:
|
async def add_post(self, post: TelegramPost) -> None:
|
||||||
"""Добавление поста."""
|
"""Добавление поста."""
|
||||||
if not post.created_at:
|
if not post.created_at:
|
||||||
post.created_at = int(datetime.now().timestamp())
|
post.created_at = int(datetime.now().timestamp())
|
||||||
|
status = post.status if post.status else "suggest"
|
||||||
|
# Преобразуем bool в int для SQLite (True -> 1, False -> 0, None -> None)
|
||||||
|
is_anonymous_int = (
|
||||||
|
None if post.is_anonymous is None else (1 if post.is_anonymous else 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Используем INSERT OR IGNORE чтобы избежать ошибок при повторном создании
|
||||||
query = """
|
query = """
|
||||||
INSERT INTO post_from_telegram_suggest (message_id, text, author_id, created_at)
|
INSERT OR IGNORE INTO post_from_telegram_suggest (message_id, text, author_id, created_at, status, is_anonymous)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
"""
|
"""
|
||||||
params = (post.message_id, post.text, post.author_id, post.created_at)
|
params = (
|
||||||
|
post.message_id,
|
||||||
|
post.text,
|
||||||
|
post.author_id,
|
||||||
|
post.created_at,
|
||||||
|
status,
|
||||||
|
is_anonymous_int,
|
||||||
|
)
|
||||||
|
|
||||||
await self._execute_query(query, params)
|
await self._execute_query(query, params)
|
||||||
self.logger.info(f"Пост добавлен: message_id={post.message_id}")
|
self.logger.info(
|
||||||
|
f"Пост добавлен (или уже существует): message_id={post.message_id}, text длина={len(post.text) if post.text else 0}, is_anonymous={is_anonymous_int}"
|
||||||
async def update_helper_message(self, message_id: int, helper_message_id: int) -> None:
|
)
|
||||||
|
|
||||||
|
async def update_helper_message(
|
||||||
|
self, message_id: int, helper_message_id: int
|
||||||
|
) -> None:
|
||||||
"""Обновление helper сообщения."""
|
"""Обновление helper сообщения."""
|
||||||
query = "UPDATE post_from_telegram_suggest SET helper_text_message_id = ? WHERE message_id = ?"
|
query = "UPDATE post_from_telegram_suggest SET helper_text_message_id = ? WHERE message_id = ?"
|
||||||
await self._execute_query(query, (helper_message_id, message_id))
|
await self._execute_query(query, (helper_message_id, message_id))
|
||||||
|
|
||||||
async def add_post_content(self, post_id: int, message_id: int, content_name: str, content_type: str) -> bool:
|
async def update_status_by_message_id(self, message_id: int, status: str) -> int:
|
||||||
|
"""Обновление статуса поста по message_id (одиночные посты). Возвращает число обновлённых строк."""
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = await self._get_connection()
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE post_from_telegram_suggest SET status = ? WHERE message_id = ?",
|
||||||
|
(status, message_id),
|
||||||
|
)
|
||||||
|
cur = await conn.execute("SELECT changes()")
|
||||||
|
row = await cur.fetchone()
|
||||||
|
n = row[0] if row else 0
|
||||||
|
await conn.commit()
|
||||||
|
if n == 0:
|
||||||
|
self.logger.warning(
|
||||||
|
f"update_status_by_message_id: 0 строк обновлено для message_id={message_id}, status={status}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.logger.info(
|
||||||
|
f"Статус поста message_id={message_id} обновлён на {status}"
|
||||||
|
)
|
||||||
|
return n
|
||||||
|
except Exception as e:
|
||||||
|
if conn:
|
||||||
|
await conn.rollback()
|
||||||
|
self.logger.error(
|
||||||
|
f"Ошибка при обновлении статуса message_id={message_id}: {e}"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
async def update_status_for_media_group_by_helper_id(
|
||||||
|
self, helper_message_id: int, status: str
|
||||||
|
) -> int:
|
||||||
|
"""Обновление статуса постов медиагруппы по helper_text_message_id. Возвращает число обновлённых строк."""
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = await self._get_connection()
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE post_from_telegram_suggest
|
||||||
|
SET status = ?
|
||||||
|
WHERE message_id = ? OR helper_text_message_id = ?
|
||||||
|
""",
|
||||||
|
(status, helper_message_id, helper_message_id),
|
||||||
|
)
|
||||||
|
cur = await conn.execute("SELECT changes()")
|
||||||
|
row = await cur.fetchone()
|
||||||
|
n = row[0] if row else 0
|
||||||
|
await conn.commit()
|
||||||
|
if n == 0:
|
||||||
|
self.logger.warning(
|
||||||
|
f"update_status_for_media_group_by_helper_id: 0 строк обновлено "
|
||||||
|
f"для helper_message_id={helper_message_id}, status={status}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.logger.info(
|
||||||
|
f"Статус медиагруппы helper_message_id={helper_message_id} обновлён на {status}"
|
||||||
|
)
|
||||||
|
return n
|
||||||
|
except Exception as e:
|
||||||
|
if conn:
|
||||||
|
await conn.rollback()
|
||||||
|
self.logger.error(
|
||||||
|
f"Ошибка при обновлении статуса медиагруппы helper_message_id={helper_message_id}: {e}"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
async def add_post_content(
|
||||||
|
self, post_id: int, message_id: int, content_name: str, content_type: str
|
||||||
|
) -> bool:
|
||||||
"""Добавление контента поста."""
|
"""Добавление контента поста."""
|
||||||
try:
|
try:
|
||||||
# Сначала добавляем связь
|
# Сначала добавляем связь
|
||||||
link_query = "INSERT OR IGNORE INTO message_link_to_content (post_id, message_id) VALUES (?, ?)"
|
link_query = "INSERT OR IGNORE INTO message_link_to_content (post_id, message_id) VALUES (?, ?)"
|
||||||
await self._execute_query(link_query, (post_id, message_id))
|
await self._execute_query(link_query, (post_id, message_id))
|
||||||
|
|
||||||
# Затем добавляем контент
|
# Затем добавляем контент
|
||||||
content_query = """
|
content_query = """
|
||||||
INSERT OR IGNORE INTO content_post_from_telegram (message_id, content_name, content_type)
|
INSERT OR IGNORE INTO content_post_from_telegram (message_id, content_name, content_type)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
"""
|
"""
|
||||||
await self._execute_query(content_query, (message_id, content_name, content_type))
|
await self._execute_query(
|
||||||
|
content_query, (message_id, content_name, content_type)
|
||||||
self.logger.info(f"Контент поста добавлен: post_id={post_id}, message_id={message_id}")
|
)
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
f"Контент поста добавлен: post_id={post_id}, message_id={message_id}"
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Ошибка при добавлении контента поста: {e}")
|
self.logger.error(f"Ошибка при добавлении контента поста: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def get_post_content_by_helper_id(self, helper_message_id: int) -> List[Tuple[str, str]]:
|
async def add_message_link(self, post_id: int, message_id: int) -> bool:
|
||||||
|
"""Добавляет связь между post_id и message_id в таблицу message_link_to_content."""
|
||||||
|
try:
|
||||||
|
self.logger.info(
|
||||||
|
f"Добавление связи: post_id={post_id}, message_id={message_id}"
|
||||||
|
)
|
||||||
|
link_query = "INSERT OR IGNORE INTO message_link_to_content (post_id, message_id) VALUES (?, ?)"
|
||||||
|
await self._execute_query(link_query, (post_id, message_id))
|
||||||
|
self.logger.info(
|
||||||
|
f"Связь успешно добавлена: post_id={post_id}, message_id={message_id}"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(
|
||||||
|
f"Ошибка при добавлении связи post_id={post_id}, message_id={message_id}: {e}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get_post_content_by_helper_id(
|
||||||
|
self, helper_message_id: int
|
||||||
|
) -> List[Tuple[str, str]]:
|
||||||
"""Получает контент поста по helper_text_message_id."""
|
"""Получает контент поста по helper_text_message_id."""
|
||||||
query = """
|
query = """
|
||||||
SELECT cpft.content_name, cpft.content_type
|
SELECT cpft.content_name, cpft.content_type
|
||||||
@@ -95,22 +266,44 @@ class PostRepository(DatabaseConnection):
|
|||||||
JOIN content_post_from_telegram cpft ON cpft.message_id = mltc.message_id
|
JOIN content_post_from_telegram cpft ON cpft.message_id = mltc.message_id
|
||||||
WHERE pft.helper_text_message_id = ?
|
WHERE pft.helper_text_message_id = ?
|
||||||
"""
|
"""
|
||||||
post_content = await self._execute_query_with_result(query, (helper_message_id,))
|
post_content = await self._execute_query_with_result(
|
||||||
|
query, (helper_message_id,)
|
||||||
|
)
|
||||||
|
|
||||||
self.logger.info(f"Получен контент поста: {len(post_content)} элементов")
|
self.logger.info(f"Получен контент поста: {len(post_content)} элементов")
|
||||||
return post_content
|
return post_content
|
||||||
|
|
||||||
|
async def get_post_content_by_message_id(
|
||||||
|
self, message_id: int
|
||||||
|
) -> List[Tuple[str, str]]:
|
||||||
|
"""Получает контент одиночного поста по message_id."""
|
||||||
|
query = """
|
||||||
|
SELECT cpft.content_name, cpft.content_type
|
||||||
|
FROM post_from_telegram_suggest pft
|
||||||
|
JOIN message_link_to_content mltc ON pft.message_id = mltc.post_id
|
||||||
|
JOIN content_post_from_telegram cpft ON cpft.message_id = mltc.message_id
|
||||||
|
WHERE pft.message_id = ? AND pft.helper_text_message_id IS NULL
|
||||||
|
"""
|
||||||
|
post_content = await self._execute_query_with_result(query, (message_id,))
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
f"Получен контент одиночного поста: {len(post_content)} элементов для message_id={message_id}"
|
||||||
|
)
|
||||||
|
return post_content
|
||||||
|
|
||||||
async def get_post_text_by_helper_id(self, helper_message_id: int) -> Optional[str]:
|
async def get_post_text_by_helper_id(self, helper_message_id: int) -> Optional[str]:
|
||||||
"""Получает текст поста по helper_text_message_id."""
|
"""Получает текст поста по helper_text_message_id."""
|
||||||
query = "SELECT text FROM post_from_telegram_suggest WHERE helper_text_message_id = ?"
|
query = "SELECT text FROM post_from_telegram_suggest WHERE helper_text_message_id = ?"
|
||||||
rows = await self._execute_query_with_result(query, (helper_message_id,))
|
rows = await self._execute_query_with_result(query, (helper_message_id,))
|
||||||
row = rows[0] if rows else None
|
row = rows[0] if rows else None
|
||||||
|
|
||||||
if row:
|
if row:
|
||||||
self.logger.info(f"Получен текст поста для helper_message_id={helper_message_id}")
|
self.logger.info(
|
||||||
|
f"Получен текст поста для helper_message_id={helper_message_id}"
|
||||||
|
)
|
||||||
return row[0]
|
return row[0]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_post_ids_by_helper_id(self, helper_message_id: int) -> List[int]:
|
async def get_post_ids_by_helper_id(self, helper_message_id: int) -> List[int]:
|
||||||
"""Получает ID сообщений по helper_text_message_id."""
|
"""Получает ID сообщений по helper_text_message_id."""
|
||||||
query = """
|
query = """
|
||||||
@@ -120,31 +313,299 @@ class PostRepository(DatabaseConnection):
|
|||||||
WHERE pft.helper_text_message_id = ?
|
WHERE pft.helper_text_message_id = ?
|
||||||
"""
|
"""
|
||||||
rows = await self._execute_query_with_result(query, (helper_message_id,))
|
rows = await self._execute_query_with_result(query, (helper_message_id,))
|
||||||
|
|
||||||
post_ids = [row[0] for row in rows]
|
post_ids = [row[0] for row in rows]
|
||||||
self.logger.info(f"Получены ID сообщений: {len(post_ids)} элементов")
|
self.logger.info(f"Получены ID сообщений: {len(post_ids)} элементов")
|
||||||
return post_ids
|
return post_ids
|
||||||
|
|
||||||
async def get_author_id_by_message_id(self, message_id: int) -> Optional[int]:
|
async def get_author_id_by_message_id(self, message_id: int) -> Optional[int]:
|
||||||
"""Получает ID автора по message_id."""
|
"""Получает ID автора по message_id."""
|
||||||
query = "SELECT author_id FROM post_from_telegram_suggest WHERE message_id = ?"
|
query = "SELECT author_id FROM post_from_telegram_suggest WHERE message_id = ?"
|
||||||
rows = await self._execute_query_with_result(query, (message_id,))
|
rows = await self._execute_query_with_result(query, (message_id,))
|
||||||
row = rows[0] if rows else None
|
row = rows[0] if rows else None
|
||||||
|
|
||||||
if row:
|
if row:
|
||||||
author_id = row[0]
|
author_id = row[0]
|
||||||
self.logger.info(f"Получен author_id: {author_id} для message_id={message_id}")
|
self.logger.info(
|
||||||
|
f"Получен author_id: {author_id} для message_id={message_id}"
|
||||||
|
)
|
||||||
return author_id
|
return author_id
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_author_id_by_helper_message_id(self, helper_message_id: int) -> Optional[int]:
|
async def get_author_id_by_helper_message_id(
|
||||||
|
self, helper_message_id: int
|
||||||
|
) -> Optional[int]:
|
||||||
"""Получает ID автора по helper_text_message_id."""
|
"""Получает ID автора по helper_text_message_id."""
|
||||||
query = "SELECT author_id FROM post_from_telegram_suggest WHERE helper_text_message_id = ?"
|
query = "SELECT author_id FROM post_from_telegram_suggest WHERE helper_text_message_id = ?"
|
||||||
rows = await self._execute_query_with_result(query, (helper_message_id,))
|
rows = await self._execute_query_with_result(query, (helper_message_id,))
|
||||||
row = rows[0] if rows else None
|
row = rows[0] if rows else None
|
||||||
|
|
||||||
if row:
|
if row:
|
||||||
author_id = row[0]
|
author_id = row[0]
|
||||||
self.logger.info(f"Получен author_id: {author_id} для helper_message_id={helper_message_id}")
|
self.logger.info(
|
||||||
|
f"Получен author_id: {author_id} для helper_message_id={helper_message_id}"
|
||||||
|
)
|
||||||
return author_id
|
return author_id
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def get_post_text_and_anonymity_by_message_id(
|
||||||
|
self, message_id: int
|
||||||
|
) -> Tuple[Optional[str], Optional[bool]]:
|
||||||
|
"""Получает текст и is_anonymous поста по message_id."""
|
||||||
|
query = "SELECT text, is_anonymous FROM post_from_telegram_suggest WHERE message_id = ?"
|
||||||
|
rows = await self._execute_query_with_result(query, (message_id,))
|
||||||
|
row = rows[0] if rows else None
|
||||||
|
|
||||||
|
if row:
|
||||||
|
text = row[0] or ""
|
||||||
|
is_anonymous_int = row[1]
|
||||||
|
# Преобразуем int в bool (1 -> True, 0 -> False, NULL -> None)
|
||||||
|
is_anonymous = None if is_anonymous_int is None else bool(is_anonymous_int)
|
||||||
|
self.logger.info(
|
||||||
|
f"Получены текст и is_anonymous для message_id={message_id}"
|
||||||
|
)
|
||||||
|
return text, is_anonymous
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
async def get_post_text_and_anonymity_by_helper_id(
|
||||||
|
self, helper_message_id: int
|
||||||
|
) -> Tuple[Optional[str], Optional[bool]]:
|
||||||
|
"""Получает текст и is_anonymous поста по helper_text_message_id."""
|
||||||
|
query = "SELECT text, is_anonymous FROM post_from_telegram_suggest WHERE helper_text_message_id = ?"
|
||||||
|
rows = await self._execute_query_with_result(query, (helper_message_id,))
|
||||||
|
row = rows[0] if rows else None
|
||||||
|
|
||||||
|
if row:
|
||||||
|
text = row[0] or ""
|
||||||
|
is_anonymous_int = row[1]
|
||||||
|
# Преобразуем int в bool (1 -> True, 0 -> False, NULL -> None)
|
||||||
|
is_anonymous = None if is_anonymous_int is None else bool(is_anonymous_int)
|
||||||
|
self.logger.info(
|
||||||
|
f"Получены текст и is_anonymous для helper_message_id={helper_message_id}"
|
||||||
|
)
|
||||||
|
return text, is_anonymous
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
async def update_published_message_id(
|
||||||
|
self, original_message_id: int, published_message_id: int
|
||||||
|
) -> None:
|
||||||
|
"""Обновляет published_message_id для опубликованного поста."""
|
||||||
|
query = "UPDATE post_from_telegram_suggest SET published_message_id = ? WHERE message_id = ?"
|
||||||
|
await self._execute_query(query, (published_message_id, original_message_id))
|
||||||
|
self.logger.info(
|
||||||
|
f"Обновлен published_message_id: {original_message_id} -> {published_message_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def add_published_post_content(
|
||||||
|
self, published_message_id: int, content_path: str, content_type: str
|
||||||
|
) -> bool:
|
||||||
|
"""Добавляет контент опубликованного поста."""
|
||||||
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
published_at = int(datetime.now().timestamp())
|
||||||
|
|
||||||
|
query = """
|
||||||
|
INSERT OR IGNORE INTO published_post_content
|
||||||
|
(published_message_id, content_name, content_type, published_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
"""
|
||||||
|
await self._execute_query(
|
||||||
|
query, (published_message_id, content_path, content_type, published_at)
|
||||||
|
)
|
||||||
|
self.logger.info(
|
||||||
|
f"Добавлен контент опубликованного поста: published_message_id={published_message_id}, type={content_type}"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(
|
||||||
|
f"Ошибка при добавлении контента опубликованного поста: {e}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get_published_post_content(
|
||||||
|
self, published_message_id: int
|
||||||
|
) -> List[Tuple[str, str]]:
|
||||||
|
"""Получает контент опубликованного поста."""
|
||||||
|
query = """
|
||||||
|
SELECT content_name, content_type
|
||||||
|
FROM published_post_content
|
||||||
|
WHERE published_message_id = ?
|
||||||
|
"""
|
||||||
|
post_content = await self._execute_query_with_result(
|
||||||
|
query, (published_message_id,)
|
||||||
|
)
|
||||||
|
self.logger.info(
|
||||||
|
f"Получен контент опубликованного поста: {len(post_content)} элементов для published_message_id={published_message_id}"
|
||||||
|
)
|
||||||
|
return post_content
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Методы для работы с ML-скорингом
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
async def update_ml_scores(self, message_id: int, ml_scores_json: str) -> bool:
|
||||||
|
"""
|
||||||
|
Обновляет ML-скоры для поста.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message_id: ID сообщения в группе модерации
|
||||||
|
ml_scores_json: JSON строка со скорами
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если обновлено успешно
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
query = "UPDATE post_from_telegram_suggest SET ml_scores = ? WHERE message_id = ?"
|
||||||
|
await self._execute_query(query, (ml_scores_json, message_id))
|
||||||
|
self.logger.info(f"ML-скоры обновлены для message_id={message_id}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(
|
||||||
|
f"Ошибка обновления ML-скоров для message_id={message_id}: {e}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get_ml_scores_by_message_id(self, message_id: int) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Получает ML-скоры для поста.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message_id: ID сообщения
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON строка со скорами или None
|
||||||
|
"""
|
||||||
|
query = "SELECT ml_scores FROM post_from_telegram_suggest WHERE message_id = ?"
|
||||||
|
rows = await self._execute_query_with_result(query, (message_id,))
|
||||||
|
if rows and rows[0][0]:
|
||||||
|
return rows[0][0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_post_text_by_message_id(self, message_id: int) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Получает текст поста по message_id.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message_id: ID сообщения
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Текст поста или None
|
||||||
|
"""
|
||||||
|
query = "SELECT text FROM post_from_telegram_suggest WHERE message_id = ?"
|
||||||
|
rows = await self._execute_query_with_result(query, (message_id,))
|
||||||
|
if rows and rows[0][0]:
|
||||||
|
return rows[0][0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_approved_posts_texts(self, limit: int = 1000) -> List[str]:
|
||||||
|
"""
|
||||||
|
Получает тексты опубликованных постов для обучения RAG.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Максимальное количество постов
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Список текстов
|
||||||
|
"""
|
||||||
|
query = """
|
||||||
|
SELECT text FROM post_from_telegram_suggest
|
||||||
|
WHERE status = 'approved'
|
||||||
|
AND text IS NOT NULL
|
||||||
|
AND text != ''
|
||||||
|
AND text != '^'
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
"""
|
||||||
|
rows = await self._execute_query_with_result(query, (limit,))
|
||||||
|
texts = [row[0] for row in rows if row[0]]
|
||||||
|
self.logger.info(f"Получено {len(texts)} опубликованных постов для обучения")
|
||||||
|
return texts
|
||||||
|
|
||||||
|
async def get_declined_posts_texts(self, limit: int = 1000) -> List[str]:
|
||||||
|
"""
|
||||||
|
Получает тексты отклоненных постов для обучения RAG.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Максимальное количество постов
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Список текстов
|
||||||
|
"""
|
||||||
|
query = """
|
||||||
|
SELECT text FROM post_from_telegram_suggest
|
||||||
|
WHERE status = 'declined'
|
||||||
|
AND text IS NOT NULL
|
||||||
|
AND text != ''
|
||||||
|
AND text != '^'
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
"""
|
||||||
|
rows = await self._execute_query_with_result(query, (limit,))
|
||||||
|
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
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from database.base import DatabaseConnection
|
from database.base import DatabaseConnection
|
||||||
from database.models import User
|
from database.models import User
|
||||||
|
|
||||||
|
|
||||||
class UserRepository(DatabaseConnection):
|
class UserRepository(DatabaseConnection):
|
||||||
"""Репозиторий для работы с пользователями."""
|
"""Репозиторий для работы с пользователями."""
|
||||||
|
|
||||||
async def create_tables(self):
|
async def create_tables(self):
|
||||||
"""Создание таблицы пользователей."""
|
"""Создание таблицы пользователей."""
|
||||||
query = '''
|
query = """
|
||||||
CREATE TABLE IF NOT EXISTS our_users (
|
CREATE TABLE IF NOT EXISTS our_users (
|
||||||
user_id INTEGER NOT NULL PRIMARY KEY,
|
user_id INTEGER NOT NULL PRIMARY KEY,
|
||||||
first_name TEXT,
|
first_name TEXT,
|
||||||
@@ -23,42 +24,56 @@ class UserRepository(DatabaseConnection):
|
|||||||
date_changed INTEGER NOT NULL,
|
date_changed INTEGER NOT NULL,
|
||||||
voice_bot_welcome_received BOOLEAN DEFAULT 0
|
voice_bot_welcome_received BOOLEAN DEFAULT 0
|
||||||
)
|
)
|
||||||
'''
|
"""
|
||||||
await self._execute_query(query)
|
await self._execute_query(query)
|
||||||
self.logger.info("Таблица пользователей создана")
|
self.logger.info("Таблица пользователей создана")
|
||||||
|
|
||||||
async def user_exists(self, user_id: int) -> bool:
|
async def user_exists(self, user_id: int) -> bool:
|
||||||
"""Проверяет, существует ли пользователь в базе данных."""
|
"""Проверяет, существует ли пользователь в базе данных."""
|
||||||
query = "SELECT user_id FROM our_users WHERE user_id = ?"
|
query = "SELECT user_id FROM our_users WHERE user_id = ?"
|
||||||
rows = await self._execute_query_with_result(query, (user_id,))
|
rows = await self._execute_query_with_result(query, (user_id,))
|
||||||
self.logger.info(f"Проверка существования пользователя: user_id={user_id}, результат={rows}")
|
self.logger.info(
|
||||||
|
f"Проверка существования пользователя: user_id={user_id}, результат={rows}"
|
||||||
|
)
|
||||||
return bool(len(rows))
|
return bool(len(rows))
|
||||||
|
|
||||||
async def add_user(self, user: User) -> None:
|
async def add_user(self, user: User) -> None:
|
||||||
"""Добавление нового пользователя с защитой от дублирования."""
|
"""Добавление нового пользователя с защитой от дублирования."""
|
||||||
if not user.date_added:
|
if not user.date_added:
|
||||||
user.date_added = int(datetime.now().timestamp())
|
user.date_added = int(datetime.now().timestamp())
|
||||||
if not user.date_changed:
|
if not user.date_changed:
|
||||||
user.date_changed = int(datetime.now().timestamp())
|
user.date_changed = int(datetime.now().timestamp())
|
||||||
|
|
||||||
query = """
|
query = """
|
||||||
INSERT OR IGNORE INTO our_users (user_id, first_name, full_name, username, is_bot,
|
INSERT OR IGNORE INTO our_users (user_id, first_name, full_name, username, is_bot,
|
||||||
language_code, emoji, has_stickers, date_added, date_changed, voice_bot_welcome_received)
|
language_code, emoji, has_stickers, date_added, date_changed, voice_bot_welcome_received)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
"""
|
"""
|
||||||
params = (user.user_id, user.first_name, user.full_name, user.username,
|
params = (
|
||||||
user.is_bot, user.language_code, user.emoji, user.has_stickers,
|
user.user_id,
|
||||||
user.date_added, user.date_changed, user.voice_bot_welcome_received)
|
user.first_name,
|
||||||
|
user.full_name,
|
||||||
|
user.username,
|
||||||
|
user.is_bot,
|
||||||
|
user.language_code,
|
||||||
|
user.emoji,
|
||||||
|
user.has_stickers,
|
||||||
|
user.date_added,
|
||||||
|
user.date_changed,
|
||||||
|
user.voice_bot_welcome_received,
|
||||||
|
)
|
||||||
|
|
||||||
await self._execute_query(query, params)
|
await self._execute_query(query, params)
|
||||||
self.logger.info(f"Пользователь обработан (создан или уже существует): {user.user_id}")
|
self.logger.info(
|
||||||
|
f"Пользователь обработан (создан или уже существует): {user.user_id}"
|
||||||
|
)
|
||||||
|
|
||||||
async def get_user_info(self, user_id: int) -> Optional[User]:
|
async def get_user_info(self, user_id: int) -> Optional[User]:
|
||||||
"""Получение информации о пользователе."""
|
"""Получение информации о пользователе."""
|
||||||
query = "SELECT username, full_name, has_stickers, emoji FROM our_users WHERE user_id = ?"
|
query = "SELECT username, full_name, has_stickers, emoji FROM our_users WHERE user_id = ?"
|
||||||
rows = await self._execute_query_with_result(query, (user_id,))
|
rows = await self._execute_query_with_result(query, (user_id,))
|
||||||
row = rows[0] if rows else None
|
row = rows[0] if rows else None
|
||||||
|
|
||||||
if row:
|
if row:
|
||||||
return User(
|
return User(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
@@ -66,16 +81,16 @@ class UserRepository(DatabaseConnection):
|
|||||||
full_name=row[1],
|
full_name=row[1],
|
||||||
username=row[0],
|
username=row[0],
|
||||||
has_stickers=bool(row[2]) if row[2] is not None else False,
|
has_stickers=bool(row[2]) if row[2] is not None else False,
|
||||||
emoji=row[3]
|
emoji=row[3],
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_user_by_id(self, user_id: int) -> Optional[User]:
|
async def get_user_by_id(self, user_id: int) -> Optional[User]:
|
||||||
"""Получение пользователя по ID."""
|
"""Получение пользователя по ID."""
|
||||||
query = "SELECT * FROM our_users WHERE user_id = ?"
|
query = "SELECT * FROM our_users WHERE user_id = ?"
|
||||||
rows = await self._execute_query_with_result(query, (user_id,))
|
rows = await self._execute_query_with_result(query, (user_id,))
|
||||||
row = rows[0] if rows else None
|
row = rows[0] if rows else None
|
||||||
|
|
||||||
if row:
|
if row:
|
||||||
return User(
|
return User(
|
||||||
user_id=row[0],
|
user_id=row[0],
|
||||||
@@ -88,58 +103,66 @@ class UserRepository(DatabaseConnection):
|
|||||||
emoji=row[7],
|
emoji=row[7],
|
||||||
date_added=row[8],
|
date_added=row[8],
|
||||||
date_changed=row[9],
|
date_changed=row[9],
|
||||||
voice_bot_welcome_received=bool(row[10]) if len(row) > 10 else False
|
voice_bot_welcome_received=bool(row[10]) if len(row) > 10 else False,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_username(self, user_id: int) -> Optional[str]:
|
async def get_username(self, user_id: int) -> Optional[str]:
|
||||||
"""Возвращает username пользователя."""
|
"""Возвращает username пользователя."""
|
||||||
query = "SELECT username FROM our_users WHERE user_id = ?"
|
query = "SELECT username FROM our_users WHERE user_id = ?"
|
||||||
rows = await self._execute_query_with_result(query, (user_id,))
|
rows = await self._execute_query_with_result(query, (user_id,))
|
||||||
row = rows[0] if rows else None
|
row = rows[0] if rows else None
|
||||||
|
|
||||||
if row:
|
if row:
|
||||||
username = row[0]
|
username = row[0]
|
||||||
self.logger.info(f"Username пользователя найден: user_id={user_id}, username={username}")
|
self.logger.info(
|
||||||
|
f"Username пользователя найден: user_id={user_id}, username={username}"
|
||||||
|
)
|
||||||
return username
|
return username
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_user_id_by_username(self, username: str) -> Optional[int]:
|
async def get_user_id_by_username(self, username: str) -> Optional[int]:
|
||||||
"""Возвращает user_id пользователя по username."""
|
"""Возвращает user_id пользователя по username."""
|
||||||
query = "SELECT user_id FROM our_users WHERE username = ?"
|
query = "SELECT user_id FROM our_users WHERE username = ?"
|
||||||
rows = await self._execute_query_with_result(query, (username,))
|
rows = await self._execute_query_with_result(query, (username,))
|
||||||
row = rows[0] if rows else None
|
row = rows[0] if rows else None
|
||||||
|
|
||||||
if row:
|
if row:
|
||||||
user_id = row[0]
|
user_id = row[0]
|
||||||
self.logger.info(f"User_id пользователя найден: username={username}, user_id={user_id}")
|
self.logger.info(
|
||||||
|
f"User_id пользователя найден: username={username}, user_id={user_id}"
|
||||||
|
)
|
||||||
return user_id
|
return user_id
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_full_name_by_id(self, user_id: int) -> Optional[str]:
|
async def get_full_name_by_id(self, user_id: int) -> Optional[str]:
|
||||||
"""Возвращает full_name пользователя."""
|
"""Возвращает full_name пользователя."""
|
||||||
query = "SELECT full_name FROM our_users WHERE user_id = ?"
|
query = "SELECT full_name FROM our_users WHERE user_id = ?"
|
||||||
rows = await self._execute_query_with_result(query, (user_id,))
|
rows = await self._execute_query_with_result(query, (user_id,))
|
||||||
row = rows[0] if rows else None
|
row = rows[0] if rows else None
|
||||||
|
|
||||||
if row:
|
if row:
|
||||||
full_name = row[0]
|
full_name = row[0]
|
||||||
self.logger.info(f"Full_name пользователя найден: user_id={user_id}, full_name={full_name}")
|
self.logger.info(
|
||||||
|
f"Full_name пользователя найден: user_id={user_id}, full_name={full_name}"
|
||||||
|
)
|
||||||
return full_name
|
return full_name
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_user_first_name(self, user_id: int) -> Optional[str]:
|
async def get_user_first_name(self, user_id: int) -> Optional[str]:
|
||||||
"""Возвращает first_name пользователя."""
|
"""Возвращает first_name пользователя."""
|
||||||
query = "SELECT first_name FROM our_users WHERE user_id = ?"
|
query = "SELECT first_name FROM our_users WHERE user_id = ?"
|
||||||
rows = await self._execute_query_with_result(query, (user_id,))
|
rows = await self._execute_query_with_result(query, (user_id,))
|
||||||
row = rows[0] if rows else None
|
row = rows[0] if rows else None
|
||||||
|
|
||||||
if row:
|
if row:
|
||||||
first_name = row[0]
|
first_name = row[0]
|
||||||
self.logger.info(f"First_name пользователя найден: user_id={user_id}, first_name={first_name}")
|
self.logger.info(
|
||||||
|
f"First_name пользователя найден: user_id={user_id}, first_name={first_name}"
|
||||||
|
)
|
||||||
return first_name
|
return first_name
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_all_user_ids(self) -> List[int]:
|
async def get_all_user_ids(self) -> List[int]:
|
||||||
"""Возвращает список всех user_id."""
|
"""Возвращает список всех user_id."""
|
||||||
query = "SELECT user_id FROM our_users"
|
query = "SELECT user_id FROM our_users"
|
||||||
@@ -147,20 +170,22 @@ class UserRepository(DatabaseConnection):
|
|||||||
user_ids = [row[0] for row in rows]
|
user_ids = [row[0] for row in rows]
|
||||||
self.logger.info(f"Получен список всех user_id: {user_ids}")
|
self.logger.info(f"Получен список всех user_id: {user_ids}")
|
||||||
return user_ids
|
return user_ids
|
||||||
|
|
||||||
async def get_last_users(self, limit: int = 30) -> List[tuple]:
|
async def get_last_users(self, limit: int = 30) -> List[tuple]:
|
||||||
"""Получение последних пользователей."""
|
"""Получение последних пользователей."""
|
||||||
query = "SELECT full_name, user_id FROM our_users ORDER BY date_changed DESC LIMIT ?"
|
query = "SELECT full_name, user_id FROM our_users ORDER BY date_changed DESC LIMIT ?"
|
||||||
rows = await self._execute_query_with_result(query, (limit,))
|
rows = await self._execute_query_with_result(query, (limit,))
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
async def update_user_date(self, user_id: int) -> None:
|
async def update_user_date(self, user_id: int) -> None:
|
||||||
"""Обновление даты последнего изменения пользователя."""
|
"""Обновление даты последнего изменения пользователя."""
|
||||||
date_changed = int(datetime.now().timestamp())
|
date_changed = int(datetime.now().timestamp())
|
||||||
query = "UPDATE our_users SET date_changed = ? WHERE user_id = ?"
|
query = "UPDATE our_users SET date_changed = ? WHERE user_id = ?"
|
||||||
await self._execute_query(query, (date_changed, user_id))
|
await self._execute_query(query, (date_changed, user_id))
|
||||||
|
|
||||||
async def update_user_info(self, user_id: int, username: str = None, full_name: str = None) -> None:
|
async def update_user_info(
|
||||||
|
self, user_id: int, username: str = None, full_name: str = None
|
||||||
|
) -> None:
|
||||||
"""Обновление информации о пользователе."""
|
"""Обновление информации о пользователе."""
|
||||||
if username and full_name:
|
if username and full_name:
|
||||||
query = "UPDATE our_users SET username = ?, full_name = ? WHERE user_id = ?"
|
query = "UPDATE our_users SET username = ?, full_name = ? WHERE user_id = ?"
|
||||||
@@ -173,85 +198,93 @@ class UserRepository(DatabaseConnection):
|
|||||||
params = (full_name, user_id)
|
params = (full_name, user_id)
|
||||||
else:
|
else:
|
||||||
return
|
return
|
||||||
|
|
||||||
await self._execute_query(query, params)
|
await self._execute_query(query, params)
|
||||||
|
|
||||||
async def update_user_emoji(self, user_id: int, emoji: str) -> None:
|
async def update_user_emoji(self, user_id: int, emoji: str) -> None:
|
||||||
"""Обновление эмодзи пользователя."""
|
"""Обновление эмодзи пользователя."""
|
||||||
query = "UPDATE our_users SET emoji = ? WHERE user_id = ?"
|
query = "UPDATE our_users SET emoji = ? WHERE user_id = ?"
|
||||||
await self._execute_query(query, (emoji, user_id))
|
await self._execute_query(query, (emoji, user_id))
|
||||||
|
|
||||||
async def update_stickers_info(self, user_id: int) -> None:
|
async def update_stickers_info(self, user_id: int) -> None:
|
||||||
"""Обновление информации о стикерах."""
|
"""Обновление информации о стикерах."""
|
||||||
query = "UPDATE our_users SET has_stickers = 1 WHERE user_id = ?"
|
query = "UPDATE our_users SET has_stickers = 1 WHERE user_id = ?"
|
||||||
await self._execute_query(query, (user_id,))
|
await self._execute_query(query, (user_id,))
|
||||||
|
|
||||||
async def get_stickers_info(self, user_id: int) -> bool:
|
async def get_stickers_info(self, user_id: int) -> bool:
|
||||||
"""Получение информации о стикерах."""
|
"""Получение информации о стикерах."""
|
||||||
query = "SELECT has_stickers FROM our_users WHERE user_id = ?"
|
query = "SELECT has_stickers FROM our_users WHERE user_id = ?"
|
||||||
rows = await self._execute_query_with_result(query, (user_id,))
|
rows = await self._execute_query_with_result(query, (user_id,))
|
||||||
row = rows[0] if rows else None
|
row = rows[0] if rows else None
|
||||||
return bool(row[0]) if row and row[0] is not None else False
|
return bool(row[0]) if row and row[0] is not None else False
|
||||||
|
|
||||||
async def check_emoji_exists(self, emoji: str) -> bool:
|
async def check_emoji_exists(self, emoji: str) -> bool:
|
||||||
"""Проверка существования эмодзи."""
|
"""Проверка существования эмодзи."""
|
||||||
query = "SELECT 1 FROM our_users WHERE emoji = ?"
|
query = "SELECT 1 FROM our_users WHERE emoji = ?"
|
||||||
rows = await self._execute_query_with_result(query, (emoji,))
|
rows = await self._execute_query_with_result(query, (emoji,))
|
||||||
row = rows[0] if rows else None
|
row = rows[0] if rows else None
|
||||||
return bool(row)
|
return bool(row)
|
||||||
|
|
||||||
async def get_user_emoji(self, user_id: int) -> str:
|
async def get_user_emoji(self, user_id: int) -> str:
|
||||||
"""
|
"""
|
||||||
Получает эмодзи пользователя.
|
Получает эмодзи пользователя.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_id: ID пользователя.
|
user_id: ID пользователя.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: Эмодзи пользователя или "Смайл еще не определен" если не установлен.
|
str: Эмодзи пользователя или "Смайл еще не определен" если не установлен.
|
||||||
"""
|
"""
|
||||||
query = "SELECT emoji FROM our_users WHERE user_id = ?"
|
query = "SELECT emoji FROM our_users WHERE user_id = ?"
|
||||||
rows = await self._execute_query_with_result(query, (user_id,))
|
rows = await self._execute_query_with_result(query, (user_id,))
|
||||||
row = rows[0] if rows else None
|
row = rows[0] if rows else None
|
||||||
|
|
||||||
if row and row[0]:
|
if row and row[0]:
|
||||||
emoji = row[0]
|
emoji = row[0]
|
||||||
self.logger.info(f"Эмодзи пользователя найден: user_id={user_id}, emoji={emoji}")
|
self.logger.info(
|
||||||
|
f"Эмодзи пользователя найден: user_id={user_id}, emoji={emoji}"
|
||||||
|
)
|
||||||
return str(emoji)
|
return str(emoji)
|
||||||
else:
|
else:
|
||||||
self.logger.info(f"Эмодзи пользователя не найден: user_id={user_id}")
|
self.logger.info(f"Эмодзи пользователя не найден: user_id={user_id}")
|
||||||
return "Смайл еще не определен"
|
return "Смайл еще не определен"
|
||||||
|
|
||||||
async def check_emoji_for_user(self, user_id: int) -> str:
|
async def check_emoji_for_user(self, user_id: int) -> str:
|
||||||
"""
|
"""
|
||||||
Проверяет, есть ли уже у пользователя назначенный emoji.
|
Проверяет, есть ли уже у пользователя назначенный emoji.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_id: ID пользователя.
|
user_id: ID пользователя.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: Эмодзи пользователя или "Смайл еще не определен" если не установлен.
|
str: Эмодзи пользователя или "Смайл еще не определен" если не установлен.
|
||||||
"""
|
"""
|
||||||
return await self.get_user_emoji(user_id)
|
return await self.get_user_emoji(user_id)
|
||||||
|
|
||||||
async def check_voice_bot_welcome_received(self, user_id: int) -> bool:
|
async def check_voice_bot_welcome_received(self, user_id: int) -> bool:
|
||||||
"""Проверяет, получал ли пользователь приветственное сообщение от voice_bot."""
|
"""Проверяет, получал ли пользователь приветственное сообщение от voice_bot."""
|
||||||
query = "SELECT voice_bot_welcome_received FROM our_users WHERE user_id = ?"
|
query = "SELECT voice_bot_welcome_received FROM our_users WHERE user_id = ?"
|
||||||
rows = await self._execute_query_with_result(query, (user_id,))
|
rows = await self._execute_query_with_result(query, (user_id,))
|
||||||
row = rows[0] if rows else None
|
row = rows[0] if rows else None
|
||||||
|
|
||||||
if row:
|
if row:
|
||||||
welcome_received = bool(row[0])
|
welcome_received = bool(row[0])
|
||||||
self.logger.info(f"Пользователь {user_id} получал приветствие: {welcome_received}")
|
self.logger.info(
|
||||||
|
f"Пользователь {user_id} получал приветствие: {welcome_received}"
|
||||||
|
)
|
||||||
return welcome_received
|
return welcome_received
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def mark_voice_bot_welcome_received(self, user_id: int) -> bool:
|
async def mark_voice_bot_welcome_received(self, user_id: int) -> bool:
|
||||||
"""Отмечает, что пользователь получил приветственное сообщение от voice_bot."""
|
"""Отмечает, что пользователь получил приветственное сообщение от voice_bot."""
|
||||||
try:
|
try:
|
||||||
query = "UPDATE our_users SET voice_bot_welcome_received = 1 WHERE user_id = ?"
|
query = (
|
||||||
|
"UPDATE our_users SET voice_bot_welcome_received = 1 WHERE user_id = ?"
|
||||||
|
)
|
||||||
await self._execute_query(query, (user_id,))
|
await self._execute_query(query, (user_id,))
|
||||||
self.logger.info(f"Пользователь {user_id} отмечен как получивший приветствие")
|
self.logger.info(
|
||||||
|
f"Пользователь {user_id} отмечен как получивший приветствие"
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Ошибка при отметке получения приветствия: {e}")
|
self.logger.error(f"Ошибка при отметке получения приветствия: {e}")
|
||||||
|
|||||||
@@ -1,79 +1,112 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from database.repositories.user_repository import UserRepository
|
|
||||||
from database.repositories.blacklist_repository import BlacklistRepository
|
|
||||||
from database.repositories.message_repository import MessageRepository
|
|
||||||
from database.repositories.post_repository import PostRepository
|
|
||||||
from database.repositories.admin_repository import AdminRepository
|
from database.repositories.admin_repository import AdminRepository
|
||||||
from database.repositories.audio_repository import AudioRepository
|
from database.repositories.audio_repository import AudioRepository
|
||||||
|
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
|
||||||
|
from database.repositories.user_repository import UserRepository
|
||||||
|
|
||||||
|
|
||||||
class RepositoryFactory:
|
class RepositoryFactory:
|
||||||
"""Фабрика для создания репозиториев."""
|
"""Фабрика для создания репозиториев."""
|
||||||
|
|
||||||
def __init__(self, db_path: str):
|
def __init__(self, db_path: str):
|
||||||
self.db_path = db_path
|
self.db_path = db_path
|
||||||
self._user_repo: Optional[UserRepository] = None
|
self._user_repo: Optional[UserRepository] = None
|
||||||
self._blacklist_repo: Optional[BlacklistRepository] = None
|
self._blacklist_repo: Optional[BlacklistRepository] = None
|
||||||
|
self._blacklist_history_repo: Optional[BlacklistHistoryRepository] = None
|
||||||
self._message_repo: Optional[MessageRepository] = None
|
self._message_repo: Optional[MessageRepository] = None
|
||||||
self._post_repo: Optional[PostRepository] = None
|
self._post_repo: Optional[PostRepository] = None
|
||||||
self._admin_repo: Optional[AdminRepository] = None
|
self._admin_repo: Optional[AdminRepository] = None
|
||||||
self._audio_repo: Optional[AudioRepository] = None
|
self._audio_repo: Optional[AudioRepository] = None
|
||||||
|
self._migration_repo: Optional[MigrationRepository] = None
|
||||||
|
self._bot_settings_repo: Optional[BotSettingsRepository] = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def users(self) -> UserRepository:
|
def users(self) -> UserRepository:
|
||||||
"""Возвращает репозиторий пользователей."""
|
"""Возвращает репозиторий пользователей."""
|
||||||
if self._user_repo is None:
|
if self._user_repo is None:
|
||||||
self._user_repo = UserRepository(self.db_path)
|
self._user_repo = UserRepository(self.db_path)
|
||||||
return self._user_repo
|
return self._user_repo
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def blacklist(self) -> BlacklistRepository:
|
def blacklist(self) -> BlacklistRepository:
|
||||||
"""Возвращает репозиторий черного списка."""
|
"""Возвращает репозиторий черного списка."""
|
||||||
if self._blacklist_repo is None:
|
if self._blacklist_repo is None:
|
||||||
self._blacklist_repo = BlacklistRepository(self.db_path)
|
self._blacklist_repo = BlacklistRepository(self.db_path)
|
||||||
return self._blacklist_repo
|
return self._blacklist_repo
|
||||||
|
|
||||||
|
@property
|
||||||
|
def blacklist_history(self) -> BlacklistHistoryRepository:
|
||||||
|
"""Возвращает репозиторий истории банов/разбанов."""
|
||||||
|
if self._blacklist_history_repo is None:
|
||||||
|
self._blacklist_history_repo = BlacklistHistoryRepository(self.db_path)
|
||||||
|
return self._blacklist_history_repo
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def messages(self) -> MessageRepository:
|
def messages(self) -> MessageRepository:
|
||||||
"""Возвращает репозиторий сообщений."""
|
"""Возвращает репозиторий сообщений."""
|
||||||
if self._message_repo is None:
|
if self._message_repo is None:
|
||||||
self._message_repo = MessageRepository(self.db_path)
|
self._message_repo = MessageRepository(self.db_path)
|
||||||
return self._message_repo
|
return self._message_repo
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def posts(self) -> PostRepository:
|
def posts(self) -> PostRepository:
|
||||||
"""Возвращает репозиторий постов."""
|
"""Возвращает репозиторий постов."""
|
||||||
if self._post_repo is None:
|
if self._post_repo is None:
|
||||||
self._post_repo = PostRepository(self.db_path)
|
self._post_repo = PostRepository(self.db_path)
|
||||||
return self._post_repo
|
return self._post_repo
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def admins(self) -> AdminRepository:
|
def admins(self) -> AdminRepository:
|
||||||
"""Возвращает репозиторий администраторов."""
|
"""Возвращает репозиторий администраторов."""
|
||||||
if self._admin_repo is None:
|
if self._admin_repo is None:
|
||||||
self._admin_repo = AdminRepository(self.db_path)
|
self._admin_repo = AdminRepository(self.db_path)
|
||||||
return self._admin_repo
|
return self._admin_repo
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def audio(self) -> AudioRepository:
|
def audio(self) -> AudioRepository:
|
||||||
"""Возвращает репозиторий аудио."""
|
"""Возвращает репозиторий аудио."""
|
||||||
if self._audio_repo is None:
|
if self._audio_repo is None:
|
||||||
self._audio_repo = AudioRepository(self.db_path)
|
self._audio_repo = AudioRepository(self.db_path)
|
||||||
return self._audio_repo
|
return self._audio_repo
|
||||||
|
|
||||||
|
@property
|
||||||
|
def migrations(self) -> MigrationRepository:
|
||||||
|
"""Возвращает репозиторий миграций."""
|
||||||
|
if self._migration_repo is None:
|
||||||
|
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):
|
async def create_all_tables(self):
|
||||||
"""Создает все таблицы в базе данных."""
|
"""Создает все таблицы в базе данных."""
|
||||||
|
await self.migrations.create_table() # Сначала создаем таблицу миграций
|
||||||
await self.users.create_tables()
|
await self.users.create_tables()
|
||||||
await self.blacklist.create_tables()
|
await self.blacklist.create_tables()
|
||||||
|
await self.blacklist_history.create_tables()
|
||||||
await self.messages.create_tables()
|
await self.messages.create_tables()
|
||||||
await self.posts.create_tables()
|
await self.posts.create_tables()
|
||||||
await self.admins.create_tables()
|
await self.admins.create_tables()
|
||||||
await self.audio.create_tables()
|
await self.audio.create_tables()
|
||||||
|
await self.bot_settings.create_table()
|
||||||
|
|
||||||
async def check_database_integrity(self):
|
async def check_database_integrity(self):
|
||||||
"""Проверяет целостность базы данных."""
|
"""Проверяет целостность базы данных."""
|
||||||
await self.users.check_database_integrity()
|
await self.users.check_database_integrity()
|
||||||
|
|
||||||
async def cleanup_wal_files(self):
|
async def cleanup_wal_files(self):
|
||||||
"""Очищает WAL файлы."""
|
"""Очищает WAL файлы."""
|
||||||
await self.users.cleanup_wal_files()
|
await self.users.cleanup_wal_files()
|
||||||
|
|||||||
@@ -40,6 +40,20 @@ CREATE TABLE IF NOT EXISTS blacklist (
|
|||||||
FOREIGN KEY (user_id) REFERENCES our_users(user_id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES our_users(user_id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Blacklist history for tracking all ban/unban events
|
||||||
|
CREATE TABLE IF NOT EXISTS blacklist_history (
|
||||||
|
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
message_for_user TEXT,
|
||||||
|
date_ban INTEGER NOT NULL,
|
||||||
|
date_unban INTEGER,
|
||||||
|
ban_author INTEGER,
|
||||||
|
created_at INTEGER DEFAULT (strftime('%s', 'now')),
|
||||||
|
updated_at INTEGER DEFAULT (strftime('%s', 'now')),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES our_users(user_id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (ban_author) REFERENCES our_users(user_id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
-- User message history
|
-- User message history
|
||||||
CREATE TABLE IF NOT EXISTS user_messages (
|
CREATE TABLE IF NOT EXISTS user_messages (
|
||||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -57,6 +71,9 @@ CREATE TABLE IF NOT EXISTS post_from_telegram_suggest (
|
|||||||
helper_text_message_id INTEGER,
|
helper_text_message_id INTEGER,
|
||||||
author_id INTEGER,
|
author_id INTEGER,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'suggest',
|
||||||
|
is_anonymous INTEGER,
|
||||||
|
published_message_id INTEGER,
|
||||||
FOREIGN KEY (author_id) REFERENCES our_users(user_id) ON DELETE CASCADE
|
FOREIGN KEY (author_id) REFERENCES our_users(user_id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -77,6 +94,15 @@ CREATE TABLE IF NOT EXISTS content_post_from_telegram (
|
|||||||
FOREIGN KEY (message_id) REFERENCES post_from_telegram_suggest(message_id) ON DELETE CASCADE
|
FOREIGN KEY (message_id) REFERENCES post_from_telegram_suggest(message_id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Content of published posts
|
||||||
|
CREATE TABLE IF NOT EXISTS published_post_content (
|
||||||
|
published_message_id INTEGER NOT NULL,
|
||||||
|
content_name TEXT NOT NULL,
|
||||||
|
content_type TEXT,
|
||||||
|
published_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (published_message_id, content_name)
|
||||||
|
);
|
||||||
|
|
||||||
-- Bot users information (user_id is now PRIMARY KEY)
|
-- Bot users information (user_id is now PRIMARY KEY)
|
||||||
CREATE TABLE IF NOT EXISTS our_users (
|
CREATE TABLE IF NOT EXISTS our_users (
|
||||||
user_id INTEGER NOT NULL PRIMARY KEY,
|
user_id INTEGER NOT NULL PRIMARY KEY,
|
||||||
@@ -100,6 +126,13 @@ CREATE TABLE IF NOT EXISTS audio_moderate (
|
|||||||
FOREIGN KEY (user_id) REFERENCES our_users(user_id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES our_users(user_id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Database migrations tracking
|
||||||
|
CREATE TABLE IF NOT EXISTS migrations (
|
||||||
|
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
script_name TEXT NOT NULL UNIQUE,
|
||||||
|
applied_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
-- Create indexes for better performance
|
-- Create indexes for better performance
|
||||||
-- Optimized index for user_audio_listens - only user_id for "show all audio listened by user X"
|
-- Optimized index for user_audio_listens - only user_id for "show all audio listened by user X"
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_audio_listens_user_id ON user_audio_listens(user_id);
|
CREATE INDEX IF NOT EXISTS idx_user_audio_listens_user_id ON user_audio_listens(user_id);
|
||||||
@@ -107,7 +140,12 @@ CREATE INDEX IF NOT EXISTS idx_audio_message_reference_author_id ON audio_messag
|
|||||||
CREATE INDEX IF NOT EXISTS idx_user_messages_user_id ON user_messages(user_id);
|
CREATE INDEX IF NOT EXISTS idx_user_messages_user_id ON user_messages(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_post_from_telegram_suggest_author_id ON post_from_telegram_suggest(author_id);
|
CREATE INDEX IF NOT EXISTS idx_post_from_telegram_suggest_author_id ON post_from_telegram_suggest(author_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_blacklist_date_to_unban ON blacklist(date_to_unban);
|
CREATE INDEX IF NOT EXISTS idx_blacklist_date_to_unban ON blacklist(date_to_unban);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_blacklist_history_user_id ON blacklist_history(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_blacklist_history_date_ban ON blacklist_history(date_ban);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_blacklist_history_date_unban ON blacklist_history(date_unban);
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_messages_date ON user_messages(date);
|
CREATE INDEX IF NOT EXISTS idx_user_messages_date ON user_messages(date);
|
||||||
CREATE INDEX IF NOT EXISTS idx_audio_message_reference_date ON audio_message_reference(date_added);
|
CREATE INDEX IF NOT EXISTS idx_audio_message_reference_date ON audio_message_reference(date_added);
|
||||||
CREATE INDEX IF NOT EXISTS idx_post_from_telegram_suggest_date ON post_from_telegram_suggest(created_at);
|
CREATE INDEX IF NOT EXISTS idx_post_from_telegram_suggest_date ON post_from_telegram_suggest(created_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_our_users_date_changed ON our_users(date_changed);
|
CREATE INDEX IF NOT EXISTS idx_our_users_date_changed ON our_users(date_changed);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_published_post_content_message_id ON published_post_content(published_message_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_post_from_telegram_suggest_published ON post_from_telegram_suggest(published_message_id);
|
||||||
|
|||||||
710
docs/IMPROVEMENTS.md
Normal file
710
docs/IMPROVEMENTS.md
Normal file
@@ -0,0 +1,710 @@
|
|||||||
|
# План улучшений проекта
|
||||||
|
|
||||||
|
Этот документ содержит список рекомендаций по улучшению кодовой базы проекта Telegram Helper Bot. Пункты отсортированы по приоритетам и могут быть использованы для планирования работ.
|
||||||
|
|
||||||
|
## Статус задач
|
||||||
|
|
||||||
|
- ⬜ Не начато
|
||||||
|
- 🟡 В работе
|
||||||
|
- ✅ Выполнено
|
||||||
|
- ❌ Отложено
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔴 Высокий приоритет
|
||||||
|
|
||||||
|
### 1. Стандартизация Dependency Injection
|
||||||
|
|
||||||
|
**Статус:** ⬜
|
||||||
|
|
||||||
|
**Проблема:**
|
||||||
|
В проекте используется смешанный подход к dependency injection:
|
||||||
|
- В некоторых местах используется `MagicData("bot_db")` и `MagicData("settings")`
|
||||||
|
- В других местах используется `**kwargs` и получение из `data`
|
||||||
|
- В сервисах напрямую вызывается `get_global_instance()`
|
||||||
|
|
||||||
|
**Текущее состояние:**
|
||||||
|
```python
|
||||||
|
# callback_handlers.py - смешанный подход
|
||||||
|
async def handler(call: CallbackQuery, settings: MagicData("settings")):
|
||||||
|
publish_service = get_post_publish_service() # Прямой вызов фабрики
|
||||||
|
|
||||||
|
async def handler(call: CallbackQuery, **kwargs):
|
||||||
|
ban_service = get_ban_service() # Прямой вызов фабрики
|
||||||
|
```
|
||||||
|
|
||||||
|
**Рекомендация:**
|
||||||
|
Стандартизировать на использование `MagicData` и `Annotated` везде:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from typing import Annotated
|
||||||
|
from aiogram.filters import MagicData
|
||||||
|
from helper_bot.handlers.admin.dependencies import BotDB, Settings
|
||||||
|
|
||||||
|
async def handler(
|
||||||
|
call: CallbackQuery,
|
||||||
|
bot_db: Annotated[AsyncBotDB, BotDB],
|
||||||
|
settings: Annotated[dict, Settings],
|
||||||
|
service: Annotated[PostPublishService, get_post_publish_service()]
|
||||||
|
):
|
||||||
|
# Использовать зависимости напрямую
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Файлы для изменения:**
|
||||||
|
- `helper_bot/handlers/callback/callback_handlers.py` (строки 47, 80, 109, 131, 182)
|
||||||
|
- `helper_bot/handlers/private/private_handlers.py`
|
||||||
|
- Все сервисы, которые используют `get_global_instance()`
|
||||||
|
|
||||||
|
**Оценка:** Средняя сложность, требует рефакторинга нескольких файлов
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Удаление `import *`
|
||||||
|
|
||||||
|
**Статус:** ⬜
|
||||||
|
|
||||||
|
**Проблема:**
|
||||||
|
В `voice_handler.py` используется импорт всех констант через `import *`, что затрудняет понимание зависимостей и может привести к конфликтам имен.
|
||||||
|
|
||||||
|
**Текущее состояние:**
|
||||||
|
```python
|
||||||
|
# helper_bot/handlers/voice/voice_handler.py
|
||||||
|
from helper_bot.handlers.voice.constants import *
|
||||||
|
```
|
||||||
|
|
||||||
|
**Рекомендация:**
|
||||||
|
Заменить на явные импорты:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from helper_bot.handlers.voice.constants import (
|
||||||
|
CONSTANT1,
|
||||||
|
CONSTANT2,
|
||||||
|
CONSTANT3,
|
||||||
|
# ... все используемые константы
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Файлы для изменения:**
|
||||||
|
- `helper_bot/handlers/voice/voice_handler.py` (строка 17)
|
||||||
|
|
||||||
|
**Оценка:** Низкая сложность, быстрое исправление
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Закрытие критичных TODO
|
||||||
|
|
||||||
|
**Статус:** ⬜
|
||||||
|
|
||||||
|
**Проблема:**
|
||||||
|
В коде есть несколько TODO комментариев, указывающих на технический долг и места, требующие рефакторинга.
|
||||||
|
|
||||||
|
**Список TODO:**
|
||||||
|
|
||||||
|
#### 3.1. Callback handlers - переход на MagicData
|
||||||
|
**Файл:** `helper_bot/handlers/callback/callback_handlers.py`
|
||||||
|
- Строка 47: `# TODO: переделать на MagicData`
|
||||||
|
- Строка 80: `# TODO: переделать на MagicData`
|
||||||
|
- Строка 109: `# TODO: переделать на MagicData`
|
||||||
|
- Строка 131: `# TODO: переделать на MagicData`
|
||||||
|
- Строка 182: `# TODO: переделать на MagicData`
|
||||||
|
|
||||||
|
**Решение:** Связано с задачей #1 (стандартизация DI)
|
||||||
|
|
||||||
|
#### 3.2. Metrics middleware - подключение к БД
|
||||||
|
**Файл:** `helper_bot/middlewares/metrics_middleware.py`
|
||||||
|
- Строка 153: `#TODO: Должна подключаться к базе данных, а не к глобальному экземпляру`
|
||||||
|
|
||||||
|
**Решение:**
|
||||||
|
```python
|
||||||
|
# Вместо
|
||||||
|
bdf = get_global_instance()
|
||||||
|
bot_db = bdf.get_db()
|
||||||
|
|
||||||
|
# Использовать dependency injection через MagicData
|
||||||
|
async def _update_active_users_metric(
|
||||||
|
self,
|
||||||
|
bot_db: Annotated[AsyncBotDB, BotDB]
|
||||||
|
):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.3. Voice handler - вынос логики
|
||||||
|
**Файл:** `helper_bot/handlers/voice/voice_handler.py`
|
||||||
|
- Строка 354: `#TODO: удалить логику из хендлера`
|
||||||
|
|
||||||
|
**Решение:** Переместить бизнес-логику в `VoiceBotService`
|
||||||
|
|
||||||
|
#### 3.4. Helper functions - архитектура
|
||||||
|
**Файл:** `helper_bot/utils/helper_func.py`
|
||||||
|
- Строка 35: `#TODO: поменять архитектуру и подключить правильный BotDB`
|
||||||
|
- Строка 145: `#TODO: Уверен можно укоротить`
|
||||||
|
|
||||||
|
**Решение:** Рефакторинг функций для использования dependency injection
|
||||||
|
|
||||||
|
#### 3.5. Group handlers - архитектура
|
||||||
|
**Файл:** `helper_bot/handlers/group/group_handlers.py`
|
||||||
|
- Строка 109: `#TODO: поменять архитектуру и подключить правильный BotDB`
|
||||||
|
|
||||||
|
**Решение:** Использовать dependency injection вместо прямого доступа к БД
|
||||||
|
|
||||||
|
**Оценка:** Средняя-высокая сложность, требует анализа каждого случая
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟡 Средний приоритет
|
||||||
|
|
||||||
|
### 4. Оптимизация работы с БД - Connection Pooling
|
||||||
|
|
||||||
|
**Статус:** ⬜
|
||||||
|
|
||||||
|
**Проблема:**
|
||||||
|
Каждый запрос к БД открывает новое соединение и закрывает его. При высокой нагрузке это неэффективно и может привести к проблемам с производительностью.
|
||||||
|
|
||||||
|
**Текущее состояние:**
|
||||||
|
```python
|
||||||
|
# database/base.py
|
||||||
|
async def _get_connection(self):
|
||||||
|
conn = await aiosqlite.connect(self.db_path)
|
||||||
|
# Настройка PRAGMA каждый раз
|
||||||
|
await conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
await conn.execute("PRAGMA journal_mode = WAL")
|
||||||
|
# ...
|
||||||
|
return conn
|
||||||
|
|
||||||
|
async def _execute_query(self, query: str, params: tuple = ()):
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = await self._get_connection() # Новое соединение каждый раз
|
||||||
|
result = await conn.execute(query, params)
|
||||||
|
await conn.commit()
|
||||||
|
return result
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
await conn.close() # Закрытие после каждого запроса
|
||||||
|
```
|
||||||
|
|
||||||
|
**Рекомендация:**
|
||||||
|
Реализовать переиспользование соединений или connection pool:
|
||||||
|
|
||||||
|
**Вариант 1: Переиспользование соединения в рамках транзакции**
|
||||||
|
```python
|
||||||
|
class DatabaseConnection:
|
||||||
|
def __init__(self, db_path: str):
|
||||||
|
self.db_path = db_path
|
||||||
|
self._connection: Optional[aiosqlite.Connection] = None
|
||||||
|
|
||||||
|
async def _get_connection(self):
|
||||||
|
if self._connection is None:
|
||||||
|
self._connection = await aiosqlite.connect(self.db_path)
|
||||||
|
# Настройка PRAGMA один раз
|
||||||
|
await self._connection.execute("PRAGMA foreign_keys = ON")
|
||||||
|
# ...
|
||||||
|
return self._connection
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
if self._connection:
|
||||||
|
await self._connection.close()
|
||||||
|
self._connection = None
|
||||||
|
```
|
||||||
|
|
||||||
|
**Вариант 2: Использование async context manager**
|
||||||
|
```python
|
||||||
|
async def _execute_query(self, query: str, params: tuple = ()):
|
||||||
|
async with aiosqlite.connect(self.db_path) as conn:
|
||||||
|
await conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
result = await conn.execute(query, params)
|
||||||
|
await conn.commit()
|
||||||
|
return result
|
||||||
|
```
|
||||||
|
|
||||||
|
**Файлы для изменения:**
|
||||||
|
- `database/base.py`
|
||||||
|
- `database/repository_factory.py` (добавить метод `close()`)
|
||||||
|
- `helper_bot/utils/base_dependency_factory.py` (закрытие соединений при shutdown)
|
||||||
|
|
||||||
|
**Оценка:** Средняя сложность, требует тестирования на производительность
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Улучшение обработки ошибок - декораторы
|
||||||
|
|
||||||
|
**Статус:** ⬜
|
||||||
|
|
||||||
|
**Проблема:**
|
||||||
|
В `callback_handlers.py` повторяется один и тот же блок обработки ошибок в каждом handler:
|
||||||
|
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
# Бизнес-логика
|
||||||
|
except UserBlockedBotError:
|
||||||
|
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
|
||||||
|
except (PostNotFoundError, PublishError) as e:
|
||||||
|
logger.error(f'Ошибка: {str(e)}')
|
||||||
|
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
|
||||||
|
except Exception as e:
|
||||||
|
if str(e) == ERROR_BOT_BLOCKED:
|
||||||
|
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
|
||||||
|
else:
|
||||||
|
important_logs = settings['Telegram']['important_logs']
|
||||||
|
await call.bot.send_message(
|
||||||
|
chat_id=important_logs,
|
||||||
|
text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
||||||
|
)
|
||||||
|
logger.error(f'Неожиданная ошибка: {str(e)}')
|
||||||
|
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Рекомендация:**
|
||||||
|
Создать декоратор для централизованной обработки ошибок:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# helper_bot/handlers/callback/decorators.py
|
||||||
|
from functools import wraps
|
||||||
|
from typing import Callable, Any
|
||||||
|
from aiogram.types import CallbackQuery
|
||||||
|
from logs.custom_logger import logger
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
def handle_callback_errors(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||||
|
"""Декоратор для обработки ошибок в callback handlers."""
|
||||||
|
@wraps(func)
|
||||||
|
async def wrapper(call: CallbackQuery, *args, **kwargs):
|
||||||
|
try:
|
||||||
|
return await func(call, *args, **kwargs)
|
||||||
|
except UserBlockedBotError:
|
||||||
|
await call.answer(
|
||||||
|
text=MESSAGE_ERROR,
|
||||||
|
show_alert=True,
|
||||||
|
cache_time=3
|
||||||
|
)
|
||||||
|
except (PostNotFoundError, PublishError) as e:
|
||||||
|
logger.error(f'Ошибка в {func.__name__}: {str(e)}')
|
||||||
|
await call.answer(
|
||||||
|
text=MESSAGE_ERROR,
|
||||||
|
show_alert=True,
|
||||||
|
cache_time=3
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
if str(e) == ERROR_BOT_BLOCKED:
|
||||||
|
await call.answer(
|
||||||
|
text=MESSAGE_ERROR,
|
||||||
|
show_alert=True,
|
||||||
|
cache_time=3
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Получить settings из kwargs или через dependency injection
|
||||||
|
settings = kwargs.get('settings')
|
||||||
|
if settings:
|
||||||
|
important_logs = settings['Telegram']['important_logs']
|
||||||
|
await call.bot.send_message(
|
||||||
|
chat_id=important_logs,
|
||||||
|
text=f"Произошла ошибка в {func.__name__}: {str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
||||||
|
)
|
||||||
|
logger.error(f'Неожиданная ошибка в {func.__name__}: {str(e)}')
|
||||||
|
await call.answer(
|
||||||
|
text=MESSAGE_ERROR,
|
||||||
|
show_alert=True,
|
||||||
|
cache_time=3
|
||||||
|
)
|
||||||
|
return wrapper
|
||||||
|
```
|
||||||
|
|
||||||
|
**Использование:**
|
||||||
|
```python
|
||||||
|
@callback_router.callback_query(F.data == CALLBACK_APPROVE)
|
||||||
|
@handle_callback_errors
|
||||||
|
@track_time("post_for_group", "callback_handlers")
|
||||||
|
@track_errors("callback_handlers", "post_for_group")
|
||||||
|
async def post_for_group(call: CallbackQuery, ...):
|
||||||
|
# Только бизнес-логика, без try-except
|
||||||
|
publish_service = get_post_publish_service()
|
||||||
|
await publish_service.publish_post(call)
|
||||||
|
await call.answer(text=MESSAGE_PUBLISHED, cache_time=3)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Файлы для изменения:**
|
||||||
|
- Создать `helper_bot/handlers/callback/decorators.py`
|
||||||
|
- Рефакторинг `helper_bot/handlers/callback/callback_handlers.py`
|
||||||
|
|
||||||
|
**Оценка:** Средняя сложность, требует тестирования всех сценариев
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Валидация настроек при старте
|
||||||
|
|
||||||
|
**Статус:** ⬜
|
||||||
|
|
||||||
|
**Проблема:**
|
||||||
|
Настройки загружаются из `.env` без валидации. Отсутствие обязательных настроек обнаруживается только во время выполнения, что затрудняет отладку.
|
||||||
|
|
||||||
|
**Текущее состояние:**
|
||||||
|
```python
|
||||||
|
# helper_bot/utils/base_dependency_factory.py
|
||||||
|
def _load_settings_from_env(self):
|
||||||
|
self.settings['Telegram'] = {
|
||||||
|
'bot_token': os.getenv('BOT_TOKEN', ''), # Может быть пустой строкой
|
||||||
|
# ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Рекомендация:**
|
||||||
|
Добавить валидацию обязательных настроек:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class BaseDependencyFactory:
|
||||||
|
REQUIRED_SETTINGS = {
|
||||||
|
'Telegram': ['bot_token'],
|
||||||
|
'S3': ['endpoint_url', 'access_key', 'secret_key', 'bucket_name'] # Если S3 включен
|
||||||
|
}
|
||||||
|
|
||||||
|
def _validate_settings(self):
|
||||||
|
"""Валидирует обязательные настройки."""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Проверка Telegram настроек
|
||||||
|
for key in self.REQUIRED_SETTINGS['Telegram']:
|
||||||
|
value = self.settings['Telegram'].get(key)
|
||||||
|
if not value:
|
||||||
|
errors.append(f"Telegram.{key} is required but not set")
|
||||||
|
|
||||||
|
# Проверка S3 настроек (если включен)
|
||||||
|
if self.settings['S3']['enabled']:
|
||||||
|
for key in self.REQUIRED_SETTINGS['S3']:
|
||||||
|
value = self.settings['S3'].get(key)
|
||||||
|
if not value:
|
||||||
|
errors.append(f"S3.{key} is required when S3 is enabled but not set")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
error_msg = "Configuration errors:\n" + "\n".join(f" - {e}" for e in errors)
|
||||||
|
raise ValueError(error_msg)
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# ... существующий код ...
|
||||||
|
self._load_settings_from_env()
|
||||||
|
self._validate_settings() # Добавить валидацию
|
||||||
|
self._init_s3_storage()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Файлы для изменения:**
|
||||||
|
- `helper_bot/utils/base_dependency_factory.py`
|
||||||
|
|
||||||
|
**Оценка:** Низкая сложность, быстрое добавление
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. Исправление RepositoryFactory
|
||||||
|
|
||||||
|
**Статус:** ⬜
|
||||||
|
|
||||||
|
**Проблема:**
|
||||||
|
Методы `check_database_integrity()` и `cleanup_wal_files()` в `RepositoryFactory` вызываются только для репозитория `users`, хотя должны применяться ко всем репозиториям или к базе данных в целом.
|
||||||
|
|
||||||
|
**Текущее состояние:**
|
||||||
|
```python
|
||||||
|
# database/repository_factory.py
|
||||||
|
async def check_database_integrity(self):
|
||||||
|
"""Проверяет целостность базы данных."""
|
||||||
|
await self.users.check_database_integrity() # Только users?
|
||||||
|
|
||||||
|
async def cleanup_wal_files(self):
|
||||||
|
"""Очищает WAL файлы."""
|
||||||
|
await self.users.cleanup_wal_files() # Только users?
|
||||||
|
```
|
||||||
|
|
||||||
|
**Рекомендация:**
|
||||||
|
Проверка целостности и очистка WAL должны выполняться один раз для всей БД, а не для каждого репозитория:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def check_database_integrity(self):
|
||||||
|
"""Проверяет целостность базы данных."""
|
||||||
|
# Использовать любой репозиторий для доступа к БД
|
||||||
|
await self.users.check_database_integrity()
|
||||||
|
|
||||||
|
async def cleanup_wal_files(self):
|
||||||
|
"""Очищает WAL файлы."""
|
||||||
|
# Использовать любой репозиторий для доступа к БД
|
||||||
|
await self.users.cleanup_wal_files()
|
||||||
|
```
|
||||||
|
|
||||||
|
Или лучше - вынести эти методы в `DatabaseConnection` и вызывать через любой репозиторий (текущая реализация уже правильная, но можно улучшить документацию).
|
||||||
|
|
||||||
|
**Альтернатива:** Создать отдельный класс `DatabaseManager` для операций на уровне БД.
|
||||||
|
|
||||||
|
**Файлы для изменения:**
|
||||||
|
- `database/repository_factory.py` (улучшить документацию)
|
||||||
|
- Возможно создать `database/database_manager.py`
|
||||||
|
|
||||||
|
**Оценка:** Низкая сложность, в основном документация
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟢 Низкий приоритет
|
||||||
|
|
||||||
|
### 8. Добавление кэширования (Redis)
|
||||||
|
|
||||||
|
**Статус:** ⬜
|
||||||
|
|
||||||
|
**Проблема:**
|
||||||
|
Часто запрашиваемые данные (например, список администраторов, настройки пользователей) загружаются из БД при каждом запросе, что создает лишнюю нагрузку на базу данных.
|
||||||
|
|
||||||
|
**Рекомендация:**
|
||||||
|
Добавить Redis для кэширования часто используемых данных:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# helper_bot/utils/cache.py
|
||||||
|
import redis.asyncio as redis
|
||||||
|
from typing import Optional, Any
|
||||||
|
import json
|
||||||
|
from helper_bot.utils.base_dependency_factory import get_global_instance
|
||||||
|
|
||||||
|
class CacheService:
|
||||||
|
def __init__(self):
|
||||||
|
bdf = get_global_instance()
|
||||||
|
settings = bdf.get_settings()
|
||||||
|
self.redis_client = None
|
||||||
|
|
||||||
|
if settings.get('Redis', {}).get('enabled', False):
|
||||||
|
self.redis_client = redis.from_url(
|
||||||
|
settings['Redis']['url'],
|
||||||
|
decode_responses=True
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get(self, key: str) -> Optional[Any]:
|
||||||
|
"""Получить значение из кэша."""
|
||||||
|
if not self.redis_client:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
value = await self.redis_client.get(key)
|
||||||
|
if value:
|
||||||
|
return json.loads(value)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка получения из кэша: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def set(self, key: str, value: Any, ttl: int = 3600):
|
||||||
|
"""Установить значение в кэш."""
|
||||||
|
if not self.redis_client:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.redis_client.setex(
|
||||||
|
key,
|
||||||
|
ttl,
|
||||||
|
json.dumps(value)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка записи в кэш: {e}")
|
||||||
|
|
||||||
|
async def delete(self, key: str):
|
||||||
|
"""Удалить значение из кэша."""
|
||||||
|
if not self.redis_client:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.redis_client.delete(key)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка удаления из кэша: {e}")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Использование:**
|
||||||
|
```python
|
||||||
|
# В репозиториях или сервисах
|
||||||
|
cache = CacheService()
|
||||||
|
|
||||||
|
# Получение с кэшированием
|
||||||
|
async def get_admin_list(self):
|
||||||
|
cache_key = "admin_list"
|
||||||
|
cached = await cache.get(cache_key)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
# Загрузка из БД
|
||||||
|
admins = await self._load_from_db()
|
||||||
|
|
||||||
|
# Сохранение в кэш на 1 час
|
||||||
|
await cache.set(cache_key, admins, ttl=3600)
|
||||||
|
return admins
|
||||||
|
```
|
||||||
|
|
||||||
|
**Данные для кэширования:**
|
||||||
|
- Список администраторов
|
||||||
|
- Настройки пользователей (если редко меняются)
|
||||||
|
- Статистика (активные пользователи за день)
|
||||||
|
- Черный список (с коротким TTL)
|
||||||
|
|
||||||
|
**Файлы для изменения:**
|
||||||
|
- Создать `helper_bot/utils/cache.py`
|
||||||
|
- Добавить настройки Redis в `BaseDependencyFactory`
|
||||||
|
- Обновить репозитории для использования кэша
|
||||||
|
|
||||||
|
**Оценка:** Средняя сложность, требует настройки Redis инфраструктуры
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. Улучшение Type Hints
|
||||||
|
|
||||||
|
**Статус:** ⬜
|
||||||
|
|
||||||
|
**Проблема:**
|
||||||
|
Некоторые методы возвращают `dict` без указания структуры, что затрудняет понимание API и использование IDE.
|
||||||
|
|
||||||
|
**Пример:**
|
||||||
|
```python
|
||||||
|
def get_settings(self):
|
||||||
|
return self.settings # Какой тип? Dict[str, Any]?
|
||||||
|
```
|
||||||
|
|
||||||
|
**Рекомендация:**
|
||||||
|
Использовать `TypedDict` для структурированных словарей:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from typing import TypedDict, Dict, Any
|
||||||
|
|
||||||
|
class TelegramSettings(TypedDict):
|
||||||
|
bot_token: str
|
||||||
|
listen_bot_token: str
|
||||||
|
preview_link: bool
|
||||||
|
main_public: str
|
||||||
|
group_for_posts: int
|
||||||
|
# ...
|
||||||
|
|
||||||
|
class SettingsDict(TypedDict):
|
||||||
|
Telegram: TelegramSettings
|
||||||
|
Settings: Dict[str, bool]
|
||||||
|
Metrics: Dict[str, Any]
|
||||||
|
S3: Dict[str, Any]
|
||||||
|
|
||||||
|
class BaseDependencyFactory:
|
||||||
|
def get_settings(self) -> SettingsDict:
|
||||||
|
return self.settings
|
||||||
|
```
|
||||||
|
|
||||||
|
**Файлы для изменения:**
|
||||||
|
- `helper_bot/utils/base_dependency_factory.py`
|
||||||
|
- Создать `helper_bot/utils/types.py` для типов
|
||||||
|
|
||||||
|
**Оценка:** Средняя сложность, требует обновления всех мест использования
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. Расширение тестового покрытия
|
||||||
|
|
||||||
|
**Статус:** ⬜
|
||||||
|
|
||||||
|
**Проблема:**
|
||||||
|
Некоторые компоненты не покрыты тестами или имеют недостаточное покрытие.
|
||||||
|
|
||||||
|
**Рекомендация:**
|
||||||
|
Добавить тесты для:
|
||||||
|
|
||||||
|
1. **Middleware:**
|
||||||
|
- `DependenciesMiddleware` - проверка внедрения зависимостей
|
||||||
|
- `BlacklistMiddleware` - проверка блокировки пользователей
|
||||||
|
- `RateLimitMiddleware` - проверка ограничений
|
||||||
|
|
||||||
|
2. **BaseDependencyFactory:**
|
||||||
|
- Инициализация с валидными настройками
|
||||||
|
- Инициализация с невалидными настройками
|
||||||
|
- Получение зависимостей
|
||||||
|
|
||||||
|
3. **Интеграционные тесты:**
|
||||||
|
- Полные сценарии обработки сообщений
|
||||||
|
- Сценарии с ошибками
|
||||||
|
- Сценарии с rate limiting
|
||||||
|
|
||||||
|
**Файлы для создания:**
|
||||||
|
- `tests/test_dependencies_middleware.py`
|
||||||
|
- `tests/test_base_dependency_factory.py`
|
||||||
|
- `tests/test_integration_handlers.py`
|
||||||
|
|
||||||
|
**Оценка:** Высокая сложность, требует времени на написание тестов
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11. Улучшение логирования
|
||||||
|
|
||||||
|
**Статус:** ⬜
|
||||||
|
|
||||||
|
**Проблема:**
|
||||||
|
В коде много `logger.info()` там, где можно использовать `logger.debug()` для детальной отладки. Это приводит к засорению логов в production.
|
||||||
|
|
||||||
|
**Рекомендация:**
|
||||||
|
Пересмотреть уровни логирования:
|
||||||
|
|
||||||
|
- `logger.debug()` - детальная отладочная информация (шаги выполнения, промежуточные значения)
|
||||||
|
- `logger.info()` - важные события (старт/остановка бота, критические действия пользователей)
|
||||||
|
- `logger.warning()` - предупреждения (нестандартные ситуации, которые не критичны)
|
||||||
|
- `logger.error()` - ошибки (исключения, сбои)
|
||||||
|
|
||||||
|
**Примеры для изменения:**
|
||||||
|
```python
|
||||||
|
# Было
|
||||||
|
logger.info(f"DependenciesMiddleware: внедрены зависимости для {type(event).__name__}")
|
||||||
|
|
||||||
|
# Стало
|
||||||
|
logger.debug(f"DependenciesMiddleware: внедрены зависимости для {type(event).__name__}")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Файлы для изменения:**
|
||||||
|
- Все файлы с избыточным `logger.info()`
|
||||||
|
|
||||||
|
**Оценка:** Низкая сложность, но требует времени на ревью всех логов
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 12. Документация проекта
|
||||||
|
|
||||||
|
**Статус:** ⬜
|
||||||
|
|
||||||
|
**Проблема:**
|
||||||
|
Отсутствует общая документация проекта, что затрудняет onboarding новых разработчиков.
|
||||||
|
|
||||||
|
**Рекомендация:**
|
||||||
|
Создать следующие документы:
|
||||||
|
|
||||||
|
1. **README.md** (в корне проекта):
|
||||||
|
- Описание проекта
|
||||||
|
- Требования
|
||||||
|
- Установка и настройка
|
||||||
|
- Запуск
|
||||||
|
- Структура проекта
|
||||||
|
|
||||||
|
2. **docs/ARCHITECTURE.md**:
|
||||||
|
- Детальное описание архитектуры
|
||||||
|
- Диаграммы компонентов
|
||||||
|
- Паттерны проектирования
|
||||||
|
|
||||||
|
3. **docs/DEPLOYMENT.md**:
|
||||||
|
- Инструкции по развертыванию
|
||||||
|
- Настройка окружения
|
||||||
|
- Мониторинг
|
||||||
|
|
||||||
|
4. **docs/DEVELOPMENT.md**:
|
||||||
|
- Руководство для разработчиков
|
||||||
|
- Процесс разработки
|
||||||
|
- Code style guide (ссылка на .cursor/rules)
|
||||||
|
|
||||||
|
**Оценка:** Средняя сложность, требует времени на написание
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Статистика
|
||||||
|
|
||||||
|
- **Всего задач:** 12
|
||||||
|
- **Высокий приоритет:** 3
|
||||||
|
- **Средний приоритет:** 4
|
||||||
|
- **Низкий приоритет:** 5
|
||||||
|
|
||||||
|
## 📝 Заметки
|
||||||
|
|
||||||
|
- Большинство задач высокого приоритета связаны между собой (стандартизация DI решит несколько TODO)
|
||||||
|
- Задачи среднего приоритета улучшают производительность и качество кода
|
||||||
|
- Задачи низкого приоритета улучшают developer experience и поддерживаемость
|
||||||
|
|
||||||
|
## 🔄 Обновления
|
||||||
|
|
||||||
|
- **2026-01-25:** Создан первоначальный список улучшений на основе анализа кодовой базы
|
||||||
|
- **2026-01-25:** Добавлена задача #8 по кэшированию (Redis)
|
||||||
|
- **2026-01-25:** Создан документ `PYTHON_VERSION_MANAGEMENT.md` с рекомендациями по унификации версий Python
|
||||||
309
docs/OPERATIONS.md
Normal file
309
docs/OPERATIONS.md
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
# Операционные команды для управления ботом
|
||||||
|
|
||||||
|
> **⚠️ ВАЖНО:** Все команды выполняются из корневой директории проекта
|
||||||
|
|
||||||
|
## 🔧 Основные команды
|
||||||
|
|
||||||
|
### Запуск и остановка
|
||||||
|
```bash
|
||||||
|
# Запустить всю инфраструктуру (Prometheus + Бот)
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Запустить только бота
|
||||||
|
docker-compose up -d telegram-bot
|
||||||
|
|
||||||
|
# Запустить только Prometheus
|
||||||
|
docker-compose up -d prometheus
|
||||||
|
|
||||||
|
# Остановить все сервисы
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# Остановить только бота
|
||||||
|
docker-compose stop telegram-bot
|
||||||
|
|
||||||
|
# Остановить только Prometheus
|
||||||
|
docker-compose stop prometheus
|
||||||
|
```
|
||||||
|
|
||||||
|
### Сборка
|
||||||
|
```bash
|
||||||
|
# Собрать все контейнеры
|
||||||
|
docker-compose build
|
||||||
|
|
||||||
|
# Собрать только бота
|
||||||
|
docker-compose build telegram-bot
|
||||||
|
|
||||||
|
# Собрать только Prometheus
|
||||||
|
docker-compose build prometheus
|
||||||
|
|
||||||
|
# Пересобрать и запустить все
|
||||||
|
docker-compose up -d --build
|
||||||
|
|
||||||
|
# Пересобрать и запустить только бота
|
||||||
|
docker-compose up -d --build telegram-bot
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Мониторинг и логи
|
||||||
|
|
||||||
|
### Просмотр логов
|
||||||
|
```bash
|
||||||
|
# Логи всех сервисов
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Логи только бота
|
||||||
|
docker-compose logs -f telegram-bot
|
||||||
|
|
||||||
|
# Логи Prometheus
|
||||||
|
docker-compose logs -f prometheus
|
||||||
|
|
||||||
|
# Логи в реальном времени (последние 100 строк)
|
||||||
|
docker-compose logs -f --tail=100
|
||||||
|
```
|
||||||
|
|
||||||
|
### Статус и здоровье
|
||||||
|
```bash
|
||||||
|
# Статус всех контейнеров
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
# Проверить здоровье всех сервисов
|
||||||
|
docker-compose ps | grep -E "(unhealthy|starting)"
|
||||||
|
|
||||||
|
# Проверить здоровье бота
|
||||||
|
curl -f http://localhost:8080/health || echo "❌ Бот недоступен"
|
||||||
|
|
||||||
|
# Проверить здоровье Prometheus
|
||||||
|
curl -f http://localhost:9090/-/healthy || echo "❌ Prometheus недоступен"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔄 Управление сервисами
|
||||||
|
|
||||||
|
### Перезапуск
|
||||||
|
```bash
|
||||||
|
# Перезапустить все сервисы
|
||||||
|
docker-compose restart
|
||||||
|
|
||||||
|
# Перезапустить только бота
|
||||||
|
docker-compose restart telegram-bot
|
||||||
|
|
||||||
|
# Перезапустить только Prometheus
|
||||||
|
docker-compose restart prometheus
|
||||||
|
```
|
||||||
|
|
||||||
|
### Обновление
|
||||||
|
```bash
|
||||||
|
# Обновить код и перезапустить
|
||||||
|
git pull origin main && docker-compose up -d --build
|
||||||
|
|
||||||
|
# Обновить только бота
|
||||||
|
git pull origin main && docker-compose up -d --build telegram-bot
|
||||||
|
|
||||||
|
# Обновить только Prometheus
|
||||||
|
docker-compose pull prometheus && docker-compose up -d prometheus
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧪 Тестирование
|
||||||
|
|
||||||
|
### Запуск тестов
|
||||||
|
```bash
|
||||||
|
# Запустить все тесты
|
||||||
|
docker-compose run --rm telegram-bot sh -c "pip install --no-cache-dir -r requirements-dev.txt && pytest"
|
||||||
|
|
||||||
|
# Тесты с покрытием
|
||||||
|
docker-compose run --rm telegram-bot sh -c "pip install --no-cache-dir -r requirements-dev.txt && pytest --cov=helper_bot --cov-report=term-missing"
|
||||||
|
|
||||||
|
# Тесты только бота
|
||||||
|
docker-compose run --rm telegram-bot sh -c "pip install --no-cache-dir -r requirements-dev.txt && pytest helper_bot/"
|
||||||
|
|
||||||
|
# Тесты с HTML отчетом
|
||||||
|
docker-compose run --rm telegram-bot sh -c "pip install --no-cache-dir -r requirements-dev.txt && pytest --cov=helper_bot --cov-report=html"
|
||||||
|
|
||||||
|
# Тесты конкретного модуля
|
||||||
|
docker-compose run --rm telegram-bot sh -c "pip install --no-cache-dir -r requirements-dev.txt && pytest helper_bot/handlers/admin/"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛠️ Разработка
|
||||||
|
|
||||||
|
### Отладка
|
||||||
|
```bash
|
||||||
|
# Проверить версию Python в контейнере
|
||||||
|
docker exec bots_telegram_bot python --version
|
||||||
|
|
||||||
|
# Открыть shell в контейнере бота
|
||||||
|
docker exec -it bots_telegram_bot sh
|
||||||
|
|
||||||
|
# Проверить установленные пакеты
|
||||||
|
docker exec bots_telegram_bot pip list
|
||||||
|
|
||||||
|
# Проверить переменные окружения
|
||||||
|
docker exec bots_telegram_bot env | grep TELEGRAM
|
||||||
|
|
||||||
|
# Проверить логи бота в реальном времени
|
||||||
|
docker exec bots_telegram_bot tail -f /app/logs/helper_bot_$(date +%Y-%m-%d).log
|
||||||
|
```
|
||||||
|
|
||||||
|
### База данных
|
||||||
|
```bash
|
||||||
|
# Создать backup базы
|
||||||
|
tar -czf "backup-$(date +%Y%m%d-%H%M%S).tar.gz" database/ logs/ .env
|
||||||
|
|
||||||
|
# Восстановить из backup
|
||||||
|
tar -xzf backup-20241231-120000.tar.gz
|
||||||
|
|
||||||
|
# Подключиться к базе данных
|
||||||
|
docker exec -it bots_telegram_bot sqlite3 /app/database/tg-bot-database.db
|
||||||
|
|
||||||
|
# Проверить размер базы данных
|
||||||
|
docker exec bots_telegram_bot ls -lh /app/database/tg-bot-database.db
|
||||||
|
|
||||||
|
# Очистить логи (⚠️ ОСТОРОЖНО!)
|
||||||
|
docker exec bots_telegram_bot find /app/logs -name "*.log" -mtime +7 -delete
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚨 Аварийные ситуации
|
||||||
|
|
||||||
|
### Диагностика
|
||||||
|
```bash
|
||||||
|
# Проверить использование ресурсов
|
||||||
|
docker stats --no-stream
|
||||||
|
|
||||||
|
# Проверить сетевые соединения
|
||||||
|
docker network ls
|
||||||
|
docker network inspect prod_bots_network
|
||||||
|
|
||||||
|
# Проверить логи ошибок
|
||||||
|
docker-compose logs | grep -i error
|
||||||
|
|
||||||
|
# Проверить использование диска
|
||||||
|
docker system df
|
||||||
|
|
||||||
|
# Проверить свободное место
|
||||||
|
docker exec bots_telegram_bot df -h
|
||||||
|
```
|
||||||
|
|
||||||
|
### Восстановление
|
||||||
|
```bash
|
||||||
|
# Принудительная перезагрузка всех сервисов
|
||||||
|
docker-compose down && docker-compose up -d
|
||||||
|
|
||||||
|
# Очистка всех контейнеров и образов
|
||||||
|
docker-compose down -v --rmi all
|
||||||
|
docker system prune -f
|
||||||
|
|
||||||
|
# Восстановление из последнего backup
|
||||||
|
ls -t backup-*.tar.gz | head -1 | xargs -I {} tar -xzf {}
|
||||||
|
|
||||||
|
# Принудительная перезагрузка только бота
|
||||||
|
docker-compose stop telegram-bot
|
||||||
|
docker-compose rm -f telegram-bot
|
||||||
|
docker-compose up -d --build telegram-bot
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📱 Доступ к сервисам
|
||||||
|
|
||||||
|
### Веб-интерфейсы
|
||||||
|
- **Prometheus**: http://localhost:9090
|
||||||
|
- **Бот Health**: http://localhost:8080/health
|
||||||
|
- **Бот Metrics**: http://localhost:8080/metrics
|
||||||
|
|
||||||
|
### Полезные команды
|
||||||
|
```bash
|
||||||
|
# Открыть Prometheus в браузере
|
||||||
|
open http://localhost:9090
|
||||||
|
|
||||||
|
# Открыть метрики бота в браузере
|
||||||
|
open http://localhost:8080/metrics
|
||||||
|
|
||||||
|
# Открыть health check бота в браузере
|
||||||
|
open http://localhost:8080/health
|
||||||
|
|
||||||
|
# Проверить доступность сервисов
|
||||||
|
curl -s http://localhost:8080/health | jq . || echo "Бот недоступен"
|
||||||
|
curl -s http://localhost:9090/-/healthy || echo "Prometheus недоступен"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔍 Отладка проблем
|
||||||
|
|
||||||
|
### Частые проблемы
|
||||||
|
```bash
|
||||||
|
# Бот не отвечает
|
||||||
|
docker-compose restart telegram-bot && docker-compose logs -f telegram-bot
|
||||||
|
|
||||||
|
# Prometheus недоступен
|
||||||
|
docker-compose restart prometheus && curl -f http://localhost:9090/-/healthy
|
||||||
|
|
||||||
|
# Проблемы с базой данных
|
||||||
|
docker exec bots_telegram_bot sqlite3 /app/database/tg-bot-database.db ".tables"
|
||||||
|
|
||||||
|
# Проблемы с сетью
|
||||||
|
docker network inspect prod_bots_network
|
||||||
|
|
||||||
|
# Проблемы с правами доступа
|
||||||
|
docker exec bots_telegram_bot ls -la /app/database/
|
||||||
|
docker exec bots_telegram_bot ls -la /app/logs/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Полезные alias'ы для .bashrc/.zshrc
|
||||||
|
```bash
|
||||||
|
# Добавить в ~/.bashrc или ~/.zshrc
|
||||||
|
alias bot='cd /Users/andrejkatyhin/PycharmProjects/prod && docker-compose'
|
||||||
|
alias bot-logs='cd /Users/andrejkatyhin/PycharmProjects/prod && docker-compose logs -f telegram-bot'
|
||||||
|
alias bot-restart='cd /Users/andrejkatyhin/PycharmProjects/prod && docker-compose restart telegram-bot'
|
||||||
|
alias bot-status='cd /Users/andrejkatyhin/PycharmProjects/prod && docker-compose ps'
|
||||||
|
alias bot-shell='cd /Users/andrejkatyhin/PycharmProjects/prod && docker exec -it bots_telegram_bot sh'
|
||||||
|
alias prometheus='cd /Users/andrejkatyhin/PycharmProjects/prod && docker-compose logs -f prometheus'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Примеры использования
|
||||||
|
|
||||||
|
### Типичный workflow разработки
|
||||||
|
```bash
|
||||||
|
# 1. Внести изменения в код
|
||||||
|
cd /Users/andrejkatyhin/PycharmProjects/prod/bots/telegram-helper-bot
|
||||||
|
# ... редактируем код ...
|
||||||
|
|
||||||
|
# 2. Пересобрать и перезапустить бота
|
||||||
|
cd /Users/andrejkatyhin/PycharmProjects/prod
|
||||||
|
docker-compose up -d --build telegram-bot
|
||||||
|
|
||||||
|
# 3. Проверить логи
|
||||||
|
docker-compose logs -f telegram-bot
|
||||||
|
|
||||||
|
# 4. Запустить тесты
|
||||||
|
docker-compose run --rm telegram-bot sh -c "pip install --no-cache-dir -r requirements-dev.txt && pytest helper_bot/"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Мониторинг в продакшене
|
||||||
|
```bash
|
||||||
|
# Проверить здоровье всех сервисов
|
||||||
|
docker-compose ps | grep -E "(unhealthy|starting)"
|
||||||
|
|
||||||
|
# Посмотреть статус
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
# Проверить логи ошибок
|
||||||
|
docker-compose logs | grep -i error
|
||||||
|
|
||||||
|
# Создать backup
|
||||||
|
tar -czf "backup-$(date +%Y%m%d-%H%M%S).tar.gz" database/ logs/ .env
|
||||||
|
|
||||||
|
# Проверить метрики
|
||||||
|
curl -s http://localhost:8080/metrics | head -20
|
||||||
|
```
|
||||||
|
|
||||||
|
### Отладка проблем
|
||||||
|
```bash
|
||||||
|
# Бот не запускается
|
||||||
|
docker-compose logs telegram-bot
|
||||||
|
|
||||||
|
# Проверить конфигурацию
|
||||||
|
docker exec bots_telegram_bot cat /app/.env
|
||||||
|
|
||||||
|
# Проверить права доступа к файлам
|
||||||
|
docker exec bots_telegram_bot ls -la /app/
|
||||||
|
|
||||||
|
# Проверить сетевые соединения
|
||||||
|
docker exec bots_telegram_bot netstat -tulpn
|
||||||
|
|
||||||
|
# Проверить процессы
|
||||||
|
docker exec bots_telegram_bot ps aux
|
||||||
|
```
|
||||||
24
env.example
24
env.example
@@ -12,6 +12,14 @@ IMPORTANT_LOGS=-1001234567890
|
|||||||
ARCHIVE=-1001234567890
|
ARCHIVE=-1001234567890
|
||||||
TEST_GROUP=-1001234567890
|
TEST_GROUP=-1001234567890
|
||||||
|
|
||||||
|
# S3 Storage (для хранения медиафайлов опубликованных постов)
|
||||||
|
S3_ENABLED=false
|
||||||
|
S3_ENDPOINT_URL=https://api.s3.ru
|
||||||
|
S3_ACCESS_KEY=your_s3_access_key_here
|
||||||
|
S3_SECRET_KEY=your_s3_secret_key_here
|
||||||
|
S3_BUCKET_NAME=your_s3_bucket_name
|
||||||
|
S3_REGION=us-east-1
|
||||||
|
|
||||||
# Bot Settings
|
# Bot Settings
|
||||||
PREVIEW_LINK=false
|
PREVIEW_LINK=false
|
||||||
LOGS=false
|
LOGS=false
|
||||||
@@ -27,3 +35,19 @@ METRICS_PORT=8080
|
|||||||
# Logging
|
# Logging
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
LOG_RETENTION_DAYS=30
|
LOG_RETENTION_DAYS=30
|
||||||
|
|
||||||
|
# ML Scoring - RAG API
|
||||||
|
# Включает оценку постов через внешний RAG API сервис
|
||||||
|
RAG_ENABLED=false
|
||||||
|
RAG_API_URL=http://xx.xxx.xx.xx/api/v1
|
||||||
|
RAG_API_KEY=your_rag_api_key_here
|
||||||
|
RAG_API_TIMEOUT=30
|
||||||
|
RAG_TEST_MODE=false
|
||||||
|
|
||||||
|
# ML Scoring - DeepSeek API
|
||||||
|
# Включает оценку постов через DeepSeek API
|
||||||
|
DEEPSEEK_ENABLED=false
|
||||||
|
DEEPSEEK_API_KEY=your_deepseek_api_key_here
|
||||||
|
DEEPSEEK_API_URL=https://api.deepseek.com/v1/chat/completions
|
||||||
|
DEEPSEEK_MODEL=deepseek-chat
|
||||||
|
DEEPSEEK_TIMEOUT=30
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Конфигурация для rate limiting
|
Конфигурация для rate limiting
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -8,26 +9,28 @@ from typing import Optional
|
|||||||
@dataclass
|
@dataclass
|
||||||
class RateLimitSettings:
|
class RateLimitSettings:
|
||||||
"""Настройки rate limiting для разных типов сообщений"""
|
"""Настройки rate limiting для разных типов сообщений"""
|
||||||
|
|
||||||
# Основные настройки
|
# Основные настройки
|
||||||
messages_per_second: float = 0.5 # Максимум 0.5 сообщений в секунду на чат
|
messages_per_second: float = 0.5 # Максимум 0.5 сообщений в секунду на чат
|
||||||
burst_limit: int = 2 # Максимум 2 сообщения подряд
|
burst_limit: int = 2 # Максимум 2 сообщения подряд
|
||||||
retry_after_multiplier: float = 1.5 # Множитель для увеличения задержки при retry
|
retry_after_multiplier: float = 1.5 # Множитель для увеличения задержки при retry
|
||||||
max_retry_delay: float = 30.0 # Максимальная задержка между попытками
|
max_retry_delay: float = 30.0 # Максимальная задержка между попытками
|
||||||
max_retries: int = 3 # Максимальное количество повторных попыток
|
max_retries: int = 3 # Максимальное количество повторных попыток
|
||||||
|
|
||||||
# Специальные настройки для разных типов сообщений
|
# Специальные настройки для разных типов сообщений
|
||||||
voice_message_delay: float = 2.0 # Дополнительная задержка для голосовых сообщений
|
voice_message_delay: float = 2.0 # Дополнительная задержка для голосовых сообщений
|
||||||
media_message_delay: float = 1.5 # Дополнительная задержка для медиа сообщений
|
media_message_delay: float = 1.5 # Дополнительная задержка для медиа сообщений
|
||||||
text_message_delay: float = 1.0 # Дополнительная задержка для текстовых сообщений
|
text_message_delay: float = 1.0 # Дополнительная задержка для текстовых сообщений
|
||||||
|
|
||||||
# Настройки для разных типов чатов
|
# Настройки для разных типов чатов
|
||||||
private_chat_multiplier: float = 1.0 # Множитель для приватных чатов
|
private_chat_multiplier: float = 1.0 # Множитель для приватных чатов
|
||||||
group_chat_multiplier: float = 0.8 # Множитель для групповых чатов
|
group_chat_multiplier: float = 0.8 # Множитель для групповых чатов
|
||||||
channel_multiplier: float = 0.6 # Множитель для каналов
|
channel_multiplier: float = 0.6 # Множитель для каналов
|
||||||
|
|
||||||
# Глобальные ограничения
|
# Глобальные ограничения
|
||||||
global_messages_per_second: float = 10.0 # Максимум 10 сообщений в секунду глобально
|
global_messages_per_second: float = (
|
||||||
|
10.0 # Максимум 10 сообщений в секунду глобально
|
||||||
|
)
|
||||||
global_burst_limit: int = 20 # Максимум 20 сообщений подряд глобально
|
global_burst_limit: int = 20 # Максимум 20 сообщений подряд глобально
|
||||||
|
|
||||||
|
|
||||||
@@ -37,7 +40,7 @@ DEVELOPMENT_CONFIG = RateLimitSettings(
|
|||||||
burst_limit=3,
|
burst_limit=3,
|
||||||
retry_after_multiplier=1.2,
|
retry_after_multiplier=1.2,
|
||||||
max_retry_delay=15.0,
|
max_retry_delay=15.0,
|
||||||
max_retries=2
|
max_retries=2,
|
||||||
)
|
)
|
||||||
|
|
||||||
PRODUCTION_CONFIG = RateLimitSettings(
|
PRODUCTION_CONFIG = RateLimitSettings(
|
||||||
@@ -48,7 +51,7 @@ PRODUCTION_CONFIG = RateLimitSettings(
|
|||||||
max_retries=3,
|
max_retries=3,
|
||||||
voice_message_delay=2.5,
|
voice_message_delay=2.5,
|
||||||
media_message_delay=2.0,
|
media_message_delay=2.0,
|
||||||
text_message_delay=1.5
|
text_message_delay=1.5,
|
||||||
)
|
)
|
||||||
|
|
||||||
STRICT_CONFIG = RateLimitSettings(
|
STRICT_CONFIG = RateLimitSettings(
|
||||||
@@ -59,46 +62,45 @@ STRICT_CONFIG = RateLimitSettings(
|
|||||||
max_retries=5,
|
max_retries=5,
|
||||||
voice_message_delay=3.0,
|
voice_message_delay=3.0,
|
||||||
media_message_delay=2.5,
|
media_message_delay=2.5,
|
||||||
text_message_delay=2.0
|
text_message_delay=2.0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_rate_limit_config(environment: str = "production") -> RateLimitSettings:
|
def get_rate_limit_config(environment: str = "production") -> RateLimitSettings:
|
||||||
"""
|
"""
|
||||||
Получает конфигурацию rate limiting в зависимости от окружения
|
Получает конфигурацию rate limiting в зависимости от окружения
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
environment: Окружение ('development', 'production', 'strict')
|
environment: Окружение ('development', 'production', 'strict')
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
RateLimitSettings: Конфигурация для указанного окружения
|
RateLimitSettings: Конфигурация для указанного окружения
|
||||||
"""
|
"""
|
||||||
configs = {
|
configs = {
|
||||||
"development": DEVELOPMENT_CONFIG,
|
"development": DEVELOPMENT_CONFIG,
|
||||||
"production": PRODUCTION_CONFIG,
|
"production": PRODUCTION_CONFIG,
|
||||||
"strict": STRICT_CONFIG
|
"strict": STRICT_CONFIG,
|
||||||
}
|
}
|
||||||
|
|
||||||
return configs.get(environment, PRODUCTION_CONFIG)
|
return configs.get(environment, PRODUCTION_CONFIG)
|
||||||
|
|
||||||
|
|
||||||
def get_adaptive_config(
|
def get_adaptive_config(
|
||||||
current_error_rate: float,
|
current_error_rate: float, base_config: Optional[RateLimitSettings] = None
|
||||||
base_config: Optional[RateLimitSettings] = None
|
|
||||||
) -> RateLimitSettings:
|
) -> RateLimitSettings:
|
||||||
"""
|
"""
|
||||||
Получает адаптивную конфигурацию на основе текущего уровня ошибок
|
Получает адаптивную конфигурацию на основе текущего уровня ошибок
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
current_error_rate: Текущий уровень ошибок (0.0 - 1.0)
|
current_error_rate: Текущий уровень ошибок (0.0 - 1.0)
|
||||||
base_config: Базовая конфигурация
|
base_config: Базовая конфигурация
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
RateLimitSettings: Адаптированная конфигурация
|
RateLimitSettings: Адаптированная конфигурация
|
||||||
"""
|
"""
|
||||||
if base_config is None:
|
if base_config is None:
|
||||||
base_config = PRODUCTION_CONFIG
|
base_config = PRODUCTION_CONFIG
|
||||||
|
|
||||||
# Если уровень ошибок высокий, ужесточаем ограничения
|
# Если уровень ошибок высокий, ужесточаем ограничения
|
||||||
if current_error_rate > 0.1: # Более 10% ошибок
|
if current_error_rate > 0.1: # Более 10% ошибок
|
||||||
return RateLimitSettings(
|
return RateLimitSettings(
|
||||||
@@ -109,9 +111,9 @@ def get_adaptive_config(
|
|||||||
max_retries=base_config.max_retries + 1,
|
max_retries=base_config.max_retries + 1,
|
||||||
voice_message_delay=base_config.voice_message_delay * 1.5,
|
voice_message_delay=base_config.voice_message_delay * 1.5,
|
||||||
media_message_delay=base_config.media_message_delay * 1.3,
|
media_message_delay=base_config.media_message_delay * 1.3,
|
||||||
text_message_delay=base_config.text_message_delay * 1.2
|
text_message_delay=base_config.text_message_delay * 1.2,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Если уровень ошибок низкий, можно немного ослабить ограничения
|
# Если уровень ошибок низкий, можно немного ослабить ограничения
|
||||||
elif current_error_rate < 0.01: # Менее 1% ошибок
|
elif current_error_rate < 0.01: # Менее 1% ошибок
|
||||||
return RateLimitSettings(
|
return RateLimitSettings(
|
||||||
@@ -122,8 +124,8 @@ def get_adaptive_config(
|
|||||||
max_retries=max(1, base_config.max_retries - 1),
|
max_retries=max(1, base_config.max_retries - 1),
|
||||||
voice_message_delay=base_config.voice_message_delay * 0.8,
|
voice_message_delay=base_config.voice_message_delay * 0.8,
|
||||||
media_message_delay=base_config.media_message_delay * 0.9,
|
media_message_delay=base_config.media_message_delay * 0.9,
|
||||||
text_message_delay=base_config.text_message_delay * 0.9
|
text_message_delay=base_config.text_message_delay * 0.9,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Возвращаем базовую конфигурацию
|
# Возвращаем базовую конфигурацию
|
||||||
return base_config
|
return base_config
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from aiogram.types import Message
|
|||||||
|
|
||||||
|
|
||||||
class ChatTypeFilter(BaseFilter): # [1]
|
class ChatTypeFilter(BaseFilter): # [1]
|
||||||
def __init__(self, chat_type: Union[str, list]): # [2]
|
def __init__(self, chat_type: Union[str, list]): # [2]
|
||||||
self.chat_type = chat_type
|
self.chat_type = chat_type
|
||||||
|
|
||||||
async def __call__(self, message: Message) -> bool: # [3]
|
async def __call__(self, message: Message) -> bool: # [3]
|
||||||
|
|||||||
@@ -1,37 +1,37 @@
|
|||||||
from .admin_handlers import admin_router
|
from .admin_handlers import admin_router
|
||||||
from .dependencies import AdminAccessMiddleware, BotDB, Settings
|
from .dependencies import AdminAccessMiddleware, BotDB, Settings
|
||||||
from .services import AdminService, User, BannedUser
|
|
||||||
from .exceptions import (
|
from .exceptions import (
|
||||||
AdminError,
|
AdminAccessDeniedError,
|
||||||
AdminAccessDeniedError,
|
AdminError,
|
||||||
UserNotFoundError,
|
InvalidInputError,
|
||||||
InvalidInputError,
|
UserAlreadyBannedError,
|
||||||
UserAlreadyBannedError
|
UserNotFoundError,
|
||||||
)
|
)
|
||||||
|
from .services import AdminService, BannedUser, User
|
||||||
from .utils import (
|
from .utils import (
|
||||||
return_to_admin_menu,
|
escape_html,
|
||||||
handle_admin_error,
|
|
||||||
format_user_info,
|
|
||||||
format_ban_confirmation,
|
format_ban_confirmation,
|
||||||
escape_html
|
format_user_info,
|
||||||
|
handle_admin_error,
|
||||||
|
return_to_admin_menu,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'admin_router',
|
"admin_router",
|
||||||
'AdminAccessMiddleware',
|
"AdminAccessMiddleware",
|
||||||
'BotDB',
|
"BotDB",
|
||||||
'Settings',
|
"Settings",
|
||||||
'AdminService',
|
"AdminService",
|
||||||
'User',
|
"User",
|
||||||
'BannedUser',
|
"BannedUser",
|
||||||
'AdminError',
|
"AdminError",
|
||||||
'AdminAccessDeniedError',
|
"AdminAccessDeniedError",
|
||||||
'UserNotFoundError',
|
"UserNotFoundError",
|
||||||
'InvalidInputError',
|
"InvalidInputError",
|
||||||
'UserAlreadyBannedError',
|
"UserAlreadyBannedError",
|
||||||
'return_to_admin_menu',
|
"return_to_admin_menu",
|
||||||
'handle_admin_error',
|
"handle_admin_error",
|
||||||
'format_user_info',
|
"format_user_info",
|
||||||
'format_ban_confirmation',
|
"format_ban_confirmation",
|
||||||
'escape_html'
|
"escape_html",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,36 +1,34 @@
|
|||||||
from aiogram import Router, types, F
|
from aiogram import F, Router, types
|
||||||
from aiogram.filters import Command, StateFilter, MagicData
|
from aiogram.filters import Command, MagicData, StateFilter
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
|
|
||||||
from helper_bot.filters.main import ChatTypeFilter
|
from helper_bot.filters.main import ChatTypeFilter
|
||||||
from helper_bot.keyboards.keyboards import (
|
|
||||||
get_reply_keyboard_admin,
|
|
||||||
create_keyboard_with_pagination,
|
|
||||||
create_keyboard_for_ban_days,
|
|
||||||
create_keyboard_for_approve_ban,
|
|
||||||
create_keyboard_for_ban_reason
|
|
||||||
)
|
|
||||||
from helper_bot.handlers.admin.dependencies import AdminAccessMiddleware
|
from helper_bot.handlers.admin.dependencies import AdminAccessMiddleware
|
||||||
from helper_bot.handlers.admin.services import AdminService
|
|
||||||
from helper_bot.handlers.admin.exceptions import (
|
from helper_bot.handlers.admin.exceptions import (
|
||||||
UserAlreadyBannedError,
|
InvalidInputError,
|
||||||
InvalidInputError
|
UserAlreadyBannedError,
|
||||||
)
|
)
|
||||||
|
from helper_bot.handlers.admin.services import AdminService
|
||||||
from helper_bot.handlers.admin.utils import (
|
from helper_bot.handlers.admin.utils import (
|
||||||
return_to_admin_menu,
|
escape_html,
|
||||||
handle_admin_error,
|
|
||||||
format_user_info,
|
|
||||||
format_ban_confirmation,
|
format_ban_confirmation,
|
||||||
escape_html
|
format_user_info,
|
||||||
|
handle_admin_error,
|
||||||
|
return_to_admin_menu,
|
||||||
)
|
)
|
||||||
from logs.custom_logger import logger
|
from helper_bot.keyboards.keyboards import (
|
||||||
|
create_keyboard_for_approve_ban,
|
||||||
|
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
|
||||||
|
|
||||||
# Local imports - metrics
|
# Local imports - metrics
|
||||||
from helper_bot.utils.metrics import (
|
from helper_bot.utils.metrics import db_query_time, track_errors, track_time
|
||||||
track_time,
|
from logs.custom_logger import logger
|
||||||
track_errors,
|
|
||||||
db_query_time
|
|
||||||
)
|
|
||||||
|
|
||||||
# Создаем роутер с middleware для проверки доступа
|
# Создаем роутер с middleware для проверки доступа
|
||||||
admin_router = Router()
|
admin_router = Router()
|
||||||
@@ -41,23 +39,19 @@ admin_router.message.middleware(AdminAccessMiddleware())
|
|||||||
# ХЕНДЛЕРЫ МЕНЮ
|
# ХЕНДЛЕРЫ МЕНЮ
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
@admin_router.message(
|
|
||||||
ChatTypeFilter(chat_type=["private"]),
|
@admin_router.message(ChatTypeFilter(chat_type=["private"]), Command("admin"))
|
||||||
Command('admin')
|
|
||||||
)
|
|
||||||
@track_time("admin_panel", "admin_handlers")
|
@track_time("admin_panel", "admin_handlers")
|
||||||
@track_errors("admin_handlers", "admin_panel")
|
@track_errors("admin_handlers", "admin_panel")
|
||||||
async def admin_panel(
|
async def admin_panel(message: types.Message, state: FSMContext, **kwargs):
|
||||||
message: types.Message,
|
|
||||||
state: FSMContext,
|
|
||||||
**kwargs
|
|
||||||
):
|
|
||||||
"""Главное меню администратора"""
|
"""Главное меню администратора"""
|
||||||
try:
|
try:
|
||||||
await state.set_state("ADMIN")
|
await state.set_state("ADMIN")
|
||||||
logger.info(f"Запуск админ панели для пользователя: {message.from_user.id}")
|
logger.info(f"Запуск админ панели для пользователя: {message.from_user.id}")
|
||||||
markup = get_reply_keyboard_admin()
|
markup = get_reply_keyboard_admin()
|
||||||
await message.answer("Добро пожаловать в админку. Выбери что хочешь:", reply_markup=markup)
|
await message.answer(
|
||||||
|
"Добро пожаловать в админку. Выбери что хочешь:", reply_markup=markup
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await handle_admin_error(message, e, state, "admin_panel")
|
await handle_admin_error(message, e, state, "admin_panel")
|
||||||
|
|
||||||
@@ -66,18 +60,20 @@ async def admin_panel(
|
|||||||
# ХЕНДЛЕР ОТМЕНЫ
|
# ХЕНДЛЕР ОТМЕНЫ
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
@admin_router.message(
|
@admin_router.message(
|
||||||
ChatTypeFilter(chat_type=["private"]),
|
ChatTypeFilter(chat_type=["private"]),
|
||||||
StateFilter("AWAIT_BAN_TARGET", "AWAIT_BAN_DETAILS", "AWAIT_BAN_DURATION", "BAN_CONFIRMATION"),
|
StateFilter(
|
||||||
F.text == 'Отменить'
|
"AWAIT_BAN_TARGET",
|
||||||
|
"AWAIT_BAN_DETAILS",
|
||||||
|
"AWAIT_BAN_DURATION",
|
||||||
|
"BAN_CONFIRMATION",
|
||||||
|
),
|
||||||
|
F.text == "Отменить",
|
||||||
)
|
)
|
||||||
@track_time("cancel_ban_process", "admin_handlers")
|
@track_time("cancel_ban_process", "admin_handlers")
|
||||||
@track_errors("admin_handlers", "cancel_ban_process")
|
@track_errors("admin_handlers", "cancel_ban_process")
|
||||||
async def cancel_ban_process(
|
async def cancel_ban_process(message: types.Message, state: FSMContext, **kwargs):
|
||||||
message: types.Message,
|
|
||||||
state: FSMContext,
|
|
||||||
**kwargs
|
|
||||||
):
|
|
||||||
"""Отмена процесса блокировки"""
|
"""Отмена процесса блокировки"""
|
||||||
try:
|
try:
|
||||||
current_state = await state.get_state()
|
current_state = await state.get_state()
|
||||||
@@ -90,32 +86,31 @@ async def cancel_ban_process(
|
|||||||
@admin_router.message(
|
@admin_router.message(
|
||||||
ChatTypeFilter(chat_type=["private"]),
|
ChatTypeFilter(chat_type=["private"]),
|
||||||
StateFilter("ADMIN"),
|
StateFilter("ADMIN"),
|
||||||
F.text == 'Бан (Список)'
|
F.text == "Бан (Список)",
|
||||||
)
|
)
|
||||||
@track_time("get_last_users", "admin_handlers")
|
@track_time("get_last_users", "admin_handlers")
|
||||||
@track_errors("admin_handlers", "get_last_users")
|
@track_errors("admin_handlers", "get_last_users")
|
||||||
@db_query_time("get_last_users", "users", "select")
|
@db_query_time("get_last_users", "users", "select")
|
||||||
async def get_last_users(
|
async def get_last_users(
|
||||||
message: types.Message,
|
message: types.Message, state: FSMContext, bot_db: MagicData("bot_db")
|
||||||
state: FSMContext,
|
):
|
||||||
bot_db: MagicData("bot_db")
|
|
||||||
):
|
|
||||||
"""Получение списка последних пользователей"""
|
"""Получение списка последних пользователей"""
|
||||||
try:
|
try:
|
||||||
logger.info(f"Получение списка последних пользователей. Пользователь: {message.from_user.full_name}")
|
logger.info(
|
||||||
|
f"Получение списка последних пользователей. Пользователь: {message.from_user.full_name}"
|
||||||
|
)
|
||||||
admin_service = AdminService(bot_db)
|
admin_service = AdminService(bot_db)
|
||||||
users = await admin_service.get_last_users()
|
users = await admin_service.get_last_users()
|
||||||
|
|
||||||
# Преобразуем в формат для клавиатуры (кортежи как ожидает create_keyboard_with_pagination)
|
# Преобразуем в формат для клавиатуры (кортежи как ожидает create_keyboard_with_pagination)
|
||||||
users_data = [
|
users_data = [(user.full_name, user.user_id) for user in users]
|
||||||
(user.full_name, user.user_id)
|
|
||||||
for user in users
|
keyboard = create_keyboard_with_pagination(
|
||||||
]
|
1, len(users_data), users_data, "ban"
|
||||||
|
)
|
||||||
keyboard = create_keyboard_with_pagination(1, len(users_data), users_data, 'ban')
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
text="Список пользователей которые последними обращались к боту",
|
text="Список пользователей которые последними обращались к боту",
|
||||||
reply_markup=keyboard
|
reply_markup=keyboard,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await handle_admin_error(message, e, state, "get_last_users")
|
await handle_admin_error(message, e, state, "get_last_users")
|
||||||
@@ -124,97 +119,474 @@ async def get_last_users(
|
|||||||
@admin_router.message(
|
@admin_router.message(
|
||||||
ChatTypeFilter(chat_type=["private"]),
|
ChatTypeFilter(chat_type=["private"]),
|
||||||
StateFilter("ADMIN"),
|
StateFilter("ADMIN"),
|
||||||
F.text == 'Разбан (список)'
|
F.text == "Разбан (список)",
|
||||||
)
|
)
|
||||||
@track_time("get_banned_users", "admin_handlers")
|
@track_time("get_banned_users", "admin_handlers")
|
||||||
@track_errors("admin_handlers", "get_banned_users")
|
@track_errors("admin_handlers", "get_banned_users")
|
||||||
@db_query_time("get_banned_users", "users", "select")
|
@db_query_time("get_banned_users", "users", "select")
|
||||||
async def get_banned_users(
|
async def get_banned_users(
|
||||||
message: types.Message,
|
message: types.Message, state: FSMContext, bot_db: MagicData("bot_db")
|
||||||
state: FSMContext,
|
):
|
||||||
bot_db: MagicData("bot_db")
|
|
||||||
):
|
|
||||||
"""Получение списка заблокированных пользователей"""
|
"""Получение списка заблокированных пользователей"""
|
||||||
try:
|
try:
|
||||||
logger.info(f"Получение списка заблокированных пользователей. Пользователь: {message.from_user.full_name}")
|
logger.info(
|
||||||
|
f"Получение списка заблокированных пользователей. Пользователь: {message.from_user.full_name}"
|
||||||
|
)
|
||||||
admin_service = AdminService(bot_db)
|
admin_service = AdminService(bot_db)
|
||||||
message_text, buttons_list = await admin_service.get_banned_users_for_display(0)
|
message_text, buttons_list = await admin_service.get_banned_users_for_display(0)
|
||||||
|
|
||||||
if buttons_list:
|
if buttons_list:
|
||||||
keyboard = create_keyboard_with_pagination(1, len(buttons_list), buttons_list, 'unlock')
|
keyboard = create_keyboard_with_pagination(
|
||||||
await message.answer(text=message_text, reply_markup=keyboard)
|
1, len(buttons_list), buttons_list, "unlock"
|
||||||
|
)
|
||||||
|
await message.answer(
|
||||||
|
text=message_text, reply_markup=keyboard, parse_mode="HTML"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
await message.answer(text="В списке заблокированных пользователей никого нет")
|
await message.answer(
|
||||||
|
text="В списке заблокированных пользователей никого нет"
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await handle_admin_error(message, e, state, "get_banned_users")
|
await handle_admin_error(message, e, state, "get_banned_users")
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.message(
|
||||||
|
ChatTypeFilter(chat_type=["private"]),
|
||||||
|
StateFilter("ADMIN"),
|
||||||
|
F.text == "📊 ML Статистика",
|
||||||
|
)
|
||||||
|
@track_time("get_ml_stats", "admin_handlers")
|
||||||
|
@track_errors("admin_handlers", "get_ml_stats")
|
||||||
|
async def get_ml_stats(message: types.Message, state: FSMContext, **kwargs):
|
||||||
|
"""Получение статистики ML-скоринга"""
|
||||||
|
try:
|
||||||
|
logger.info(
|
||||||
|
f"Запрос ML статистики от пользователя: {message.from_user.full_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
bdf = get_global_instance()
|
||||||
|
scoring_manager = bdf.get_scoring_manager()
|
||||||
|
|
||||||
|
if not scoring_manager:
|
||||||
|
await message.answer(
|
||||||
|
"📊 ML Scoring отключен\n\nДля включения установите RAG_ENABLED=true или DEEPSEEK_ENABLED=true в .env"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
stats = await scoring_manager.get_stats()
|
||||||
|
|
||||||
|
# Формируем текст статистики
|
||||||
|
lines = ["📊 <b>ML Scoring Статистика</b>\n"]
|
||||||
|
|
||||||
|
# RAG статистика
|
||||||
|
if "rag" in stats:
|
||||||
|
rag = stats["rag"]
|
||||||
|
lines.append("🤖 <b>RAG API:</b>")
|
||||||
|
|
||||||
|
# Проверяем, есть ли данные из API (новый контракт содержит model_loaded и vector_store)
|
||||||
|
if "model_loaded" in rag or "vector_store" in rag:
|
||||||
|
# Данные из API /stats
|
||||||
|
if "model_loaded" in rag:
|
||||||
|
model_loaded = rag.get("model_loaded", False)
|
||||||
|
lines.append(
|
||||||
|
f" • Модель загружена: {'✅' if model_loaded else '❌'}"
|
||||||
|
)
|
||||||
|
if "model_name" in rag:
|
||||||
|
lines.append(f" • Модель: {rag.get('model_name', 'N/A')}")
|
||||||
|
if "device" in rag:
|
||||||
|
lines.append(f" • Устройство: {rag.get('device', 'N/A')}")
|
||||||
|
|
||||||
|
# Статистика из vector_store
|
||||||
|
if "vector_store" in rag:
|
||||||
|
vector_store = rag["vector_store"]
|
||||||
|
positive_count = vector_store.get("positive_count", 0)
|
||||||
|
negative_count = vector_store.get("negative_count", 0)
|
||||||
|
total_count = vector_store.get("total_count", 0)
|
||||||
|
|
||||||
|
lines.append(f" • Положительных примеров: {positive_count}")
|
||||||
|
lines.append(f" • Отрицательных примеров: {negative_count}")
|
||||||
|
lines.append(f" • Всего примеров: {total_count}")
|
||||||
|
|
||||||
|
if "vector_dim" in vector_store:
|
||||||
|
lines.append(
|
||||||
|
f" • Размерность векторов: {vector_store.get('vector_dim', 'N/A')}"
|
||||||
|
)
|
||||||
|
if "max_examples" in vector_store:
|
||||||
|
lines.append(
|
||||||
|
f" • Макс. примеров: {vector_store.get('max_examples', 'N/A')}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Fallback на синхронные данные (если API недоступен)
|
||||||
|
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 ключ")
|
||||||
|
else:
|
||||||
|
lines.append(f" • Статус: ❌ Отключен")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# DeepSeek статистика
|
||||||
|
if "deepseek" in stats:
|
||||||
|
ds = stats["deepseek"]
|
||||||
|
lines.append("🔮 <b>DeepSeek API:</b>")
|
||||||
|
lines.append(
|
||||||
|
f" • Статус: {'✅ Включен' if ds.get('enabled') else '❌ Отключен'}"
|
||||||
|
)
|
||||||
|
lines.append(f" • Модель: {ds.get('model', 'N/A')}")
|
||||||
|
lines.append(f" • Таймаут: {ds.get('timeout', 'N/A')}с")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Если ничего не включено
|
||||||
|
if "rag" not in stats and "deepseek" not in stats:
|
||||||
|
lines.append("⚠️ Ни один сервис не настроен")
|
||||||
|
|
||||||
|
await message.answer("\n".join(lines), parse_mode="HTML")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка получения ML статистики: {e}")
|
||||||
|
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 (
|
||||||
|
"⚙️ <b>Авто-модерация постов</b>\n\n"
|
||||||
|
f"🤖 <b>Авто-публикация:</b> {publish_status}\n"
|
||||||
|
f" Порог: RAG score ≥ <b>{publish_threshold}</b>\n\n"
|
||||||
|
f"🚫 <b>Авто-отклонение:</b> {decline_status}\n"
|
||||||
|
f" Порог: RAG score ≤ <b>{decline_threshold}</b>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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(
|
||||||
|
"📈 <b>Изменение порога авто-публикации</b>\n\n"
|
||||||
|
"Введите новое значение порога (от 0.0 до 1.0).\n"
|
||||||
|
"Посты с RAG score ≥ этого значения будут автоматически публиковаться.\n\n"
|
||||||
|
"Текущее рекомендуемое значение: <b>0.8</b>",
|
||||||
|
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(
|
||||||
|
"📉 <b>Изменение порога авто-отклонения</b>\n\n"
|
||||||
|
"Введите новое значение порога (от 0.0 до 1.0).\n"
|
||||||
|
"Посты с RAG score ≤ этого значения будут автоматически отклоняться.\n\n"
|
||||||
|
"Текущее рекомендуемое значение: <b>0.4</b>",
|
||||||
|
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"✅ Порог авто-публикации изменен на <b>{value}</b>",
|
||||||
|
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"✅ Порог авто-отклонения изменен на <b>{value}</b>",
|
||||||
|
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)}")
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# ХЕНДЛЕРЫ ПРОЦЕССА БАНА
|
# ХЕНДЛЕРЫ ПРОЦЕССА БАНА
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
@admin_router.message(
|
@admin_router.message(
|
||||||
ChatTypeFilter(chat_type=["private"]),
|
ChatTypeFilter(chat_type=["private"]),
|
||||||
StateFilter("ADMIN"),
|
StateFilter("ADMIN"),
|
||||||
F.text.in_(['Бан по нику', 'Бан по ID'])
|
F.text.in_(["Бан по нику", "Бан по ID"]),
|
||||||
)
|
)
|
||||||
@track_time("start_ban_process", "admin_handlers")
|
@track_time("start_ban_process", "admin_handlers")
|
||||||
@track_errors("admin_handlers", "start_ban_process")
|
@track_errors("admin_handlers", "start_ban_process")
|
||||||
async def start_ban_process(
|
async def start_ban_process(message: types.Message, state: FSMContext, **kwargs):
|
||||||
message: types.Message,
|
|
||||||
state: FSMContext,
|
|
||||||
**kwargs
|
|
||||||
):
|
|
||||||
"""Начало процесса блокировки пользователя"""
|
"""Начало процесса блокировки пользователя"""
|
||||||
try:
|
try:
|
||||||
ban_type = "username" if message.text == 'Бан по нику' else "id"
|
ban_type = "username" if message.text == "Бан по нику" else "id"
|
||||||
await state.update_data(ban_type=ban_type)
|
await state.update_data(ban_type=ban_type)
|
||||||
|
|
||||||
prompt_text = "Пришли мне username блокируемого пользователя" if ban_type == "username" else "Пришли мне ID блокируемого пользователя"
|
prompt_text = (
|
||||||
|
"Пришли мне username блокируемого пользователя"
|
||||||
|
if ban_type == "username"
|
||||||
|
else "Пришли мне ID блокируемого пользователя"
|
||||||
|
)
|
||||||
await message.answer(prompt_text)
|
await message.answer(prompt_text)
|
||||||
await state.set_state('AWAIT_BAN_TARGET')
|
await state.set_state("AWAIT_BAN_TARGET")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await handle_admin_error(message, e, state, "start_ban_process")
|
await handle_admin_error(message, e, state, "start_ban_process")
|
||||||
|
|
||||||
|
|
||||||
@admin_router.message(
|
@admin_router.message(
|
||||||
ChatTypeFilter(chat_type=["private"]),
|
ChatTypeFilter(chat_type=["private"]), StateFilter("AWAIT_BAN_TARGET")
|
||||||
StateFilter("AWAIT_BAN_TARGET")
|
|
||||||
)
|
)
|
||||||
@track_time("process_ban_target", "admin_handlers")
|
@track_time("process_ban_target", "admin_handlers")
|
||||||
@track_errors("admin_handlers", "process_ban_target")
|
@track_errors("admin_handlers", "process_ban_target")
|
||||||
async def process_ban_target(
|
async def process_ban_target(
|
||||||
message: types.Message,
|
message: types.Message, state: FSMContext, bot_db: MagicData("bot_db")
|
||||||
state: FSMContext,
|
):
|
||||||
bot_db: MagicData("bot_db")
|
|
||||||
):
|
|
||||||
"""Обработка введенного username/ID для блокировки"""
|
"""Обработка введенного username/ID для блокировки"""
|
||||||
logger.info(f"process_ban_target: === НАЧАЛО ОБРАБОТКИ === Получено сообщение от {message.from_user.id}: {message.text}")
|
logger.info(
|
||||||
|
f"process_ban_target: === НАЧАЛО ОБРАБОТКИ === Получено сообщение от {message.from_user.id}: {message.text}"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user_data = await state.get_data()
|
user_data = await state.get_data()
|
||||||
ban_type = user_data.get('ban_type')
|
ban_type = user_data.get("ban_type")
|
||||||
admin_service = AdminService(bot_db)
|
admin_service = AdminService(bot_db)
|
||||||
|
|
||||||
logger.info(f"process_ban_target: ban_type={ban_type}, user_data={user_data}")
|
logger.info(f"process_ban_target: ban_type={ban_type}, user_data={user_data}")
|
||||||
|
|
||||||
# Определяем пользователя
|
# Определяем пользователя
|
||||||
if ban_type == "username":
|
if ban_type == "username":
|
||||||
logger.info(f"process_ban_target: Поиск пользователя по username: {message.text}")
|
logger.info(
|
||||||
|
f"process_ban_target: Поиск пользователя по username: {message.text}"
|
||||||
|
)
|
||||||
user = await admin_service.get_user_by_username(message.text)
|
user = await admin_service.get_user_by_username(message.text)
|
||||||
if not user:
|
if not user:
|
||||||
logger.warning(f"process_ban_target: Пользователь с username '{message.text}' не найден")
|
logger.warning(
|
||||||
await message.answer(f"Пользователь с username '{escape_html(message.text)}' не найден.")
|
f"process_ban_target: Пользователь с username '{message.text}' не найден"
|
||||||
|
)
|
||||||
|
await message.answer(
|
||||||
|
f"Пользователь с username '{escape_html(message.text)}' не найден."
|
||||||
|
)
|
||||||
await return_to_admin_menu(message, state)
|
await return_to_admin_menu(message, state)
|
||||||
return
|
return
|
||||||
else: # ban_type == "id"
|
else: # ban_type == "id"
|
||||||
try:
|
try:
|
||||||
logger.info(f"process_ban_target: Валидация и поиск пользователя по ID: {message.text}")
|
logger.info(
|
||||||
|
f"process_ban_target: Валидация и поиск пользователя по ID: {message.text}"
|
||||||
|
)
|
||||||
user_id = await admin_service.validate_user_input(message.text)
|
user_id = await admin_service.validate_user_input(message.text)
|
||||||
user = await admin_service.get_user_by_id(user_id)
|
user = await admin_service.get_user_by_id(user_id)
|
||||||
if not user:
|
if not user:
|
||||||
logger.warning(f"process_ban_target: Пользователь с ID {user_id} не найден в базе данных")
|
logger.warning(
|
||||||
await message.answer(f"Пользователь с ID {user_id} не найден в базе данных.")
|
f"process_ban_target: Пользователь с ID {user_id} не найден в базе данных"
|
||||||
|
)
|
||||||
|
await message.answer(
|
||||||
|
f"Пользователь с ID {user_id} не найден в базе данных."
|
||||||
|
)
|
||||||
await return_to_admin_menu(message, state)
|
await return_to_admin_menu(message, state)
|
||||||
return
|
return
|
||||||
except InvalidInputError as e:
|
except InvalidInputError as e:
|
||||||
@@ -222,115 +594,117 @@ async def process_ban_target(
|
|||||||
await message.answer(str(e))
|
await message.answer(str(e))
|
||||||
await return_to_admin_menu(message, state)
|
await return_to_admin_menu(message, state)
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(f"process_ban_target: Найден пользователь: {user.user_id}, {user.username}, {user.full_name}")
|
logger.info(
|
||||||
|
f"process_ban_target: Найден пользователь: {user.user_id}, {user.username}, {user.full_name}"
|
||||||
|
)
|
||||||
|
|
||||||
# Сохраняем данные пользователя
|
# Сохраняем данные пользователя
|
||||||
await state.update_data(
|
await state.update_data(
|
||||||
target_user_id=user.user_id,
|
target_user_id=user.user_id,
|
||||||
target_username=user.username,
|
target_username=user.username,
|
||||||
target_full_name=user.full_name
|
target_full_name=user.full_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Показываем информацию о пользователе и запрашиваем причину
|
# Показываем информацию о пользователе и запрашиваем причину
|
||||||
user_info = format_user_info(user.user_id, user.username, user.full_name)
|
user_info = format_user_info(user.user_id, user.username, user.full_name)
|
||||||
markup = create_keyboard_for_ban_reason()
|
markup = create_keyboard_for_ban_reason()
|
||||||
logger.info(f"process_ban_target: Отправка сообщения с причиной бана, user_info: {user_info}")
|
logger.info(
|
||||||
|
f"process_ban_target: Отправка сообщения с причиной бана, user_info: {user_info}"
|
||||||
|
)
|
||||||
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
text=f"{user_info}\n\nВыбери причину бана из списка или напиши ее в чат",
|
text=f"{user_info}\n\nВыбери причину бана из списка или напиши ее в чат",
|
||||||
reply_markup=markup
|
reply_markup=markup,
|
||||||
)
|
)
|
||||||
await state.set_state('AWAIT_BAN_DETAILS')
|
await state.set_state("AWAIT_BAN_DETAILS")
|
||||||
logger.info("process_ban_target: Состояние изменено на AWAIT_BAN_DETAILS")
|
logger.info("process_ban_target: Состояние изменено на AWAIT_BAN_DETAILS")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"process_ban_target: Неожиданная ошибка: {e}", exc_info=True)
|
logger.error(f"process_ban_target: Неожиданная ошибка: {e}", exc_info=True)
|
||||||
await handle_admin_error(message, e, state, "process_ban_target")
|
await handle_admin_error(message, e, state, "process_ban_target")
|
||||||
|
|
||||||
|
|
||||||
@admin_router.message(
|
@admin_router.message(
|
||||||
ChatTypeFilter(chat_type=["private"]),
|
ChatTypeFilter(chat_type=["private"]), StateFilter("AWAIT_BAN_DETAILS")
|
||||||
StateFilter("AWAIT_BAN_DETAILS")
|
|
||||||
)
|
)
|
||||||
@track_time("process_ban_reason", "admin_handlers")
|
@track_time("process_ban_reason", "admin_handlers")
|
||||||
@track_errors("admin_handlers", "process_ban_reason")
|
@track_errors("admin_handlers", "process_ban_reason")
|
||||||
async def process_ban_reason(
|
async def process_ban_reason(message: types.Message, state: FSMContext, **kwargs):
|
||||||
message: types.Message,
|
|
||||||
state: FSMContext,
|
|
||||||
**kwargs
|
|
||||||
):
|
|
||||||
"""Обработка причины блокировки"""
|
"""Обработка причины блокировки"""
|
||||||
logger.info(f"process_ban_reason: === НАЧАЛО ОБРАБОТКИ === Получено сообщение от {message.from_user.id}: {message.text}")
|
logger.info(
|
||||||
|
f"process_ban_reason: === НАЧАЛО ОБРАБОТКИ === Получено сообщение от {message.from_user.id}: {message.text}"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Проверяем текущее состояние
|
# Проверяем текущее состояние
|
||||||
current_state = await state.get_state()
|
current_state = await state.get_state()
|
||||||
logger.info(f"process_ban_reason: Текущее состояние: {current_state}")
|
logger.info(f"process_ban_reason: Текущее состояние: {current_state}")
|
||||||
|
|
||||||
# Проверяем данные состояния
|
# Проверяем данные состояния
|
||||||
state_data = await state.get_data()
|
state_data = await state.get_data()
|
||||||
logger.info(f"process_ban_reason: Данные состояния: {state_data}")
|
logger.info(f"process_ban_reason: Данные состояния: {state_data}")
|
||||||
|
|
||||||
logger.info(f"process_ban_reason: Обновление данных состояния с причиной: {message.text}")
|
logger.info(
|
||||||
|
f"process_ban_reason: Обновление данных состояния с причиной: {message.text}"
|
||||||
|
)
|
||||||
await state.update_data(ban_reason=message.text)
|
await state.update_data(ban_reason=message.text)
|
||||||
|
|
||||||
markup = create_keyboard_for_ban_days()
|
markup = create_keyboard_for_ban_days()
|
||||||
safe_reason = escape_html(message.text)
|
safe_reason = escape_html(message.text)
|
||||||
logger.info(f"process_ban_reason: Отправка сообщения с выбором срока бана, причина: {safe_reason}")
|
logger.info(
|
||||||
|
f"process_ban_reason: Отправка сообщения с выбором срока бана, причина: {safe_reason}"
|
||||||
|
)
|
||||||
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
f"Выбрана причина: {safe_reason}. Выбери срок бана в днях или напиши его в чат",
|
f"Выбрана причина: {safe_reason}. Выбери срок бана в днях или напиши его в чат",
|
||||||
reply_markup=markup
|
reply_markup=markup,
|
||||||
)
|
)
|
||||||
await state.set_state('AWAIT_BAN_DURATION')
|
await state.set_state("AWAIT_BAN_DURATION")
|
||||||
logger.info("process_ban_reason: Состояние изменено на AWAIT_BAN_DURATION")
|
logger.info("process_ban_reason: Состояние изменено на AWAIT_BAN_DURATION")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"process_ban_reason: Неожиданная ошибка: {e}", exc_info=True)
|
logger.error(f"process_ban_reason: Неожиданная ошибка: {e}", exc_info=True)
|
||||||
await handle_admin_error(message, e, state, "process_ban_reason")
|
await handle_admin_error(message, e, state, "process_ban_reason")
|
||||||
|
|
||||||
|
|
||||||
@admin_router.message(
|
@admin_router.message(
|
||||||
ChatTypeFilter(chat_type=["private"]),
|
ChatTypeFilter(chat_type=["private"]), StateFilter("AWAIT_BAN_DURATION")
|
||||||
StateFilter("AWAIT_BAN_DURATION")
|
|
||||||
)
|
)
|
||||||
@track_time("process_ban_duration", "admin_handlers")
|
@track_time("process_ban_duration", "admin_handlers")
|
||||||
@track_errors("admin_handlers", "process_ban_duration")
|
@track_errors("admin_handlers", "process_ban_duration")
|
||||||
async def process_ban_duration(
|
async def process_ban_duration(message: types.Message, state: FSMContext, **kwargs):
|
||||||
message: types.Message,
|
|
||||||
state: FSMContext,
|
|
||||||
**kwargs
|
|
||||||
):
|
|
||||||
"""Обработка срока блокировки"""
|
"""Обработка срока блокировки"""
|
||||||
try:
|
try:
|
||||||
user_data = await state.get_data()
|
user_data = await state.get_data()
|
||||||
|
|
||||||
# Определяем срок блокировки
|
# Определяем срок блокировки
|
||||||
if message.text == 'Навсегда':
|
if message.text == "Навсегда":
|
||||||
ban_days = None
|
ban_days = None
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
ban_days = int(message.text)
|
ban_days = int(message.text)
|
||||||
if ban_days <= 0:
|
if ban_days <= 0:
|
||||||
await message.answer("Срок блокировки должен быть положительным числом.")
|
await message.answer(
|
||||||
|
"Срок блокировки должен быть положительным числом."
|
||||||
|
)
|
||||||
return
|
return
|
||||||
except ValueError:
|
except ValueError:
|
||||||
await message.answer("Пожалуйста, введите корректное число дней или выберите 'Навсегда'.")
|
await message.answer(
|
||||||
|
"Пожалуйста, введите корректное число дней или выберите 'Навсегда'."
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
await state.update_data(ban_days=ban_days)
|
await state.update_data(ban_days=ban_days)
|
||||||
|
|
||||||
# Показываем подтверждение
|
# Показываем подтверждение
|
||||||
confirmation_text = format_ban_confirmation(
|
confirmation_text = format_ban_confirmation(
|
||||||
user_data['target_user_id'],
|
user_data["target_user_id"], user_data["ban_reason"], ban_days
|
||||||
user_data['ban_reason'],
|
|
||||||
ban_days
|
|
||||||
)
|
)
|
||||||
markup = create_keyboard_for_approve_ban()
|
markup = create_keyboard_for_approve_ban()
|
||||||
await message.answer(confirmation_text, reply_markup=markup)
|
await message.answer(confirmation_text, reply_markup=markup)
|
||||||
await state.set_state('BAN_CONFIRMATION')
|
await state.set_state("BAN_CONFIRMATION")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await handle_admin_error(message, e, state, "process_ban_duration")
|
await handle_admin_error(message, e, state, "process_ban_duration")
|
||||||
|
|
||||||
@@ -338,34 +712,31 @@ async def process_ban_duration(
|
|||||||
@admin_router.message(
|
@admin_router.message(
|
||||||
ChatTypeFilter(chat_type=["private"]),
|
ChatTypeFilter(chat_type=["private"]),
|
||||||
StateFilter("BAN_CONFIRMATION"),
|
StateFilter("BAN_CONFIRMATION"),
|
||||||
F.text == 'Подтвердить'
|
F.text == "Подтвердить",
|
||||||
)
|
)
|
||||||
@track_time("confirm_ban", "admin_handlers")
|
@track_time("confirm_ban", "admin_handlers")
|
||||||
@track_errors("admin_handlers", "confirm_ban")
|
@track_errors("admin_handlers", "confirm_ban")
|
||||||
async def confirm_ban(
|
async def confirm_ban(
|
||||||
message: types.Message,
|
message: types.Message, state: FSMContext, bot_db: MagicData("bot_db"), **kwargs
|
||||||
state: FSMContext,
|
):
|
||||||
bot_db: MagicData("bot_db"),
|
|
||||||
**kwargs
|
|
||||||
):
|
|
||||||
"""Подтверждение блокировки пользователя"""
|
"""Подтверждение блокировки пользователя"""
|
||||||
try:
|
try:
|
||||||
user_data = await state.get_data()
|
user_data = await state.get_data()
|
||||||
admin_service = AdminService(bot_db)
|
admin_service = AdminService(bot_db)
|
||||||
|
|
||||||
|
|
||||||
# Выполняем блокировку
|
# Выполняем блокировку
|
||||||
await admin_service.ban_user(
|
await admin_service.ban_user(
|
||||||
user_id=user_data['target_user_id'],
|
user_id=user_data["target_user_id"],
|
||||||
username=user_data['target_username'],
|
username=user_data["target_username"],
|
||||||
reason=user_data['ban_reason'],
|
reason=user_data["ban_reason"],
|
||||||
ban_days=user_data['ban_days']
|
ban_days=user_data["ban_days"],
|
||||||
|
ban_author_id=message.from_user.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
safe_username = escape_html(user_data['target_username'])
|
safe_username = escape_html(user_data["target_username"])
|
||||||
await message.reply(f"Пользователь {safe_username} успешно заблокирован.")
|
await message.reply(f"Пользователь {safe_username} успешно заблокирован.")
|
||||||
await return_to_admin_menu(message, state)
|
await return_to_admin_menu(message, state)
|
||||||
|
|
||||||
except UserAlreadyBannedError as e:
|
except UserAlreadyBannedError as e:
|
||||||
await message.reply(str(e))
|
await message.reply(str(e))
|
||||||
await return_to_admin_menu(message, state)
|
await return_to_admin_menu(message, state)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Constants for admin handlers"""
|
"""Constants for admin handlers"""
|
||||||
|
|
||||||
from typing import Final, Dict
|
from typing import Dict, Final
|
||||||
|
|
||||||
# Admin button texts
|
# Admin button texts
|
||||||
ADMIN_BUTTON_TEXTS: Final[Dict[str, str]] = {
|
ADMIN_BUTTON_TEXTS: Final[Dict[str, str]] = {
|
||||||
@@ -9,7 +9,7 @@ ADMIN_BUTTON_TEXTS: Final[Dict[str, str]] = {
|
|||||||
"BAN_BY_ID": "Бан по ID",
|
"BAN_BY_ID": "Бан по ID",
|
||||||
"UNBAN_LIST": "Разбан (список)",
|
"UNBAN_LIST": "Разбан (список)",
|
||||||
"RETURN_TO_BOT": "Вернуться в бота",
|
"RETURN_TO_BOT": "Вернуться в бота",
|
||||||
"CANCEL": "Отменить"
|
"CANCEL": "Отменить",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Admin button to command mapping for metrics
|
# Admin button to command mapping for metrics
|
||||||
@@ -19,11 +19,11 @@ ADMIN_BUTTON_COMMAND_MAPPING: Final[Dict[str, str]] = {
|
|||||||
"Бан по ID": "admin_ban_by_id",
|
"Бан по ID": "admin_ban_by_id",
|
||||||
"Разбан (список)": "admin_unban_list",
|
"Разбан (список)": "admin_unban_list",
|
||||||
"Вернуться в бота": "admin_return_to_bot",
|
"Вернуться в бота": "admin_return_to_bot",
|
||||||
"Отменить": "admin_cancel"
|
"Отменить": "admin_cancel",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Admin commands
|
# Admin commands
|
||||||
ADMIN_COMMANDS: Final[Dict[str, str]] = {
|
ADMIN_COMMANDS: Final[Dict[str, str]] = {
|
||||||
"ADMIN": "admin",
|
"ADMIN": "admin",
|
||||||
"TEST_METRICS": "test_metrics"
|
"TEST_METRICS": "test_metrics",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
from typing import Dict, Any
|
from typing import Any, Dict
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from typing_extensions import Annotated
|
from typing_extensions import Annotated
|
||||||
|
|
||||||
from aiogram import BaseMiddleware
|
from aiogram import BaseMiddleware
|
||||||
from aiogram.types import TelegramObject
|
from aiogram.types import TelegramObject
|
||||||
|
|
||||||
@@ -13,36 +15,46 @@ from logs.custom_logger import logger
|
|||||||
|
|
||||||
class AdminAccessMiddleware(BaseMiddleware):
|
class AdminAccessMiddleware(BaseMiddleware):
|
||||||
"""Middleware для проверки административного доступа"""
|
"""Middleware для проверки административного доступа"""
|
||||||
|
|
||||||
async def __call__(self, handler, event: TelegramObject, data: Dict[str, Any]) -> Any:
|
async def __call__(
|
||||||
if hasattr(event, 'from_user'):
|
self, handler, event: TelegramObject, data: Dict[str, Any]
|
||||||
|
) -> Any:
|
||||||
|
if hasattr(event, "from_user"):
|
||||||
user_id = event.from_user.id
|
user_id = event.from_user.id
|
||||||
username = getattr(event.from_user, 'username', 'Unknown')
|
username = getattr(event.from_user, "username", "Unknown")
|
||||||
|
|
||||||
logger.info(f"AdminAccessMiddleware: проверка доступа для пользователя {username} (ID: {user_id})")
|
logger.info(
|
||||||
|
f"AdminAccessMiddleware: проверка доступа для пользователя {username} (ID: {user_id})"
|
||||||
|
)
|
||||||
|
|
||||||
# Получаем bot_db из data (внедренного DependenciesMiddleware)
|
# Получаем bot_db из data (внедренного DependenciesMiddleware)
|
||||||
bot_db = data.get('bot_db')
|
bot_db = data.get("bot_db")
|
||||||
if not bot_db:
|
if not bot_db:
|
||||||
# Fallback: получаем напрямую если middleware не сработала
|
# Fallback: получаем напрямую если middleware не сработала
|
||||||
bdf = get_global_instance()
|
bdf = get_global_instance()
|
||||||
bot_db = bdf.get_db()
|
bot_db = bdf.get_db()
|
||||||
|
|
||||||
is_admin_result = await check_access(user_id, bot_db)
|
is_admin_result = await check_access(user_id, bot_db)
|
||||||
logger.info(f"AdminAccessMiddleware: результат проверки для {username}: {is_admin_result}")
|
logger.info(
|
||||||
|
f"AdminAccessMiddleware: результат проверки для {username}: {is_admin_result}"
|
||||||
|
)
|
||||||
|
|
||||||
if not is_admin_result:
|
if not is_admin_result:
|
||||||
logger.warning(f"AdminAccessMiddleware: доступ запрещен для пользователя {username} (ID: {user_id})")
|
logger.warning(
|
||||||
if hasattr(event, 'answer'):
|
f"AdminAccessMiddleware: доступ запрещен для пользователя {username} (ID: {user_id})"
|
||||||
await event.answer('Доступ запрещен!')
|
)
|
||||||
|
if hasattr(event, "answer"):
|
||||||
|
await event.answer("Доступ запрещен!")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Вызываем хендлер с data
|
# Вызываем хендлер с data
|
||||||
return await handler(event, data)
|
return await handler(event, data)
|
||||||
except TypeError as e:
|
except TypeError as e:
|
||||||
if "missing 1 required positional argument: 'data'" in str(e):
|
if "missing 1 required positional argument: 'data'" in str(e):
|
||||||
logger.error(f"Ошибка в AdminAccessMiddleware: {e}. Хендлер не принимает параметр 'data'")
|
logger.error(
|
||||||
|
f"Ошибка в AdminAccessMiddleware: {e}. Хендлер не принимает параметр 'data'"
|
||||||
|
)
|
||||||
# Пытаемся вызвать хендлер без data (для совместимости с MagicData)
|
# Пытаемся вызвать хендлер без data (для совместимости с MagicData)
|
||||||
return await handler(event)
|
return await handler(event)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1,23 +1,28 @@
|
|||||||
class AdminError(Exception):
|
class AdminError(Exception):
|
||||||
"""Базовое исключение для административных операций"""
|
"""Базовое исключение для административных операций"""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class AdminAccessDeniedError(AdminError):
|
class AdminAccessDeniedError(AdminError):
|
||||||
"""Исключение при отказе в административном доступе"""
|
"""Исключение при отказе в административном доступе"""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class UserNotFoundError(AdminError):
|
class UserNotFoundError(AdminError):
|
||||||
"""Исключение при отсутствии пользователя"""
|
"""Исключение при отсутствии пользователя"""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class InvalidInputError(AdminError):
|
class InvalidInputError(AdminError):
|
||||||
"""Исключение при некорректном вводе данных"""
|
"""Исключение при некорректном вводе данных"""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class UserAlreadyBannedError(AdminError):
|
class UserAlreadyBannedError(AdminError):
|
||||||
"""Исключение при попытке забанить уже заблокированного пользователя"""
|
"""Исключение при попытке забанить уже заблокированного пользователя"""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -1,27 +1,31 @@
|
|||||||
"""
|
"""
|
||||||
Обработчики команд для мониторинга rate limiting
|
Обработчики команд для мониторинга rate limiting
|
||||||
"""
|
"""
|
||||||
from aiogram import Router, types, F
|
|
||||||
|
from aiogram import F, Router, types
|
||||||
from aiogram.filters import Command, MagicData
|
from aiogram.filters import Command, MagicData
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
from aiogram.types import FSInputFile
|
from aiogram.types import FSInputFile
|
||||||
|
|
||||||
from helper_bot.filters.main import ChatTypeFilter
|
from helper_bot.filters.main import ChatTypeFilter
|
||||||
from helper_bot.middlewares.dependencies_middleware import DependenciesMiddleware
|
from helper_bot.middlewares.dependencies_middleware import DependenciesMiddleware
|
||||||
from helper_bot.utils.rate_limit_monitor import rate_limit_monitor, get_rate_limit_summary
|
|
||||||
from helper_bot.utils.rate_limit_metrics import update_rate_limit_gauges, get_rate_limit_metrics_summary
|
|
||||||
from logs.custom_logger import logger
|
|
||||||
|
|
||||||
# Local imports - metrics
|
# Local imports - metrics
|
||||||
from helper_bot.utils.metrics import (
|
from helper_bot.utils.metrics import track_errors, track_time
|
||||||
track_time,
|
from helper_bot.utils.rate_limit_metrics import (
|
||||||
track_errors
|
get_rate_limit_metrics_summary,
|
||||||
|
update_rate_limit_gauges,
|
||||||
)
|
)
|
||||||
|
from helper_bot.utils.rate_limit_monitor import (
|
||||||
|
get_rate_limit_summary,
|
||||||
|
rate_limit_monitor,
|
||||||
|
)
|
||||||
|
from logs.custom_logger import logger
|
||||||
|
|
||||||
|
|
||||||
class RateLimitHandlers:
|
class RateLimitHandlers:
|
||||||
def __init__(self, db, settings):
|
def __init__(self, db, settings):
|
||||||
self.db = db.get_db() if hasattr(db, 'get_db') else db
|
self.db = db.get_db() if hasattr(db, "get_db") else db
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
self.router = Router()
|
self.router = Router()
|
||||||
self._setup_handlers()
|
self._setup_handlers()
|
||||||
@@ -35,38 +39,38 @@ class RateLimitHandlers:
|
|||||||
self.router.message.register(
|
self.router.message.register(
|
||||||
self.rate_limit_stats_handler,
|
self.rate_limit_stats_handler,
|
||||||
ChatTypeFilter(chat_type=["private"]),
|
ChatTypeFilter(chat_type=["private"]),
|
||||||
Command("ratelimit_stats")
|
Command("ratelimit_stats"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Команда для сброса статистики rate limiting
|
# Команда для сброса статистики rate limiting
|
||||||
self.router.message.register(
|
self.router.message.register(
|
||||||
self.reset_rate_limit_stats_handler,
|
self.reset_rate_limit_stats_handler,
|
||||||
ChatTypeFilter(chat_type=["private"]),
|
ChatTypeFilter(chat_type=["private"]),
|
||||||
Command("reset_ratelimit_stats")
|
Command("reset_ratelimit_stats"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Команда для просмотра ошибок rate limiting
|
# Команда для просмотра ошибок rate limiting
|
||||||
self.router.message.register(
|
self.router.message.register(
|
||||||
self.rate_limit_errors_handler,
|
self.rate_limit_errors_handler,
|
||||||
ChatTypeFilter(chat_type=["private"]),
|
ChatTypeFilter(chat_type=["private"]),
|
||||||
Command("ratelimit_errors")
|
Command("ratelimit_errors"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Команда для просмотра Prometheus метрик
|
# Команда для просмотра Prometheus метрик
|
||||||
self.router.message.register(
|
self.router.message.register(
|
||||||
self.rate_limit_prometheus_handler,
|
self.rate_limit_prometheus_handler,
|
||||||
ChatTypeFilter(chat_type=["private"]),
|
ChatTypeFilter(chat_type=["private"]),
|
||||||
Command("ratelimit_prometheus")
|
Command("ratelimit_prometheus"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@track_time("rate_limit_stats_handler", "rate_limit_handlers")
|
@track_time("rate_limit_stats_handler", "rate_limit_handlers")
|
||||||
@track_errors("rate_limit_handlers", "rate_limit_stats_handler")
|
@track_errors("rate_limit_handlers", "rate_limit_stats_handler")
|
||||||
async def rate_limit_stats_handler(
|
async def rate_limit_stats_handler(
|
||||||
self,
|
self,
|
||||||
message: types.Message,
|
message: types.Message,
|
||||||
state: FSMContext,
|
state: FSMContext,
|
||||||
bot_db: MagicData("bot_db"),
|
bot_db: MagicData("bot_db"),
|
||||||
settings: MagicData("settings")
|
settings: MagicData("settings"),
|
||||||
):
|
):
|
||||||
"""Показывает статистику rate limiting"""
|
"""Показывает статистику rate limiting"""
|
||||||
try:
|
try:
|
||||||
@@ -74,11 +78,11 @@ class RateLimitHandlers:
|
|||||||
if not await bot_db.is_admin(message.from_user.id):
|
if not await bot_db.is_admin(message.from_user.id):
|
||||||
await message.answer("У вас нет прав для выполнения этой команды.")
|
await message.answer("У вас нет прав для выполнения этой команды.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Получаем сводку
|
# Получаем сводку
|
||||||
summary = get_rate_limit_summary()
|
summary = get_rate_limit_summary()
|
||||||
global_stats = rate_limit_monitor.get_global_stats()
|
global_stats = rate_limit_monitor.get_global_stats()
|
||||||
|
|
||||||
# Формируем сообщение со статистикой
|
# Формируем сообщение со статистикой
|
||||||
stats_text = (
|
stats_text = (
|
||||||
f"📊 <b>Статистика Rate Limiting</b>\n\n"
|
f"📊 <b>Статистика Rate Limiting</b>\n\n"
|
||||||
@@ -91,15 +95,17 @@ class RateLimitHandlers:
|
|||||||
f"• Активных чатов: {summary['active_chats']}\n"
|
f"• Активных чатов: {summary['active_chats']}\n"
|
||||||
f"• Ошибок за час: {summary['recent_errors_count']}\n\n"
|
f"• Ошибок за час: {summary['recent_errors_count']}\n\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Добавляем детальную статистику
|
# Добавляем детальную статистику
|
||||||
stats_text += f"🔍 <b>Детальная статистика:</b>\n"
|
stats_text += f"🔍 <b>Детальная статистика:</b>\n"
|
||||||
stats_text += f"• Успешных запросов: {global_stats.successful_requests}\n"
|
stats_text += f"• Успешных запросов: {global_stats.successful_requests}\n"
|
||||||
stats_text += f"• Неудачных запросов: {global_stats.failed_requests}\n"
|
stats_text += f"• Неудачных запросов: {global_stats.failed_requests}\n"
|
||||||
stats_text += f"• RetryAfter ошибок: {global_stats.retry_after_errors}\n"
|
stats_text += f"• RetryAfter ошибок: {global_stats.retry_after_errors}\n"
|
||||||
stats_text += f"• Других ошибок: {global_stats.other_errors}\n"
|
stats_text += f"• Других ошибок: {global_stats.other_errors}\n"
|
||||||
stats_text += f"• Общее время ожидания: {global_stats.total_wait_time:.2f}с\n\n"
|
stats_text += (
|
||||||
|
f"• Общее время ожидания: {global_stats.total_wait_time:.2f}с\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
# Добавляем топ чатов по запросам
|
# Добавляем топ чатов по запросам
|
||||||
top_chats = rate_limit_monitor.get_top_chats_by_requests(5)
|
top_chats = rate_limit_monitor.get_top_chats_by_requests(5)
|
||||||
if top_chats:
|
if top_chats:
|
||||||
@@ -107,16 +113,16 @@ class RateLimitHandlers:
|
|||||||
for i, (chat_id, chat_stats) in enumerate(top_chats, 1):
|
for i, (chat_id, chat_stats) in enumerate(top_chats, 1):
|
||||||
stats_text += f"{i}. Chat {chat_id}: {chat_stats.total_requests} запросов ({chat_stats.success_rate:.1%} успех)\n"
|
stats_text += f"{i}. Chat {chat_id}: {chat_stats.total_requests} запросов ({chat_stats.success_rate:.1%} успех)\n"
|
||||||
stats_text += "\n"
|
stats_text += "\n"
|
||||||
|
|
||||||
# Добавляем чаты с высоким процентом ошибок
|
# Добавляем чаты с высоким процентом ошибок
|
||||||
high_error_chats = rate_limit_monitor.get_chats_with_high_error_rate(0.1)
|
high_error_chats = rate_limit_monitor.get_chats_with_high_error_rate(0.1)
|
||||||
if high_error_chats:
|
if high_error_chats:
|
||||||
stats_text += f"⚠️ <b>Чаты с высоким процентом ошибок (>10%):</b>\n"
|
stats_text += f"⚠️ <b>Чаты с высоким процентом ошибок (>10%):</b>\n"
|
||||||
for chat_id, chat_stats in high_error_chats[:3]:
|
for chat_id, chat_stats in high_error_chats[:3]:
|
||||||
stats_text += f"• Chat {chat_id}: {chat_stats.error_rate:.1%} ошибок ({chat_stats.failed_requests}/{chat_stats.total_requests})\n"
|
stats_text += f"• Chat {chat_id}: {chat_stats.error_rate:.1%} ошибок ({chat_stats.failed_requests}/{chat_stats.total_requests})\n"
|
||||||
|
|
||||||
await message.answer(stats_text, parse_mode='HTML')
|
await message.answer(stats_text, parse_mode="HTML")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при получении статистики rate limiting: {e}")
|
logger.error(f"Ошибка при получении статистики rate limiting: {e}")
|
||||||
await message.answer("Произошла ошибка при получении статистики.")
|
await message.answer("Произошла ошибка при получении статистики.")
|
||||||
@@ -125,10 +131,10 @@ class RateLimitHandlers:
|
|||||||
@track_errors("rate_limit_handlers", "reset_rate_limit_stats_handler")
|
@track_errors("rate_limit_handlers", "reset_rate_limit_stats_handler")
|
||||||
async def reset_rate_limit_stats_handler(
|
async def reset_rate_limit_stats_handler(
|
||||||
self,
|
self,
|
||||||
message: types.Message,
|
message: types.Message,
|
||||||
state: FSMContext,
|
state: FSMContext,
|
||||||
bot_db: MagicData("bot_db"),
|
bot_db: MagicData("bot_db"),
|
||||||
settings: MagicData("settings")
|
settings: MagicData("settings"),
|
||||||
):
|
):
|
||||||
"""Сбрасывает статистику rate limiting"""
|
"""Сбрасывает статистику rate limiting"""
|
||||||
try:
|
try:
|
||||||
@@ -136,12 +142,12 @@ class RateLimitHandlers:
|
|||||||
if not await bot_db.is_admin(message.from_user.id):
|
if not await bot_db.is_admin(message.from_user.id):
|
||||||
await message.answer("У вас нет прав для выполнения этой команды.")
|
await message.answer("У вас нет прав для выполнения этой команды.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Сбрасываем статистику
|
# Сбрасываем статистику
|
||||||
rate_limit_monitor.reset_stats()
|
rate_limit_monitor.reset_stats()
|
||||||
|
|
||||||
await message.answer("✅ Статистика rate limiting сброшена.")
|
await message.answer("✅ Статистика rate limiting сброшена.")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при сбросе статистики rate limiting: {e}")
|
logger.error(f"Ошибка при сбросе статистики rate limiting: {e}")
|
||||||
await message.answer("Произошла ошибка при сбросе статистики.")
|
await message.answer("Произошла ошибка при сбросе статистики.")
|
||||||
@@ -150,10 +156,10 @@ class RateLimitHandlers:
|
|||||||
@track_errors("rate_limit_handlers", "rate_limit_errors_handler")
|
@track_errors("rate_limit_handlers", "rate_limit_errors_handler")
|
||||||
async def rate_limit_errors_handler(
|
async def rate_limit_errors_handler(
|
||||||
self,
|
self,
|
||||||
message: types.Message,
|
message: types.Message,
|
||||||
state: FSMContext,
|
state: FSMContext,
|
||||||
bot_db: MagicData("bot_db"),
|
bot_db: MagicData("bot_db"),
|
||||||
settings: MagicData("settings")
|
settings: MagicData("settings"),
|
||||||
):
|
):
|
||||||
"""Показывает недавние ошибки rate limiting"""
|
"""Показывает недавние ошибки rate limiting"""
|
||||||
try:
|
try:
|
||||||
@@ -161,29 +167,34 @@ class RateLimitHandlers:
|
|||||||
if not await bot_db.is_admin(message.from_user.id):
|
if not await bot_db.is_admin(message.from_user.id):
|
||||||
await message.answer("У вас нет прав для выполнения этой команды.")
|
await message.answer("У вас нет прав для выполнения этой команды.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Получаем ошибки за последний час
|
# Получаем ошибки за последний час
|
||||||
recent_errors = rate_limit_monitor.get_recent_errors(60)
|
recent_errors = rate_limit_monitor.get_recent_errors(60)
|
||||||
error_summary = rate_limit_monitor.get_error_summary(60)
|
error_summary = rate_limit_monitor.get_error_summary(60)
|
||||||
|
|
||||||
if not recent_errors:
|
if not recent_errors:
|
||||||
await message.answer("✅ Ошибок rate limiting за последний час не было.")
|
await message.answer(
|
||||||
|
"✅ Ошибок rate limiting за последний час не было."
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Формируем сообщение с ошибками
|
# Формируем сообщение с ошибками
|
||||||
errors_text = f"🚨 <b>Ошибки Rate Limiting (последний час)</b>\n\n"
|
errors_text = f"🚨 <b>Ошибки Rate Limiting (последний час)</b>\n\n"
|
||||||
errors_text += f"📊 <b>Сводка ошибок:</b>\n"
|
errors_text += f"📊 <b>Сводка ошибок:</b>\n"
|
||||||
for error_type, count in error_summary.items():
|
for error_type, count in error_summary.items():
|
||||||
errors_text += f"• {error_type}: {count}\n"
|
errors_text += f"• {error_type}: {count}\n"
|
||||||
errors_text += f"\nВсего ошибок: {len(recent_errors)}\n\n"
|
errors_text += f"\nВсего ошибок: {len(recent_errors)}\n\n"
|
||||||
|
|
||||||
# Показываем последние 10 ошибок
|
# Показываем последние 10 ошибок
|
||||||
errors_text += f"🔍 <b>Последние ошибки:</b>\n"
|
errors_text += f"🔍 <b>Последние ошибки:</b>\n"
|
||||||
for i, error in enumerate(recent_errors[-10:], 1):
|
for i, error in enumerate(recent_errors[-10:], 1):
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
timestamp = datetime.fromtimestamp(error['timestamp']).strftime("%H:%M:%S")
|
|
||||||
|
timestamp = datetime.fromtimestamp(error["timestamp"]).strftime(
|
||||||
|
"%H:%M:%S"
|
||||||
|
)
|
||||||
errors_text += f"{i}. {timestamp} - Chat {error['chat_id']} - {error['error_type']}\n"
|
errors_text += f"{i}. {timestamp} - Chat {error['chat_id']} - {error['error_type']}\n"
|
||||||
|
|
||||||
# Если сообщение слишком длинное, разбиваем на части
|
# Если сообщение слишком длинное, разбиваем на части
|
||||||
if len(errors_text) > 4000:
|
if len(errors_text) > 4000:
|
||||||
# Отправляем сводку
|
# Отправляем сводку
|
||||||
@@ -192,32 +203,37 @@ class RateLimitHandlers:
|
|||||||
for error_type, count in error_summary.items():
|
for error_type, count in error_summary.items():
|
||||||
summary_text += f"• {error_type}: {count}\n"
|
summary_text += f"• {error_type}: {count}\n"
|
||||||
summary_text += f"\nВсего ошибок: {len(recent_errors)}"
|
summary_text += f"\nВсего ошибок: {len(recent_errors)}"
|
||||||
|
|
||||||
await message.answer(summary_text, parse_mode='HTML')
|
await message.answer(summary_text, parse_mode="HTML")
|
||||||
|
|
||||||
# Отправляем детали отдельным сообщением
|
# Отправляем детали отдельным сообщением
|
||||||
details_text = f"🔍 <b>Последние ошибки:</b>\n"
|
details_text = f"🔍 <b>Последние ошибки:</b>\n"
|
||||||
for i, error in enumerate(recent_errors[-10:], 1):
|
for i, error in enumerate(recent_errors[-10:], 1):
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
timestamp = datetime.fromtimestamp(error['timestamp']).strftime("%H:%M:%S")
|
|
||||||
|
timestamp = datetime.fromtimestamp(error["timestamp"]).strftime(
|
||||||
|
"%H:%M:%S"
|
||||||
|
)
|
||||||
details_text += f"{i}. {timestamp} - Chat {error['chat_id']} - {error['error_type']}\n"
|
details_text += f"{i}. {timestamp} - Chat {error['chat_id']} - {error['error_type']}\n"
|
||||||
|
|
||||||
await message.answer(details_text, parse_mode='HTML')
|
await message.answer(details_text, parse_mode="HTML")
|
||||||
else:
|
else:
|
||||||
await message.answer(errors_text, parse_mode='HTML')
|
await message.answer(errors_text, parse_mode="HTML")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при получении ошибок rate limiting: {e}")
|
logger.error(f"Ошибка при получении ошибок rate limiting: {e}")
|
||||||
await message.answer("Произошла ошибка при получении информации об ошибках.")
|
await message.answer(
|
||||||
|
"Произошла ошибка при получении информации об ошибках."
|
||||||
|
)
|
||||||
|
|
||||||
@track_time("rate_limit_prometheus_handler", "rate_limit_handlers")
|
@track_time("rate_limit_prometheus_handler", "rate_limit_handlers")
|
||||||
@track_errors("rate_limit_handlers", "rate_limit_prometheus_handler")
|
@track_errors("rate_limit_handlers", "rate_limit_prometheus_handler")
|
||||||
async def rate_limit_prometheus_handler(
|
async def rate_limit_prometheus_handler(
|
||||||
self,
|
self,
|
||||||
message: types.Message,
|
message: types.Message,
|
||||||
state: FSMContext,
|
state: FSMContext,
|
||||||
bot_db: MagicData("bot_db"),
|
bot_db: MagicData("bot_db"),
|
||||||
settings: MagicData("settings")
|
settings: MagicData("settings"),
|
||||||
):
|
):
|
||||||
"""Показывает Prometheus метрики rate limiting"""
|
"""Показывает Prometheus метрики rate limiting"""
|
||||||
try:
|
try:
|
||||||
@@ -225,13 +241,13 @@ class RateLimitHandlers:
|
|||||||
if not await bot_db.is_admin(message.from_user.id):
|
if not await bot_db.is_admin(message.from_user.id):
|
||||||
await message.answer("У вас нет прав для выполнения этой команды.")
|
await message.answer("У вас нет прав для выполнения этой команды.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Обновляем gauge метрики
|
# Обновляем gauge метрики
|
||||||
update_rate_limit_gauges()
|
update_rate_limit_gauges()
|
||||||
|
|
||||||
# Получаем сводку метрик
|
# Получаем сводку метрик
|
||||||
metrics_summary = get_rate_limit_metrics_summary()
|
metrics_summary = get_rate_limit_metrics_summary()
|
||||||
|
|
||||||
# Формируем сообщение с метриками
|
# Формируем сообщение с метриками
|
||||||
metrics_text = (
|
metrics_text = (
|
||||||
f"📊 <b>Prometheus метрики Rate Limiting</b>\n\n"
|
f"📊 <b>Prometheus метрики Rate Limiting</b>\n\n"
|
||||||
@@ -243,30 +259,40 @@ class RateLimitHandlers:
|
|||||||
f"• rate_limit_avg_wait_time: {metrics_summary['average_wait_time']:.3f}s\n"
|
f"• rate_limit_avg_wait_time: {metrics_summary['average_wait_time']:.3f}s\n"
|
||||||
f"• rate_limit_active_chats: {metrics_summary['active_chats']}\n\n"
|
f"• rate_limit_active_chats: {metrics_summary['active_chats']}\n\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Добавляем детальные метрики
|
# Добавляем детальные метрики
|
||||||
metrics_text += f"🔍 <b>Детальные метрики:</b>\n"
|
metrics_text += f"🔍 <b>Детальные метрики:</b>\n"
|
||||||
metrics_text += f"• Успешных запросов: {metrics_summary['successful_requests']}\n"
|
metrics_text += (
|
||||||
metrics_text += f"• Неудачных запросов: {metrics_summary['failed_requests']}\n"
|
f"• Успешных запросов: {metrics_summary['successful_requests']}\n"
|
||||||
metrics_text += f"• RetryAfter ошибок: {metrics_summary['retry_after_errors']}\n"
|
)
|
||||||
|
metrics_text += (
|
||||||
|
f"• Неудачных запросов: {metrics_summary['failed_requests']}\n"
|
||||||
|
)
|
||||||
|
metrics_text += (
|
||||||
|
f"• RetryAfter ошибок: {metrics_summary['retry_after_errors']}\n"
|
||||||
|
)
|
||||||
metrics_text += f"• Других ошибок: {metrics_summary['other_errors']}\n"
|
metrics_text += f"• Других ошибок: {metrics_summary['other_errors']}\n"
|
||||||
metrics_text += f"• Общее время ожидания: {metrics_summary['total_wait_time']:.2f}s\n\n"
|
metrics_text += (
|
||||||
|
f"• Общее время ожидания: {metrics_summary['total_wait_time']:.2f}s\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
# Добавляем информацию о доступных метриках
|
# Добавляем информацию о доступных метриках
|
||||||
metrics_text += f"📈 <b>Доступные Prometheus метрики:</b>\n"
|
metrics_text += f"📈 <b>Доступные Prometheus метрики:</b>\n"
|
||||||
metrics_text += f"• rate_limit_requests_total - общее количество запросов\n"
|
metrics_text += f"• rate_limit_requests_total - общее количество запросов\n"
|
||||||
metrics_text += f"• rate_limit_errors_total - количество ошибок по типам\n"
|
metrics_text += f"• rate_limit_errors_total - количество ошибок по типам\n"
|
||||||
metrics_text += f"• rate_limit_wait_duration_seconds - время ожидания\n"
|
metrics_text += f"• rate_limit_wait_duration_seconds - время ожидания\n"
|
||||||
metrics_text += f"• rate_limit_request_interval_seconds - интервалы между запросами\n"
|
metrics_text += (
|
||||||
|
f"• rate_limit_request_interval_seconds - интервалы между запросами\n"
|
||||||
|
)
|
||||||
metrics_text += f"• rate_limit_active_chats - количество активных чатов\n"
|
metrics_text += f"• rate_limit_active_chats - количество активных чатов\n"
|
||||||
metrics_text += f"• rate_limit_success_rate - процент успеха по чатам\n"
|
metrics_text += f"• rate_limit_success_rate - процент успеха по чатам\n"
|
||||||
metrics_text += f"• rate_limit_requests_per_minute - запросов в минуту\n"
|
metrics_text += f"• rate_limit_requests_per_minute - запросов в минуту\n"
|
||||||
metrics_text += f"• rate_limit_total_requests - общее количество запросов\n"
|
metrics_text += f"• rate_limit_total_requests - общее количество запросов\n"
|
||||||
metrics_text += f"• rate_limit_total_errors - количество ошибок\n"
|
metrics_text += f"• rate_limit_total_errors - количество ошибок\n"
|
||||||
metrics_text += f"• rate_limit_avg_wait_time - среднее время ожидания\n"
|
metrics_text += f"• rate_limit_avg_wait_time - среднее время ожидания\n"
|
||||||
|
|
||||||
await message.answer(metrics_text, parse_mode='HTML')
|
await message.answer(metrics_text, parse_mode="HTML")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при получении Prometheus метрик: {e}")
|
logger.error(f"Ошибка при получении Prometheus метрик: {e}")
|
||||||
await message.answer("Произошла ошибка при получении метрик.")
|
await message.answer("Произошла ошибка при получении метрик.")
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
from typing import List, Optional
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
from helper_bot.utils.helper_func import add_days_to_date, get_banned_users_buttons, get_banned_users_list
|
from helper_bot.handlers.admin.exceptions import (
|
||||||
from helper_bot.handlers.admin.exceptions import UserAlreadyBannedError, InvalidInputError
|
InvalidInputError,
|
||||||
from logs.custom_logger import logger
|
UserAlreadyBannedError,
|
||||||
|
)
|
||||||
|
from helper_bot.utils.helper_func import (
|
||||||
|
add_days_to_date,
|
||||||
|
get_banned_users_buttons,
|
||||||
|
get_banned_users_list,
|
||||||
|
)
|
||||||
|
|
||||||
# Local imports - metrics
|
# Local imports - metrics
|
||||||
from helper_bot.utils.metrics import (
|
from helper_bot.utils.metrics import track_errors, track_time
|
||||||
track_time,
|
from logs.custom_logger import logger
|
||||||
track_errors
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class User:
|
class User:
|
||||||
"""Модель пользователя"""
|
"""Модель пользователя"""
|
||||||
|
|
||||||
def __init__(self, user_id: int, username: str, full_name: str):
|
def __init__(self, user_id: int, username: str, full_name: str):
|
||||||
self.user_id = user_id
|
self.user_id = user_id
|
||||||
self.username = username
|
self.username = username
|
||||||
@@ -22,7 +27,10 @@ class User:
|
|||||||
|
|
||||||
class BannedUser:
|
class BannedUser:
|
||||||
"""Модель заблокированного пользователя"""
|
"""Модель заблокированного пользователя"""
|
||||||
def __init__(self, user_id: int, username: str, reason: str, unban_date: Optional[datetime]):
|
|
||||||
|
def __init__(
|
||||||
|
self, user_id: int, username: str, reason: str, unban_date: Optional[datetime]
|
||||||
|
):
|
||||||
self.user_id = user_id
|
self.user_id = user_id
|
||||||
self.username = username
|
self.username = username
|
||||||
self.reason = reason
|
self.reason = reason
|
||||||
@@ -31,10 +39,10 @@ class BannedUser:
|
|||||||
|
|
||||||
class AdminService:
|
class AdminService:
|
||||||
"""Сервис для административных операций"""
|
"""Сервис для административных операций"""
|
||||||
|
|
||||||
def __init__(self, bot_db):
|
def __init__(self, bot_db):
|
||||||
self.bot_db = bot_db
|
self.bot_db = bot_db
|
||||||
|
|
||||||
@track_time("get_last_users", "admin_service")
|
@track_time("get_last_users", "admin_service")
|
||||||
@track_errors("admin_service", "get_last_users")
|
@track_errors("admin_service", "get_last_users")
|
||||||
async def get_last_users(self) -> List[User]:
|
async def get_last_users(self) -> List[User]:
|
||||||
@@ -42,17 +50,13 @@ class AdminService:
|
|||||||
try:
|
try:
|
||||||
users_data = await self.bot_db.get_last_users(30)
|
users_data = await self.bot_db.get_last_users(30)
|
||||||
return [
|
return [
|
||||||
User(
|
User(user_id=user[1], username="Неизвестно", full_name=user[0])
|
||||||
user_id=user[1],
|
|
||||||
username='Неизвестно',
|
|
||||||
full_name=user[0]
|
|
||||||
)
|
|
||||||
for user in users_data
|
for user in users_data
|
||||||
]
|
]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при получении списка последних пользователей: {e}")
|
logger.error(f"Ошибка при получении списка последних пользователей: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@track_time("get_banned_users", "admin_service")
|
@track_time("get_banned_users", "admin_service")
|
||||||
@track_errors("admin_service", "get_banned_users")
|
@track_errors("admin_service", "get_banned_users")
|
||||||
async def get_banned_users(self) -> List[BannedUser]:
|
async def get_banned_users(self) -> List[BannedUser]:
|
||||||
@@ -66,18 +70,22 @@ class AdminService:
|
|||||||
username = await self.bot_db.get_username(user_id)
|
username = await self.bot_db.get_username(user_id)
|
||||||
full_name = await self.bot_db.get_full_name_by_id(user_id)
|
full_name = await self.bot_db.get_full_name_by_id(user_id)
|
||||||
user_name = username or full_name or f"User_{user_id}"
|
user_name = username or full_name or f"User_{user_id}"
|
||||||
|
|
||||||
banned_users.append(BannedUser(
|
banned_users.append(
|
||||||
user_id=user_id,
|
BannedUser(
|
||||||
username=user_name,
|
user_id=user_id,
|
||||||
reason=reason,
|
username=user_name,
|
||||||
unban_date=unban_date
|
reason=reason,
|
||||||
))
|
unban_date=unban_date,
|
||||||
|
)
|
||||||
|
)
|
||||||
return banned_users
|
return banned_users
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при получении списка заблокированных пользователей: {e}")
|
logger.error(
|
||||||
|
f"Ошибка при получении списка заблокированных пользователей: {e}"
|
||||||
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@track_time("get_user_by_username", "admin_service")
|
@track_time("get_user_by_username", "admin_service")
|
||||||
@track_errors("admin_service", "get_user_by_username")
|
@track_errors("admin_service", "get_user_by_username")
|
||||||
async def get_user_by_username(self, username: str) -> Optional[User]:
|
async def get_user_by_username(self, username: str) -> Optional[User]:
|
||||||
@@ -86,17 +94,15 @@ class AdminService:
|
|||||||
user_id = await self.bot_db.get_user_id_by_username(username)
|
user_id = await self.bot_db.get_user_id_by_username(username)
|
||||||
if not user_id:
|
if not user_id:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
full_name = await self.bot_db.get_full_name_by_id(user_id)
|
full_name = await self.bot_db.get_full_name_by_id(user_id)
|
||||||
return User(
|
return User(
|
||||||
user_id=user_id,
|
user_id=user_id, username=username, full_name=full_name or "Неизвестно"
|
||||||
username=username,
|
|
||||||
full_name=full_name or 'Неизвестно'
|
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при поиске пользователя по username {username}: {e}")
|
logger.error(f"Ошибка при поиске пользователя по username {username}: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@track_time("get_user_by_id", "admin_service")
|
@track_time("get_user_by_id", "admin_service")
|
||||||
@track_errors("admin_service", "get_user_by_id")
|
@track_errors("admin_service", "get_user_by_id")
|
||||||
async def get_user_by_id(self, user_id: int) -> Optional[User]:
|
async def get_user_by_id(self, user_id: int) -> Optional[User]:
|
||||||
@@ -105,39 +111,50 @@ class AdminService:
|
|||||||
user_info = await self.bot_db.get_user_by_id(user_id)
|
user_info = await self.bot_db.get_user_by_id(user_id)
|
||||||
if not user_info:
|
if not user_info:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return User(
|
return User(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
username=user_info.username or 'Неизвестно',
|
username=user_info.username or "Неизвестно",
|
||||||
full_name=user_info.full_name or 'Неизвестно'
|
full_name=user_info.full_name or "Неизвестно",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при поиске пользователя по ID {user_id}: {e}")
|
logger.error(f"Ошибка при поиске пользователя по ID {user_id}: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@track_time("ban_user", "admin_service")
|
@track_time("ban_user", "admin_service")
|
||||||
@track_errors("admin_service", "ban_user")
|
@track_errors("admin_service", "ban_user")
|
||||||
async def ban_user(self, user_id: int, username: str, reason: str, ban_days: Optional[int]) -> None:
|
async def ban_user(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
username: str,
|
||||||
|
reason: str,
|
||||||
|
ban_days: Optional[int],
|
||||||
|
ban_author_id: int,
|
||||||
|
) -> None:
|
||||||
"""Заблокировать пользователя"""
|
"""Заблокировать пользователя"""
|
||||||
try:
|
try:
|
||||||
# Проверяем, не заблокирован ли уже пользователь
|
# Проверяем, не заблокирован ли уже пользователь
|
||||||
if await self.bot_db.check_user_in_blacklist(user_id):
|
if await self.bot_db.check_user_in_blacklist(user_id):
|
||||||
raise UserAlreadyBannedError(f"Пользователь {user_id} уже заблокирован")
|
raise UserAlreadyBannedError(f"Пользователь {user_id} уже заблокирован")
|
||||||
|
|
||||||
# Рассчитываем дату разблокировки
|
# Рассчитываем дату разблокировки
|
||||||
date_to_unban = None
|
date_to_unban = None
|
||||||
if ban_days is not None:
|
if ban_days is not None:
|
||||||
date_to_unban = add_days_to_date(ban_days)
|
date_to_unban = add_days_to_date(ban_days)
|
||||||
|
|
||||||
# Сохраняем в БД (username больше не передается, так как не используется в новой схеме)
|
# Сохраняем в БД (username больше не передается, так как не используется в новой схеме)
|
||||||
await self.bot_db.set_user_blacklist(user_id, None, reason, date_to_unban)
|
await self.bot_db.set_user_blacklist(
|
||||||
|
user_id, None, reason, date_to_unban, ban_author=ban_author_id
|
||||||
logger.info(f"Пользователь {user_id} ({username}) заблокирован. Причина: {reason}, срок: {ban_days} дней")
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Пользователь {user_id} ({username}) заблокирован. Причина: {reason}, срок: {ban_days} дней"
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при блокировке пользователя {user_id}: {e}")
|
logger.error(f"Ошибка при блокировке пользователя {user_id}: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@track_time("unban_user", "admin_service")
|
@track_time("unban_user", "admin_service")
|
||||||
@track_errors("admin_service", "unban_user")
|
@track_errors("admin_service", "unban_user")
|
||||||
async def unban_user(self, user_id: int) -> None:
|
async def unban_user(self, user_id: int) -> None:
|
||||||
@@ -148,7 +165,7 @@ class AdminService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при разблокировке пользователя {user_id}: {e}")
|
logger.error(f"Ошибка при разблокировке пользователя {user_id}: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@track_time("validate_user_input", "admin_service")
|
@track_time("validate_user_input", "admin_service")
|
||||||
@track_errors("admin_service", "validate_user_input")
|
@track_errors("admin_service", "validate_user_input")
|
||||||
async def validate_user_input(self, input_text: str) -> int:
|
async def validate_user_input(self, input_text: str) -> int:
|
||||||
@@ -156,11 +173,13 @@ class AdminService:
|
|||||||
try:
|
try:
|
||||||
user_id = int(input_text.strip())
|
user_id = int(input_text.strip())
|
||||||
if user_id <= 0:
|
if user_id <= 0:
|
||||||
raise InvalidInputError("ID пользователя должен быть положительным числом")
|
raise InvalidInputError(
|
||||||
|
"ID пользователя должен быть положительным числом"
|
||||||
|
)
|
||||||
return user_id
|
return user_id
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise InvalidInputError("ID пользователя должен быть числом")
|
raise InvalidInputError("ID пользователя должен быть числом")
|
||||||
|
|
||||||
@track_time("get_banned_users_for_display", "admin_service")
|
@track_time("get_banned_users_for_display", "admin_service")
|
||||||
@track_errors("admin_service", "get_banned_users_for_display")
|
@track_errors("admin_service", "get_banned_users_for_display")
|
||||||
async def get_banned_users_for_display(self, page: int = 0) -> tuple[str, list]:
|
async def get_banned_users_for_display(self, page: int = 0) -> tuple[str, list]:
|
||||||
@@ -171,5 +190,7 @@ class AdminService:
|
|||||||
buttons_list = await get_banned_users_buttons(self.bot_db)
|
buttons_list = await get_banned_users_buttons(self.bot_db)
|
||||||
return message_text, buttons_list
|
return message_text, buttons_list
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при получении данных заблокированных пользователей: {e}")
|
logger.error(
|
||||||
|
f"Ошибка при получении данных заблокированных пользователей: {e}"
|
||||||
|
)
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import html
|
import html
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from aiogram import types
|
from aiogram import types
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
|
|
||||||
from helper_bot.keyboards.keyboards import get_reply_keyboard_admin
|
|
||||||
from helper_bot.handlers.admin.exceptions import AdminError
|
from helper_bot.handlers.admin.exceptions import AdminError
|
||||||
|
from helper_bot.keyboards.keyboards import get_reply_keyboard_admin
|
||||||
from logs.custom_logger import logger
|
from logs.custom_logger import logger
|
||||||
|
|
||||||
|
|
||||||
@@ -13,33 +14,41 @@ def escape_html(text: str) -> str:
|
|||||||
return html.escape(str(text)) if text else ""
|
return html.escape(str(text)) if text else ""
|
||||||
|
|
||||||
|
|
||||||
async def return_to_admin_menu(message: types.Message, state: FSMContext,
|
async def return_to_admin_menu(
|
||||||
additional_message: Optional[str] = None) -> None:
|
message: types.Message, state: FSMContext, additional_message: Optional[str] = None
|
||||||
|
) -> None:
|
||||||
"""Универсальная функция для возврата в админ-меню"""
|
"""Универсальная функция для возврата в админ-меню"""
|
||||||
logger.info(f"return_to_admin_menu: Возврат в админ-меню для пользователя {message.from_user.id}")
|
logger.info(
|
||||||
|
f"return_to_admin_menu: Возврат в админ-меню для пользователя {message.from_user.id}"
|
||||||
|
)
|
||||||
|
|
||||||
await state.set_data({})
|
await state.set_data({})
|
||||||
await state.set_state("ADMIN")
|
await state.set_state("ADMIN")
|
||||||
markup = get_reply_keyboard_admin()
|
markup = get_reply_keyboard_admin()
|
||||||
|
|
||||||
if additional_message:
|
if additional_message:
|
||||||
logger.info(f"return_to_admin_menu: Отправка дополнительного сообщения: {additional_message}")
|
logger.info(
|
||||||
|
f"return_to_admin_menu: Отправка дополнительного сообщения: {additional_message}"
|
||||||
|
)
|
||||||
await message.answer(additional_message)
|
await message.answer(additional_message)
|
||||||
|
|
||||||
await message.answer('Вернулись в меню', reply_markup=markup)
|
await message.answer("Вернулись в меню", reply_markup=markup)
|
||||||
logger.info(f"return_to_admin_menu: Пользователь {message.from_user.id} успешно возвращен в админ-меню")
|
logger.info(
|
||||||
|
f"return_to_admin_menu: Пользователь {message.from_user.id} успешно возвращен в админ-меню"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def handle_admin_error(message: types.Message, error: Exception,
|
async def handle_admin_error(
|
||||||
state: FSMContext, error_context: str = "") -> None:
|
message: types.Message, error: Exception, state: FSMContext, error_context: str = ""
|
||||||
|
) -> None:
|
||||||
"""Централизованная обработка ошибок административных операций"""
|
"""Централизованная обработка ошибок административных операций"""
|
||||||
logger.error(f"Ошибка в {error_context}: {error}")
|
logger.error(f"Ошибка в {error_context}: {error}")
|
||||||
|
|
||||||
if isinstance(error, AdminError):
|
if isinstance(error, AdminError):
|
||||||
await message.answer(f"Ошибка: {str(error)}")
|
await message.answer(f"Ошибка: {str(error)}")
|
||||||
else:
|
else:
|
||||||
await message.answer("Произошла внутренняя ошибка. Попробуйте позже.")
|
await message.answer("Произошла внутренняя ошибка. Попробуйте позже.")
|
||||||
|
|
||||||
await return_to_admin_menu(message, state)
|
await return_to_admin_menu(message, state)
|
||||||
|
|
||||||
|
|
||||||
@@ -47,19 +56,23 @@ def format_user_info(user_id: int, username: str, full_name: str) -> str:
|
|||||||
"""Форматирование информации о пользователе для отображения"""
|
"""Форматирование информации о пользователе для отображения"""
|
||||||
safe_username = escape_html(username)
|
safe_username = escape_html(username)
|
||||||
safe_full_name = escape_html(full_name)
|
safe_full_name = escape_html(full_name)
|
||||||
|
|
||||||
return (f"<b>Выбран пользователь:</b>\n"
|
return (
|
||||||
f"<b>ID:</b> {user_id}\n"
|
f"<b>Выбран пользователь:</b>\n"
|
||||||
f"<b>Username:</b> {safe_username}\n"
|
f"<b>ID:</b> {user_id}\n"
|
||||||
f"<b>Имя:</b> {safe_full_name}")
|
f"<b>Username:</b> {safe_username}\n"
|
||||||
|
f"<b>Имя:</b> {safe_full_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def format_ban_confirmation(user_id: int, reason: str, ban_days: Optional[int]) -> str:
|
def format_ban_confirmation(user_id: int, reason: str, ban_days: Optional[int]) -> str:
|
||||||
"""Форматирование подтверждения бана"""
|
"""Форматирование подтверждения бана"""
|
||||||
safe_reason = escape_html(reason)
|
safe_reason = escape_html(reason)
|
||||||
ban_text = "Навсегда" if ban_days is None else f"{ban_days} дней"
|
ban_text = "Навсегда" if ban_days is None else f"{ban_days} дней"
|
||||||
|
|
||||||
return (f"<b>Необходимо подтверждение:</b>\n"
|
return (
|
||||||
f"<b>Пользователь:</b> {user_id}\n"
|
f"<b>Необходимо подтверждение:</b>\n"
|
||||||
f"<b>Причина бана:</b> {safe_reason}\n"
|
f"<b>Пользователь:</b> {user_id}\n"
|
||||||
f"<b>Срок бана:</b> {ban_text}")
|
f"<b>Причина бана:</b> {safe_reason}\n"
|
||||||
|
f"<b>Срок бана:</b> {ban_text}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,24 +1,34 @@
|
|||||||
from .callback_handlers import callback_router
|
from .callback_handlers import callback_router
|
||||||
from .services import PostPublishService, BanService
|
|
||||||
from .exceptions import UserBlockedBotError, PostNotFoundError, UserNotFoundError, PublishError, BanError
|
|
||||||
from .constants import (
|
from .constants import (
|
||||||
CALLBACK_PUBLISH, CALLBACK_DECLINE, CALLBACK_BAN, CALLBACK_UNLOCK,
|
CALLBACK_BAN,
|
||||||
CALLBACK_RETURN, CALLBACK_PAGE
|
CALLBACK_DECLINE,
|
||||||
|
CALLBACK_PAGE,
|
||||||
|
CALLBACK_PUBLISH,
|
||||||
|
CALLBACK_RETURN,
|
||||||
|
CALLBACK_UNLOCK,
|
||||||
)
|
)
|
||||||
|
from .exceptions import (
|
||||||
|
BanError,
|
||||||
|
PostNotFoundError,
|
||||||
|
PublishError,
|
||||||
|
UserBlockedBotError,
|
||||||
|
UserNotFoundError,
|
||||||
|
)
|
||||||
|
from .services import BanService, PostPublishService
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'callback_router',
|
"callback_router",
|
||||||
'PostPublishService',
|
"PostPublishService",
|
||||||
'BanService',
|
"BanService",
|
||||||
'UserBlockedBotError',
|
"UserBlockedBotError",
|
||||||
'PostNotFoundError',
|
"PostNotFoundError",
|
||||||
'UserNotFoundError',
|
"UserNotFoundError",
|
||||||
'PublishError',
|
"PublishError",
|
||||||
'BanError',
|
"BanError",
|
||||||
'CALLBACK_PUBLISH',
|
"CALLBACK_PUBLISH",
|
||||||
'CALLBACK_DECLINE',
|
"CALLBACK_DECLINE",
|
||||||
'CALLBACK_BAN',
|
"CALLBACK_BAN",
|
||||||
'CALLBACK_UNLOCK',
|
"CALLBACK_UNLOCK",
|
||||||
'CALLBACK_RETURN',
|
"CALLBACK_RETURN",
|
||||||
'CALLBACK_PAGE'
|
"CALLBACK_PAGE",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,35 +1,54 @@
|
|||||||
import html
|
import html
|
||||||
import traceback
|
|
||||||
import time
|
import time
|
||||||
|
import traceback
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from aiogram import Router, F
|
from aiogram import F, Router
|
||||||
from aiogram.types import CallbackQuery
|
|
||||||
from aiogram.fsm.context import FSMContext
|
|
||||||
from aiogram.filters import MagicData
|
from aiogram.filters import MagicData
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.types import CallbackQuery
|
||||||
|
|
||||||
from helper_bot.handlers.voice.constants import CALLBACK_SAVE, CALLBACK_DELETE
|
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
|
from helper_bot.handlers.voice.services import AudioFileService
|
||||||
from helper_bot.keyboards.keyboards import create_keyboard_with_pagination, get_reply_keyboard_admin, \
|
from helper_bot.keyboards.keyboards import (
|
||||||
create_keyboard_for_ban_reason
|
create_keyboard_for_ban_reason,
|
||||||
from helper_bot.utils.helper_func import get_banned_users_list, get_banned_users_buttons
|
create_keyboard_with_pagination,
|
||||||
from helper_bot.utils.base_dependency_factory import get_global_instance
|
get_reply_keyboard_admin,
|
||||||
from .dependency_factory import get_post_publish_service, get_ban_service
|
|
||||||
from .exceptions import UserBlockedBotError, PostNotFoundError, UserNotFoundError, PublishError, BanError
|
|
||||||
from .constants import (
|
|
||||||
CALLBACK_PUBLISH, CALLBACK_DECLINE, CALLBACK_BAN, CALLBACK_UNLOCK,
|
|
||||||
CALLBACK_RETURN, CALLBACK_PAGE, MESSAGE_PUBLISHED, MESSAGE_DECLINED,
|
|
||||||
MESSAGE_USER_BANNED, MESSAGE_USER_UNLOCKED, MESSAGE_ERROR,
|
|
||||||
ERROR_BOT_BLOCKED
|
|
||||||
)
|
)
|
||||||
from logs.custom_logger import logger
|
from helper_bot.utils.base_dependency_factory import get_global_instance
|
||||||
|
from helper_bot.utils.helper_func import get_banned_users_buttons, get_banned_users_list
|
||||||
|
|
||||||
# Local imports - metrics
|
# Local imports - metrics
|
||||||
from helper_bot.utils.metrics import (
|
from helper_bot.utils.metrics import (
|
||||||
track_time,
|
|
||||||
track_errors,
|
|
||||||
db_query_time,
|
db_query_time,
|
||||||
track_file_operations
|
track_errors,
|
||||||
|
track_file_operations,
|
||||||
|
track_time,
|
||||||
|
)
|
||||||
|
from logs.custom_logger import logger
|
||||||
|
|
||||||
|
from .constants import (
|
||||||
|
CALLBACK_BAN,
|
||||||
|
CALLBACK_DECLINE,
|
||||||
|
CALLBACK_PAGE,
|
||||||
|
CALLBACK_PUBLISH,
|
||||||
|
CALLBACK_RETURN,
|
||||||
|
CALLBACK_UNLOCK,
|
||||||
|
ERROR_BOT_BLOCKED,
|
||||||
|
MESSAGE_DECLINED,
|
||||||
|
MESSAGE_ERROR,
|
||||||
|
MESSAGE_PUBLISHED,
|
||||||
|
MESSAGE_USER_BANNED,
|
||||||
|
MESSAGE_USER_UNLOCKED,
|
||||||
|
)
|
||||||
|
from .dependency_factory import get_ban_service, get_post_publish_service
|
||||||
|
from .exceptions import (
|
||||||
|
BanError,
|
||||||
|
PostNotFoundError,
|
||||||
|
PublishError,
|
||||||
|
UserBlockedBotError,
|
||||||
|
UserNotFoundError,
|
||||||
)
|
)
|
||||||
|
|
||||||
callback_router = Router()
|
callback_router = Router()
|
||||||
@@ -38,65 +57,61 @@ callback_router = Router()
|
|||||||
@callback_router.callback_query(F.data == CALLBACK_PUBLISH)
|
@callback_router.callback_query(F.data == CALLBACK_PUBLISH)
|
||||||
@track_time("post_for_group", "callback_handlers")
|
@track_time("post_for_group", "callback_handlers")
|
||||||
@track_errors("callback_handlers", "post_for_group")
|
@track_errors("callback_handlers", "post_for_group")
|
||||||
async def post_for_group(
|
async def post_for_group(call: CallbackQuery, settings: MagicData("settings")):
|
||||||
call: CallbackQuery,
|
|
||||||
settings: MagicData("settings")
|
|
||||||
):
|
|
||||||
publish_service = get_post_publish_service()
|
publish_service = get_post_publish_service()
|
||||||
# TODO: переделать на MagicData
|
# TODO: переделать на MagicData
|
||||||
logger.info(
|
logger.info(
|
||||||
f'Получен callback-запрос с действием: {call.data} от пользователя {call.from_user.full_name} (ID сообщения: {call.message.message_id})')
|
f"Получен callback-запрос с действием: {call.data} от пользователя {call.from_user.full_name} (ID сообщения: {call.message.message_id})"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await publish_service.publish_post(call)
|
await publish_service.publish_post(call)
|
||||||
await call.answer(text=MESSAGE_PUBLISHED, cache_time=3)
|
await call.answer(text=MESSAGE_PUBLISHED, cache_time=3)
|
||||||
except UserBlockedBotError:
|
except UserBlockedBotError:
|
||||||
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
|
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
|
||||||
except (PostNotFoundError, PublishError) as e:
|
except (PostNotFoundError, PublishError) as e:
|
||||||
logger.error(f'Ошибка при публикации поста: {str(e)}')
|
logger.error(f"Ошибка при публикации поста: {str(e)}")
|
||||||
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
|
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if str(e) == ERROR_BOT_BLOCKED:
|
if str(e) == ERROR_BOT_BLOCKED:
|
||||||
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
|
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
|
||||||
else:
|
else:
|
||||||
important_logs = settings['Telegram']['important_logs']
|
important_logs = settings["Telegram"]["important_logs"]
|
||||||
await call.bot.send_message(
|
await call.bot.send_message(
|
||||||
chat_id=important_logs,
|
chat_id=important_logs,
|
||||||
text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}",
|
||||||
)
|
)
|
||||||
logger.error(f'Неожиданная ошибка при публикации поста: {str(e)}')
|
logger.error(f"Неожиданная ошибка при публикации поста: {str(e)}")
|
||||||
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
|
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
|
||||||
|
|
||||||
|
|
||||||
@callback_router.callback_query(F.data == CALLBACK_DECLINE)
|
@callback_router.callback_query(F.data == CALLBACK_DECLINE)
|
||||||
@track_time("decline_post_for_group", "callback_handlers")
|
@track_time("decline_post_for_group", "callback_handlers")
|
||||||
@track_errors("callback_handlers", "decline_post_for_group")
|
@track_errors("callback_handlers", "decline_post_for_group")
|
||||||
async def decline_post_for_group(
|
async def decline_post_for_group(call: CallbackQuery, settings: MagicData("settings")):
|
||||||
call: CallbackQuery,
|
|
||||||
settings: MagicData("settings")
|
|
||||||
):
|
|
||||||
publish_service = get_post_publish_service()
|
publish_service = get_post_publish_service()
|
||||||
# TODO: переделать на MagicData
|
# TODO: переделать на MagicData
|
||||||
logger.info(
|
logger.info(
|
||||||
f'Получен callback-запрос с данными: {call.data} от пользователя {call.from_user.full_name} (ID: {call.from_user.id})')
|
f"Получен callback-запрос с данными: {call.data} от пользователя {call.from_user.full_name} (ID: {call.from_user.id})"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
await publish_service.decline_post(call)
|
await publish_service.decline_post(call)
|
||||||
await call.answer(text=MESSAGE_DECLINED, cache_time=3)
|
await call.answer(text=MESSAGE_DECLINED, cache_time=3)
|
||||||
except UserBlockedBotError:
|
except UserBlockedBotError:
|
||||||
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
|
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
|
||||||
except (PostNotFoundError, PublishError) as e:
|
except (PostNotFoundError, PublishError) as e:
|
||||||
logger.error(f'Ошибка при отклонении поста: {str(e)}')
|
logger.error(f"Ошибка при отклонении поста: {str(e)}")
|
||||||
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
|
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if str(e) == ERROR_BOT_BLOCKED:
|
if str(e) == ERROR_BOT_BLOCKED:
|
||||||
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
|
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
|
||||||
else:
|
else:
|
||||||
important_logs = settings['Telegram']['important_logs']
|
important_logs = settings["Telegram"]["important_logs"]
|
||||||
await call.bot.send_message(
|
await call.bot.send_message(
|
||||||
chat_id=important_logs,
|
chat_id=important_logs,
|
||||||
text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}",
|
||||||
)
|
)
|
||||||
logger.error(f'Неожиданная ошибка при отклонении поста: {str(e)}')
|
logger.error(f"Неожиданная ошибка при отклонении поста: {str(e)}")
|
||||||
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
|
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
|
||||||
|
|
||||||
|
|
||||||
@@ -112,49 +127,75 @@ async def ban_user_from_post(call: CallbackQuery, **kwargs):
|
|||||||
except UserBlockedBotError:
|
except UserBlockedBotError:
|
||||||
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
|
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
|
||||||
except (UserNotFoundError, BanError) as e:
|
except (UserNotFoundError, BanError) as e:
|
||||||
logger.error(f'Ошибка при блокировке пользователя: {str(e)}')
|
logger.error(f"Ошибка при блокировке пользователя: {str(e)}")
|
||||||
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
|
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if str(e) == ERROR_BOT_BLOCKED:
|
if str(e) == ERROR_BOT_BLOCKED:
|
||||||
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
|
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
|
||||||
else:
|
else:
|
||||||
logger.error(f'Неожиданная ошибка при блокировке пользователя: {str(e)}')
|
logger.error(f"Неожиданная ошибка при блокировке пользователя: {str(e)}")
|
||||||
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
|
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
|
||||||
|
|
||||||
|
|
||||||
@callback_router.callback_query(F.data.contains(CALLBACK_BAN))
|
@callback_router.callback_query(F.data.contains(CALLBACK_BAN))
|
||||||
@track_time("process_ban_user", "callback_handlers")
|
@track_time("process_ban_user", "callback_handlers")
|
||||||
@track_errors("callback_handlers", "process_ban_user")
|
@track_errors("callback_handlers", "process_ban_user")
|
||||||
async def process_ban_user(call: CallbackQuery, state: FSMContext, **kwargs):
|
async def process_ban_user(
|
||||||
|
call: CallbackQuery, state: FSMContext, bot_db: MagicData("bot_db"), **kwargs
|
||||||
|
):
|
||||||
ban_service = get_ban_service()
|
ban_service = get_ban_service()
|
||||||
# TODO: переделать на MagicData
|
# TODO: переделать на MagicData
|
||||||
user_id = call.data[4:]
|
user_id = call.data[4:]
|
||||||
logger.info(f"Вызов функции process_ban_user. Данные callback: {call.data} пользователь: {user_id}")
|
logger.info(
|
||||||
|
f"Вызов функции process_ban_user. Данные callback: {call.data} пользователь: {user_id}"
|
||||||
|
)
|
||||||
|
|
||||||
# Проверяем, что user_id является валидным числом
|
# Проверяем, что user_id является валидным числом
|
||||||
try:
|
try:
|
||||||
user_id_int = int(user_id)
|
user_id_int = int(user_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.error(f"Некорректный user_id в callback: {user_id}")
|
logger.error(f"Некорректный user_id в callback: {user_id}")
|
||||||
await call.answer(text="Ошибка: некорректный ID пользователя", show_alert=True, cache_time=3)
|
await call.answer(
|
||||||
return
|
text="Ошибка: некорректный ID пользователя", show_alert=True, cache_time=3
|
||||||
|
)
|
||||||
try:
|
return
|
||||||
user_name = await ban_service.ban_user(str(user_id_int), "")
|
|
||||||
await state.update_data(user_id=user_id_int, user_name=user_name, message_for_user=None, date_to_unban=None)
|
try:
|
||||||
markup = create_keyboard_for_ban_reason()
|
# Получаем username пользователя
|
||||||
|
username = await ban_service.ban_user(str(user_id_int), "")
|
||||||
user_name_escaped = html.escape(str(user_name))
|
if not username:
|
||||||
full_name_escaped = html.escape(str(call.message.from_user.full_name))
|
raise UserNotFoundError(f"Пользователь с ID {user_id_int} не найден в базе")
|
||||||
await call.message.answer(
|
|
||||||
text=f"<b>Выбран пользователь:\nid:</b> {user_id_int}\n<b>username:</b> {user_name_escaped}\nИмя:{full_name_escaped}\nВыбери причину бана из списка или напиши ее в чат",
|
# Получаем full_name пользователя из базы данных
|
||||||
reply_markup=markup
|
full_name = await bot_db.get_full_name_by_id(user_id_int)
|
||||||
|
if not full_name:
|
||||||
|
full_name = "Неизвестно"
|
||||||
|
|
||||||
|
# Сохраняем данные в формате, совместимом с admin_handlers
|
||||||
|
await state.update_data(
|
||||||
|
target_user_id=user_id_int,
|
||||||
|
target_username=username,
|
||||||
|
target_full_name=full_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Используем единый формат отображения информации о пользователе
|
||||||
|
user_info = format_user_info(user_id_int, username, full_name)
|
||||||
|
markup = create_keyboard_for_ban_reason()
|
||||||
|
|
||||||
|
await call.message.answer(
|
||||||
|
text=f"{user_info}\n\nВыбери причину бана из списка или напиши ее в чат",
|
||||||
|
reply_markup=markup,
|
||||||
|
)
|
||||||
|
await state.set_state("AWAIT_BAN_DETAILS")
|
||||||
|
logger.info(
|
||||||
|
f"process_ban_user: Состояние изменено на AWAIT_BAN_DETAILS для пользователя {user_id_int}"
|
||||||
)
|
)
|
||||||
await state.set_state('BAN_2')
|
|
||||||
except UserNotFoundError:
|
except UserNotFoundError:
|
||||||
markup = get_reply_keyboard_admin()
|
markup = get_reply_keyboard_admin()
|
||||||
await call.message.answer(text='Пользователь с таким ID не найден в базе', reply_markup=markup)
|
await call.message.answer(
|
||||||
await state.set_state('ADMIN')
|
text="Пользователь с таким ID не найден в базе", reply_markup=markup
|
||||||
|
)
|
||||||
|
await state.set_state("ADMIN")
|
||||||
|
|
||||||
|
|
||||||
@callback_router.callback_query(F.data.contains(CALLBACK_UNLOCK))
|
@callback_router.callback_query(F.data.contains(CALLBACK_UNLOCK))
|
||||||
@@ -164,22 +205,26 @@ async def process_unlock_user(call: CallbackQuery, **kwargs):
|
|||||||
ban_service = get_ban_service()
|
ban_service = get_ban_service()
|
||||||
# TODO: переделать на MagicData
|
# TODO: переделать на MagicData
|
||||||
user_id = call.data[7:]
|
user_id = call.data[7:]
|
||||||
|
|
||||||
# Проверяем, что user_id является валидным числом
|
# Проверяем, что user_id является валидным числом
|
||||||
try:
|
try:
|
||||||
user_id_int = int(user_id)
|
user_id_int = int(user_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.error(f"Некорректный user_id в callback: {user_id}")
|
logger.error(f"Некорректный user_id в callback: {user_id}")
|
||||||
await call.answer(text="Ошибка: некорректный ID пользователя", show_alert=True, cache_time=3)
|
await call.answer(
|
||||||
|
text="Ошибка: некорректный ID пользователя", show_alert=True, cache_time=3
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
username = await ban_service.unlock_user(str(user_id_int))
|
username = await ban_service.unlock_user(str(user_id_int))
|
||||||
await call.answer(f'{MESSAGE_USER_UNLOCKED} {username}', show_alert=True)
|
await call.answer(f"{MESSAGE_USER_UNLOCKED} {username}", show_alert=True)
|
||||||
except UserNotFoundError:
|
except UserNotFoundError:
|
||||||
await call.answer(text='Пользователь не найден в базе', show_alert=True, cache_time=3)
|
await call.answer(
|
||||||
|
text="Пользователь не найден в базе", show_alert=True, cache_time=3
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Ошибка при разблокировке пользователя: {str(e)}')
|
logger.error(f"Ошибка при разблокировке пользователя: {str(e)}")
|
||||||
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
|
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
|
||||||
|
|
||||||
|
|
||||||
@@ -190,48 +235,56 @@ async def return_to_main_menu(call: CallbackQuery, **kwargs):
|
|||||||
await call.message.delete()
|
await call.message.delete()
|
||||||
logger.info(f"Запуск админ панели для пользователя: {call.message.from_user.id}")
|
logger.info(f"Запуск админ панели для пользователя: {call.message.from_user.id}")
|
||||||
markup = get_reply_keyboard_admin()
|
markup = get_reply_keyboard_admin()
|
||||||
await call.message.answer("Добро пожаловать в админку. Выбери что хочешь:", reply_markup=markup)
|
await call.message.answer(
|
||||||
|
"Добро пожаловать в админку. Выбери что хочешь:", reply_markup=markup
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@callback_router.callback_query(F.data.contains(CALLBACK_PAGE))
|
@callback_router.callback_query(F.data.contains(CALLBACK_PAGE))
|
||||||
@track_time("change_page", "callback_handlers")
|
@track_time("change_page", "callback_handlers")
|
||||||
@track_errors("callback_handlers", "change_page")
|
@track_errors("callback_handlers", "change_page")
|
||||||
async def change_page(
|
async def change_page(call: CallbackQuery, bot_db: MagicData("bot_db"), **kwargs):
|
||||||
call: CallbackQuery,
|
|
||||||
bot_db: MagicData("bot_db"),
|
|
||||||
**kwargs
|
|
||||||
):
|
|
||||||
try:
|
try:
|
||||||
page_number = int(call.data[5:])
|
page_number = int(call.data[5:])
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.error(f"Некорректный номер страницы в callback: {call.data}")
|
logger.error(f"Некорректный номер страницы в callback: {call.data}")
|
||||||
await call.answer(text="Ошибка: некорректный номер страницы", show_alert=True, cache_time=3)
|
await call.answer(
|
||||||
|
text="Ошибка: некорректный номер страницы", show_alert=True, cache_time=3
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(f"Переход на страницу {page_number}")
|
logger.info(f"Переход на страницу {page_number}")
|
||||||
|
|
||||||
if call.message.text == 'Список пользователей которые последними обращались к боту':
|
items_per_page = 9
|
||||||
|
|
||||||
|
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(page_number, len(list_users), list_users, 'ban')
|
keyboard = create_keyboard_with_pagination(
|
||||||
|
page_number, len(list_users), list_users, "ban"
|
||||||
|
)
|
||||||
await call.bot.edit_message_reply_markup(
|
await call.bot.edit_message_reply_markup(
|
||||||
chat_id=call.message.chat.id,
|
chat_id=call.message.chat.id,
|
||||||
message_id=call.message.message_id,
|
message_id=call.message.message_id,
|
||||||
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)
|
||||||
keyboard = create_keyboard_with_pagination(page_number, len(buttons), buttons, 'unlock')
|
keyboard = create_keyboard_with_pagination(
|
||||||
|
page_number, len(buttons), buttons, "unlock"
|
||||||
|
)
|
||||||
await call.bot.edit_message_reply_markup(
|
await call.bot.edit_message_reply_markup(
|
||||||
chat_id=call.message.chat.id,
|
chat_id=call.message.chat.id,
|
||||||
message_id=call.message.message_id,
|
message_id=call.message.message_id,
|
||||||
reply_markup=keyboard
|
reply_markup=keyboard,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -241,73 +294,81 @@ async def change_page(
|
|||||||
@track_file_operations("voice")
|
@track_file_operations("voice")
|
||||||
@db_query_time("save_voice_message", "audio_moderate", "mixed")
|
@db_query_time("save_voice_message", "audio_moderate", "mixed")
|
||||||
async def save_voice_message(
|
async def save_voice_message(
|
||||||
call: CallbackQuery,
|
call: CallbackQuery,
|
||||||
bot_db: MagicData("bot_db"),
|
bot_db: MagicData("bot_db"),
|
||||||
settings: MagicData("settings"),
|
settings: MagicData("settings"),
|
||||||
**kwargs
|
**kwargs,
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
logger.info(f"Начинаем сохранение голосового сообщения. Message ID: {call.message.message_id}")
|
logger.info(
|
||||||
|
f"Начинаем сохранение голосового сообщения. Message ID: {call.message.message_id}"
|
||||||
|
)
|
||||||
|
|
||||||
# Создаем сервис для работы с аудио файлами
|
# Создаем сервис для работы с аудио файлами
|
||||||
audio_service = AudioFileService(bot_db)
|
audio_service = AudioFileService(bot_db)
|
||||||
|
|
||||||
# Получаем ID пользователя из базы
|
# Получаем ID пользователя из базы
|
||||||
user_id = await bot_db.get_user_id_by_message_id_for_voice_bot(call.message.message_id)
|
user_id = await bot_db.get_user_id_by_message_id_for_voice_bot(
|
||||||
|
call.message.message_id
|
||||||
|
)
|
||||||
logger.info(f"Получен user_id: {user_id}")
|
logger.info(f"Получен user_id: {user_id}")
|
||||||
|
|
||||||
# Генерируем имя файла
|
# Генерируем имя файла
|
||||||
file_name = await audio_service.generate_file_name(user_id)
|
file_name = await audio_service.generate_file_name(user_id)
|
||||||
logger.info(f"Сгенерировано имя файла: {file_name}")
|
logger.info(f"Сгенерировано имя файла: {file_name}")
|
||||||
|
|
||||||
# Собираем инфо о сообщении
|
# Собираем инфо о сообщении
|
||||||
time_UTC = int(time.time())
|
time_UTC = int(time.time())
|
||||||
date_added = datetime.fromtimestamp(time_UTC)
|
date_added = datetime.fromtimestamp(time_UTC)
|
||||||
|
|
||||||
# Получаем file_id из voice сообщения
|
# Получаем file_id из voice сообщения
|
||||||
file_id = call.message.voice.file_id if call.message.voice else ""
|
file_id = call.message.voice.file_id if call.message.voice else ""
|
||||||
logger.info(f"Получен file_id: {file_id}")
|
logger.info(f"Получен file_id: {file_id}")
|
||||||
|
|
||||||
# ВАЖНО: Сначала скачиваем и сохраняем файл на диск
|
# ВАЖНО: Сначала скачиваем и сохраняем файл на диск
|
||||||
logger.info("Начинаем скачивание и сохранение файла на диск...")
|
logger.info("Начинаем скачивание и сохранение файла на диск...")
|
||||||
await audio_service.download_and_save_audio(call.bot, call.message, file_name)
|
await audio_service.download_and_save_audio(call.bot, call.message, file_name)
|
||||||
logger.info("Файл успешно скачан и сохранен на диск")
|
logger.info("Файл успешно скачан и сохранен на диск")
|
||||||
|
|
||||||
# Только после успешного сохранения файла - сохраняем в базу данных
|
# Только после успешного сохранения файла - сохраняем в базу данных
|
||||||
logger.info("Начинаем сохранение информации в базу данных...")
|
logger.info("Начинаем сохранение информации в базу данных...")
|
||||||
await audio_service.save_audio_file(file_name, user_id, date_added, file_id)
|
await audio_service.save_audio_file(file_name, user_id, date_added, file_id)
|
||||||
logger.info("Информация успешно сохранена в базу данных")
|
logger.info("Информация успешно сохранена в базу данных")
|
||||||
|
|
||||||
# Удаляем сообщение из предложки
|
# Удаляем сообщение из предложки
|
||||||
logger.info("Удаляем сообщение из предложки...")
|
logger.info("Удаляем сообщение из предложки...")
|
||||||
await call.bot.delete_message(
|
await call.bot.delete_message(
|
||||||
chat_id=settings['Telegram']['group_for_posts'],
|
chat_id=settings["Telegram"]["group_for_posts"],
|
||||||
message_id=call.message.message_id
|
message_id=call.message.message_id,
|
||||||
)
|
)
|
||||||
logger.info("Сообщение удалено из предложки")
|
logger.info("Сообщение удалено из предложки")
|
||||||
|
|
||||||
# Удаляем запись из таблицы audio_moderate
|
# Удаляем запись из таблицы audio_moderate
|
||||||
logger.info("Удаляем запись из таблицы audio_moderate...")
|
logger.info("Удаляем запись из таблицы audio_moderate...")
|
||||||
await bot_db.delete_audio_moderate_record(call.message.message_id)
|
await bot_db.delete_audio_moderate_record(call.message.message_id)
|
||||||
logger.info("Запись удалена из таблицы audio_moderate")
|
logger.info("Запись удалена из таблицы audio_moderate")
|
||||||
|
|
||||||
await call.answer(text='Сохранено!', cache_time=3)
|
await call.answer(text="Сохранено!", cache_time=3)
|
||||||
logger.info(f"Голосовое сообщение успешно сохранено: {file_name}")
|
logger.info(f"Голосовое сообщение успешно сохранено: {file_name}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при сохранении голосового сообщения: {e}")
|
logger.error(f"Ошибка при сохранении голосового сообщения: {e}")
|
||||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||||
|
|
||||||
# Дополнительная информация для диагностики
|
# Дополнительная информация для диагностики
|
||||||
try:
|
try:
|
||||||
if 'call' in locals() and call.message:
|
if "call" in locals() and call.message:
|
||||||
logger.error(f"Message ID: {call.message.message_id}")
|
logger.error(f"Message ID: {call.message.message_id}")
|
||||||
logger.error(f"User ID: {user_id if 'user_id' in locals() else 'не определен'}")
|
logger.error(
|
||||||
logger.error(f"File name: {file_name if 'file_name' in locals() else 'не определен'}")
|
f"User ID: {user_id if 'user_id' in locals() else 'не определен'}"
|
||||||
|
)
|
||||||
|
logger.error(
|
||||||
|
f"File name: {file_name if 'file_name' in locals() else 'не определен'}"
|
||||||
|
)
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
await call.answer(text='Ошибка при сохранении!', cache_time=3)
|
await call.answer(text="Ошибка при сохранении!", cache_time=3)
|
||||||
|
|
||||||
|
|
||||||
@callback_router.callback_query(F.data == CALLBACK_DELETE)
|
@callback_router.callback_query(F.data == CALLBACK_DELETE)
|
||||||
@@ -315,23 +376,23 @@ async def save_voice_message(
|
|||||||
@track_errors("callback_handlers", "delete_voice_message")
|
@track_errors("callback_handlers", "delete_voice_message")
|
||||||
@db_query_time("delete_voice_message", "audio_moderate", "delete")
|
@db_query_time("delete_voice_message", "audio_moderate", "delete")
|
||||||
async def delete_voice_message(
|
async def delete_voice_message(
|
||||||
call: CallbackQuery,
|
call: CallbackQuery,
|
||||||
bot_db: MagicData("bot_db"),
|
bot_db: MagicData("bot_db"),
|
||||||
settings: MagicData("settings"),
|
settings: MagicData("settings"),
|
||||||
**kwargs
|
**kwargs,
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
# Удаляем сообщение из предложки
|
# Удаляем сообщение из предложки
|
||||||
await call.bot.delete_message(
|
await call.bot.delete_message(
|
||||||
chat_id=settings['Telegram']['group_for_posts'],
|
chat_id=settings["Telegram"]["group_for_posts"],
|
||||||
message_id=call.message.message_id
|
message_id=call.message.message_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Удаляем запись из таблицы audio_moderate
|
# Удаляем запись из таблицы audio_moderate
|
||||||
await bot_db.delete_audio_moderate_record(call.message.message_id)
|
await bot_db.delete_audio_moderate_record(call.message.message_id)
|
||||||
|
|
||||||
await call.answer(text='Удалено!', cache_time=3)
|
await call.answer(text="Удалено!", cache_time=3)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при удалении голосового сообщения: {e}")
|
logger.error(f"Ошибка при удалении голосового сообщения: {e}")
|
||||||
await call.answer(text='Ошибка при удалении!', cache_time=3)
|
await call.answer(text="Ошибка при удалении!", cache_time=3)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from typing import Final, Dict
|
from typing import Dict, Final
|
||||||
|
|
||||||
# Callback data constants
|
# Callback data constants
|
||||||
CALLBACK_PUBLISH = "publish"
|
CALLBACK_PUBLISH = "publish"
|
||||||
@@ -33,9 +33,9 @@ ERROR_BOT_BLOCKED = "Forbidden: bot was blocked by the user"
|
|||||||
# Callback to command mapping for metrics
|
# Callback to command mapping for metrics
|
||||||
CALLBACK_COMMAND_MAPPING: Final[Dict[str, str]] = {
|
CALLBACK_COMMAND_MAPPING: Final[Dict[str, str]] = {
|
||||||
"publish": "publish",
|
"publish": "publish",
|
||||||
"decline": "decline",
|
"decline": "decline",
|
||||||
"ban": "ban",
|
"ban": "ban",
|
||||||
"unlock": "unlock",
|
"unlock": "unlock",
|
||||||
"return": "return",
|
"return": "return",
|
||||||
"page": "page"
|
"page": "page",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
from aiogram import Bot
|
from aiogram import Bot
|
||||||
from aiogram.client.default import DefaultBotProperties
|
from aiogram.client.default import DefaultBotProperties
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
|
|
||||||
from helper_bot.utils.base_dependency_factory import get_global_instance
|
from helper_bot.utils.base_dependency_factory import get_global_instance
|
||||||
from .services import PostPublishService, BanService
|
|
||||||
|
from .services import BanService, PostPublishService
|
||||||
|
|
||||||
|
|
||||||
def get_post_publish_service() -> PostPublishService:
|
def get_post_publish_service() -> PostPublishService:
|
||||||
@@ -13,13 +15,15 @@ def get_post_publish_service() -> PostPublishService:
|
|||||||
|
|
||||||
db = bdf.get_db()
|
db = bdf.get_db()
|
||||||
settings = bdf.settings
|
settings = bdf.settings
|
||||||
return PostPublishService(None, db, settings)
|
s3_storage = bdf.get_s3_storage()
|
||||||
|
scoring_manager = bdf.get_scoring_manager()
|
||||||
|
return PostPublishService(None, db, settings, s3_storage, scoring_manager)
|
||||||
|
|
||||||
|
|
||||||
def get_ban_service() -> BanService:
|
def get_ban_service() -> BanService:
|
||||||
"""Фабрика для BanService"""
|
"""Фабрика для BanService"""
|
||||||
bdf = get_global_instance()
|
bdf = get_global_instance()
|
||||||
|
|
||||||
db = bdf.get_db()
|
db = bdf.get_db()
|
||||||
settings = bdf.settings
|
settings = bdf.settings
|
||||||
return BanService(None, db, settings)
|
return BanService(None, db, settings)
|
||||||
|
|||||||
@@ -1,23 +1,28 @@
|
|||||||
class UserBlockedBotError(Exception):
|
class UserBlockedBotError(Exception):
|
||||||
"""Исключение, возникающее когда пользователь заблокировал бота"""
|
"""Исключение, возникающее когда пользователь заблокировал бота"""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class PostNotFoundError(Exception):
|
class PostNotFoundError(Exception):
|
||||||
"""Исключение, возникающее когда пост не найден в базе данных"""
|
"""Исключение, возникающее когда пост не найден в базе данных"""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class UserNotFoundError(Exception):
|
class UserNotFoundError(Exception):
|
||||||
"""Исключение, возникающее когда пользователь не найден в базе данных"""
|
"""Исключение, возникающее когда пользователь не найден в базе данных"""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class PublishError(Exception):
|
class PublishError(Exception):
|
||||||
"""Общее исключение для ошибок публикации"""
|
"""Общее исключение для ошибок публикации"""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class BanError(Exception):
|
class BanError(Exception):
|
||||||
"""Исключение для ошибок бана/разбана пользователей"""
|
"""Исключение для ошибок бана/разбана пользователей"""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -1,47 +1,73 @@
|
|||||||
from datetime import datetime, timedelta
|
|
||||||
import html
|
import html
|
||||||
from typing import Dict, Any
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
from aiogram import Bot
|
from aiogram import Bot, types
|
||||||
from aiogram.types import CallbackQuery
|
from aiogram.types import CallbackQuery
|
||||||
|
|
||||||
from helper_bot.utils.helper_func import (
|
|
||||||
send_text_message, send_photo_message, send_video_message,
|
|
||||||
send_video_note_message, send_audio_message, send_voice_message,
|
|
||||||
send_media_group_to_channel, delete_user_blacklist
|
|
||||||
)
|
|
||||||
from helper_bot.keyboards.keyboards import create_keyboard_for_ban_reason
|
from helper_bot.keyboards.keyboards import create_keyboard_for_ban_reason
|
||||||
from .exceptions import (
|
from helper_bot.utils.helper_func import (
|
||||||
UserBlockedBotError, PostNotFoundError, UserNotFoundError,
|
delete_user_blacklist,
|
||||||
PublishError, BanError
|
get_publish_text,
|
||||||
|
send_audio_message,
|
||||||
|
send_media_group_to_channel,
|
||||||
|
send_photo_message,
|
||||||
|
send_text_message,
|
||||||
|
send_video_message,
|
||||||
|
send_video_note_message,
|
||||||
|
send_voice_message,
|
||||||
)
|
)
|
||||||
from .constants import (
|
|
||||||
CONTENT_TYPE_TEXT, CONTENT_TYPE_PHOTO, CONTENT_TYPE_VIDEO,
|
|
||||||
CONTENT_TYPE_VIDEO_NOTE, CONTENT_TYPE_AUDIO, CONTENT_TYPE_VOICE,
|
|
||||||
CONTENT_TYPE_MEDIA_GROUP, MESSAGE_POST_PUBLISHED, MESSAGE_POST_DECLINED,
|
|
||||||
MESSAGE_USER_BANNED_SPAM, ERROR_BOT_BLOCKED
|
|
||||||
)
|
|
||||||
from logs.custom_logger import logger
|
|
||||||
|
|
||||||
# Local imports - metrics
|
# Local imports - metrics
|
||||||
from helper_bot.utils.metrics import (
|
from helper_bot.utils.metrics import (
|
||||||
track_media_processing,
|
db_query_time,
|
||||||
track_time,
|
|
||||||
track_errors,
|
track_errors,
|
||||||
db_query_time
|
track_media_processing,
|
||||||
|
track_time,
|
||||||
|
)
|
||||||
|
from logs.custom_logger import logger
|
||||||
|
|
||||||
|
from .constants import (
|
||||||
|
CONTENT_TYPE_AUDIO,
|
||||||
|
CONTENT_TYPE_MEDIA_GROUP,
|
||||||
|
CONTENT_TYPE_PHOTO,
|
||||||
|
CONTENT_TYPE_TEXT,
|
||||||
|
CONTENT_TYPE_VIDEO,
|
||||||
|
CONTENT_TYPE_VIDEO_NOTE,
|
||||||
|
CONTENT_TYPE_VOICE,
|
||||||
|
ERROR_BOT_BLOCKED,
|
||||||
|
MESSAGE_POST_DECLINED,
|
||||||
|
MESSAGE_POST_PUBLISHED,
|
||||||
|
MESSAGE_USER_BANNED_SPAM,
|
||||||
|
)
|
||||||
|
from .exceptions import (
|
||||||
|
BanError,
|
||||||
|
PostNotFoundError,
|
||||||
|
PublishError,
|
||||||
|
UserBlockedBotError,
|
||||||
|
UserNotFoundError,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class PostPublishService:
|
class PostPublishService:
|
||||||
def __init__(self, bot: Bot, db, settings: Dict[str, Any]):
|
def __init__(
|
||||||
|
self,
|
||||||
|
bot: Bot,
|
||||||
|
db,
|
||||||
|
settings: Dict[str, Any],
|
||||||
|
s3_storage=None,
|
||||||
|
scoring_manager=None,
|
||||||
|
):
|
||||||
# bot может быть None - в этом случае используем бота из контекста сообщения
|
# bot может быть None - в этом случае используем бота из контекста сообщения
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
self.db = db
|
self.db = db
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
self.group_for_posts = settings['Telegram']['group_for_posts']
|
self.s3_storage = s3_storage
|
||||||
self.main_public = settings['Telegram']['main_public']
|
self.scoring_manager = scoring_manager
|
||||||
self.important_logs = settings['Telegram']['important_logs']
|
self.group_for_posts = settings["Telegram"]["group_for_posts"]
|
||||||
|
self.main_public = settings["Telegram"]["main_public"]
|
||||||
|
self.important_logs = settings["Telegram"]["important_logs"]
|
||||||
|
|
||||||
def _get_bot(self, message) -> Bot:
|
def _get_bot(self, message) -> Bot:
|
||||||
"""Получает бота из контекста сообщения или использует переданного"""
|
"""Получает бота из контекста сообщения или использует переданного"""
|
||||||
if self.bot:
|
if self.bot:
|
||||||
@@ -52,13 +78,18 @@ class PostPublishService:
|
|||||||
@track_errors("post_publish_service", "publish_post")
|
@track_errors("post_publish_service", "publish_post")
|
||||||
async def publish_post(self, call: CallbackQuery) -> None:
|
async def publish_post(self, call: CallbackQuery) -> None:
|
||||||
"""Основной метод публикации поста"""
|
"""Основной метод публикации поста"""
|
||||||
# Проверяем, является ли сообщение частью медиагруппы
|
# Проверяем, является ли сообщение helper-сообщением медиагруппы
|
||||||
|
if call.message.text == CONTENT_TYPE_MEDIA_GROUP:
|
||||||
|
await self._publish_media_group(call)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Проверяем, является ли сообщение частью медиагруппы (для обратной совместимости)
|
||||||
if call.message.media_group_id:
|
if call.message.media_group_id:
|
||||||
await self._publish_media_group(call)
|
await self._publish_media_group(call)
|
||||||
return
|
return
|
||||||
|
|
||||||
content_type = call.message.content_type
|
content_type = call.message.content_type
|
||||||
|
|
||||||
if content_type == CONTENT_TYPE_TEXT:
|
if content_type == CONTENT_TYPE_TEXT:
|
||||||
await self._publish_text_post(call)
|
await self._publish_text_post(call)
|
||||||
elif content_type == CONTENT_TYPE_PHOTO:
|
elif content_type == CONTENT_TYPE_PHOTO:
|
||||||
@@ -78,148 +109,484 @@ class PostPublishService:
|
|||||||
@track_errors("post_publish_service", "_publish_text_post")
|
@track_errors("post_publish_service", "_publish_text_post")
|
||||||
async def _publish_text_post(self, call: CallbackQuery) -> None:
|
async def _publish_text_post(self, call: CallbackQuery) -> None:
|
||||||
"""Публикация текстового поста"""
|
"""Публикация текстового поста"""
|
||||||
text_post = html.escape(str(call.message.text))
|
|
||||||
author_id = await self._get_author_id(call.message.message_id)
|
author_id = await self._get_author_id(call.message.message_id)
|
||||||
|
|
||||||
await send_text_message(self.main_public, call.message, text_post)
|
updated_rows = await self.db.update_status_by_message_id(
|
||||||
|
call.message.message_id, "approved"
|
||||||
|
)
|
||||||
|
if updated_rows == 0:
|
||||||
|
logger.error(
|
||||||
|
f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'"
|
||||||
|
)
|
||||||
|
raise PostNotFoundError(
|
||||||
|
f"Пост с message_id={call.message.message_id} не найден в базе данных"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Получаем сырой текст и is_anonymous из базы
|
||||||
|
raw_text, is_anonymous = (
|
||||||
|
await self.db.get_post_text_and_anonymity_by_message_id(
|
||||||
|
call.message.message_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if raw_text is None:
|
||||||
|
raw_text = ""
|
||||||
|
|
||||||
|
# Получаем данные автора
|
||||||
|
user = await self.db.get_user_by_id(author_id)
|
||||||
|
if not user:
|
||||||
|
raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных")
|
||||||
|
|
||||||
|
# Формируем финальный текст с учетом is_anonymous
|
||||||
|
formatted_text = get_publish_text(
|
||||||
|
raw_text, user.first_name, user.username, is_anonymous
|
||||||
|
)
|
||||||
|
|
||||||
|
sent_message = await send_text_message(
|
||||||
|
self.main_public, call.message, formatted_text
|
||||||
|
)
|
||||||
|
|
||||||
|
# Сохраняем published_message_id
|
||||||
|
await self.db.update_published_message_id(
|
||||||
|
original_message_id=call.message.message_id,
|
||||||
|
published_message_id=sent_message.message_id,
|
||||||
|
)
|
||||||
|
|
||||||
await self._delete_post_and_notify_author(call, author_id)
|
await self._delete_post_and_notify_author(call, author_id)
|
||||||
logger.info(f'Текст сообщения опубликован в канале {self.main_public}.')
|
logger.info(
|
||||||
|
f"Текст сообщение опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}."
|
||||||
|
)
|
||||||
|
|
||||||
@track_time("_publish_photo_post", "post_publish_service")
|
@track_time("_publish_photo_post", "post_publish_service")
|
||||||
@track_errors("post_publish_service", "_publish_photo_post")
|
@track_errors("post_publish_service", "_publish_photo_post")
|
||||||
async def _publish_photo_post(self, call: CallbackQuery) -> None:
|
async def _publish_photo_post(self, call: CallbackQuery) -> None:
|
||||||
"""Публикация поста с фото"""
|
"""Публикация поста с фото"""
|
||||||
text_post_with_photo = html.escape(str(call.message.caption))
|
|
||||||
author_id = await self._get_author_id(call.message.message_id)
|
author_id = await self._get_author_id(call.message.message_id)
|
||||||
|
|
||||||
await send_photo_message(self.main_public, call.message, call.message.photo[-1].file_id, text_post_with_photo)
|
updated_rows = await self.db.update_status_by_message_id(
|
||||||
|
call.message.message_id, "approved"
|
||||||
|
)
|
||||||
|
if updated_rows == 0:
|
||||||
|
logger.error(
|
||||||
|
f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'"
|
||||||
|
)
|
||||||
|
raise PostNotFoundError(
|
||||||
|
f"Пост с message_id={call.message.message_id} не найден в базе данных"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Получаем сырой текст и is_anonymous из базы
|
||||||
|
raw_text, is_anonymous = (
|
||||||
|
await self.db.get_post_text_and_anonymity_by_message_id(
|
||||||
|
call.message.message_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if raw_text is None:
|
||||||
|
raw_text = ""
|
||||||
|
|
||||||
|
# Получаем данные автора
|
||||||
|
user = await self.db.get_user_by_id(author_id)
|
||||||
|
if not user:
|
||||||
|
raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных")
|
||||||
|
|
||||||
|
# Формируем финальный текст с учетом is_anonymous
|
||||||
|
formatted_text = get_publish_text(
|
||||||
|
raw_text, user.first_name, user.username, is_anonymous
|
||||||
|
)
|
||||||
|
|
||||||
|
sent_message = await send_photo_message(
|
||||||
|
self.main_public,
|
||||||
|
call.message,
|
||||||
|
call.message.photo[-1].file_id,
|
||||||
|
formatted_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Сохраняем published_message_id
|
||||||
|
await self.db.update_published_message_id(
|
||||||
|
original_message_id=call.message.message_id,
|
||||||
|
published_message_id=sent_message.message_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Сохраняем медиафайл из опубликованного поста (используем уже сохраненный файл)
|
||||||
|
await self._save_published_post_content(
|
||||||
|
sent_message, sent_message.message_id, call.message.message_id
|
||||||
|
)
|
||||||
|
|
||||||
await self._delete_post_and_notify_author(call, author_id)
|
await self._delete_post_and_notify_author(call, author_id)
|
||||||
logger.info(f'Пост с фото опубликован в канале {self.main_public}.')
|
logger.info(
|
||||||
|
f"Пост с фото опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}."
|
||||||
|
)
|
||||||
|
|
||||||
@track_time("_publish_video_post", "post_publish_service")
|
@track_time("_publish_video_post", "post_publish_service")
|
||||||
@track_errors("post_publish_service", "_publish_video_post")
|
@track_errors("post_publish_service", "_publish_video_post")
|
||||||
async def _publish_video_post(self, call: CallbackQuery) -> None:
|
async def _publish_video_post(self, call: CallbackQuery) -> None:
|
||||||
"""Публикация поста с видео"""
|
"""Публикация поста с видео"""
|
||||||
text_post_with_photo = html.escape(str(call.message.caption))
|
|
||||||
author_id = await self._get_author_id(call.message.message_id)
|
author_id = await self._get_author_id(call.message.message_id)
|
||||||
|
|
||||||
await send_video_message(self.main_public, call.message, call.message.video.file_id, text_post_with_photo)
|
updated_rows = await self.db.update_status_by_message_id(
|
||||||
|
call.message.message_id, "approved"
|
||||||
|
)
|
||||||
|
if updated_rows == 0:
|
||||||
|
logger.error(
|
||||||
|
f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'"
|
||||||
|
)
|
||||||
|
raise PostNotFoundError(
|
||||||
|
f"Пост с message_id={call.message.message_id} не найден в базе данных"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Получаем сырой текст и is_anonymous из базы
|
||||||
|
raw_text, is_anonymous = (
|
||||||
|
await self.db.get_post_text_and_anonymity_by_message_id(
|
||||||
|
call.message.message_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if raw_text is None:
|
||||||
|
raw_text = ""
|
||||||
|
|
||||||
|
# Получаем данные автора
|
||||||
|
user = await self.db.get_user_by_id(author_id)
|
||||||
|
if not user:
|
||||||
|
raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных")
|
||||||
|
|
||||||
|
# Формируем финальный текст с учетом is_anonymous
|
||||||
|
formatted_text = get_publish_text(
|
||||||
|
raw_text, user.first_name, user.username, is_anonymous
|
||||||
|
)
|
||||||
|
|
||||||
|
sent_message = await send_video_message(
|
||||||
|
self.main_public, call.message, call.message.video.file_id, formatted_text
|
||||||
|
)
|
||||||
|
|
||||||
|
# Сохраняем published_message_id
|
||||||
|
await self.db.update_published_message_id(
|
||||||
|
original_message_id=call.message.message_id,
|
||||||
|
published_message_id=sent_message.message_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Сохраняем медиафайл из опубликованного поста (используем уже сохраненный файл)
|
||||||
|
await self._save_published_post_content(
|
||||||
|
sent_message, sent_message.message_id, call.message.message_id
|
||||||
|
)
|
||||||
|
|
||||||
await self._delete_post_and_notify_author(call, author_id)
|
await self._delete_post_and_notify_author(call, author_id)
|
||||||
logger.info(f'Пост с видео опубликован в канале {self.main_public}.')
|
logger.info(
|
||||||
|
f"Пост с видео опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}."
|
||||||
|
)
|
||||||
|
|
||||||
@track_time("_publish_video_note_post", "post_publish_service")
|
@track_time("_publish_video_note_post", "post_publish_service")
|
||||||
@track_errors("post_publish_service", "_publish_video_note_post")
|
@track_errors("post_publish_service", "_publish_video_note_post")
|
||||||
async def _publish_video_note_post(self, call: CallbackQuery) -> None:
|
async def _publish_video_note_post(self, call: CallbackQuery) -> None:
|
||||||
"""Публикация поста с кружком"""
|
"""Публикация поста с кружком"""
|
||||||
author_id = await self._get_author_id(call.message.message_id)
|
author_id = await self._get_author_id(call.message.message_id)
|
||||||
|
|
||||||
await send_video_note_message(self.main_public, call.message, call.message.video_note.file_id)
|
updated_rows = await self.db.update_status_by_message_id(
|
||||||
|
call.message.message_id, "approved"
|
||||||
|
)
|
||||||
|
if updated_rows == 0:
|
||||||
|
logger.error(
|
||||||
|
f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'"
|
||||||
|
)
|
||||||
|
raise PostNotFoundError(
|
||||||
|
f"Пост с message_id={call.message.message_id} не найден в базе данных"
|
||||||
|
)
|
||||||
|
|
||||||
|
sent_message = await send_video_note_message(
|
||||||
|
self.main_public, call.message, call.message.video_note.file_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Сохраняем published_message_id
|
||||||
|
await self.db.update_published_message_id(
|
||||||
|
original_message_id=call.message.message_id,
|
||||||
|
published_message_id=sent_message.message_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Сохраняем медиафайл из опубликованного поста (используем уже сохраненный файл)
|
||||||
|
await self._save_published_post_content(
|
||||||
|
sent_message, sent_message.message_id, call.message.message_id
|
||||||
|
)
|
||||||
|
|
||||||
await self._delete_post_and_notify_author(call, author_id)
|
await self._delete_post_and_notify_author(call, author_id)
|
||||||
logger.info(f'Пост с кружком опубликован в канале {self.main_public}.')
|
logger.info(
|
||||||
|
f"Пост с кружком опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}."
|
||||||
|
)
|
||||||
|
|
||||||
@track_time("_publish_audio_post", "post_publish_service")
|
@track_time("_publish_audio_post", "post_publish_service")
|
||||||
@track_errors("post_publish_service", "_publish_audio_post")
|
@track_errors("post_publish_service", "_publish_audio_post")
|
||||||
async def _publish_audio_post(self, call: CallbackQuery) -> None:
|
async def _publish_audio_post(self, call: CallbackQuery) -> None:
|
||||||
"""Публикация поста с аудио"""
|
"""Публикация поста с аудио"""
|
||||||
text_post_with_photo = html.escape(str(call.message.caption))
|
|
||||||
author_id = await self._get_author_id(call.message.message_id)
|
author_id = await self._get_author_id(call.message.message_id)
|
||||||
|
|
||||||
await send_audio_message(self.main_public, call.message, call.message.audio.file_id, text_post_with_photo)
|
updated_rows = await self.db.update_status_by_message_id(
|
||||||
|
call.message.message_id, "approved"
|
||||||
|
)
|
||||||
|
if updated_rows == 0:
|
||||||
|
logger.error(
|
||||||
|
f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'"
|
||||||
|
)
|
||||||
|
raise PostNotFoundError(
|
||||||
|
f"Пост с message_id={call.message.message_id} не найден в базе данных"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Получаем сырой текст и is_anonymous из базы
|
||||||
|
raw_text, is_anonymous = (
|
||||||
|
await self.db.get_post_text_and_anonymity_by_message_id(
|
||||||
|
call.message.message_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if raw_text is None:
|
||||||
|
raw_text = ""
|
||||||
|
|
||||||
|
# Получаем данные автора
|
||||||
|
user = await self.db.get_user_by_id(author_id)
|
||||||
|
if not user:
|
||||||
|
raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных")
|
||||||
|
|
||||||
|
# Формируем финальный текст с учетом is_anonymous
|
||||||
|
formatted_text = get_publish_text(
|
||||||
|
raw_text, user.first_name, user.username, is_anonymous
|
||||||
|
)
|
||||||
|
|
||||||
|
sent_message = await send_audio_message(
|
||||||
|
self.main_public, call.message, call.message.audio.file_id, formatted_text
|
||||||
|
)
|
||||||
|
|
||||||
|
# Сохраняем published_message_id
|
||||||
|
await self.db.update_published_message_id(
|
||||||
|
original_message_id=call.message.message_id,
|
||||||
|
published_message_id=sent_message.message_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Сохраняем медиафайл из опубликованного поста (используем уже сохраненный файл)
|
||||||
|
await self._save_published_post_content(
|
||||||
|
sent_message, sent_message.message_id, call.message.message_id
|
||||||
|
)
|
||||||
|
|
||||||
await self._delete_post_and_notify_author(call, author_id)
|
await self._delete_post_and_notify_author(call, author_id)
|
||||||
logger.info(f'Пост с аудио опубликован в канале {self.main_public}.')
|
logger.info(
|
||||||
|
f"Пост с аудио опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}."
|
||||||
|
)
|
||||||
|
|
||||||
@track_time("_publish_voice_post", "post_publish_service")
|
@track_time("_publish_voice_post", "post_publish_service")
|
||||||
@track_errors("post_publish_service", "_publish_voice_post")
|
@track_errors("post_publish_service", "_publish_voice_post")
|
||||||
async def _publish_voice_post(self, call: CallbackQuery) -> None:
|
async def _publish_voice_post(self, call: CallbackQuery) -> None:
|
||||||
"""Публикация поста с войсом"""
|
"""Публикация поста с войсом"""
|
||||||
author_id = await self._get_author_id(call.message.message_id)
|
author_id = await self._get_author_id(call.message.message_id)
|
||||||
|
|
||||||
await send_voice_message(self.main_public, call.message, call.message.voice.file_id)
|
updated_rows = await self.db.update_status_by_message_id(
|
||||||
|
call.message.message_id, "approved"
|
||||||
|
)
|
||||||
|
if updated_rows == 0:
|
||||||
|
logger.error(
|
||||||
|
f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'"
|
||||||
|
)
|
||||||
|
raise PostNotFoundError(
|
||||||
|
f"Пост с message_id={call.message.message_id} не найден в базе данных"
|
||||||
|
)
|
||||||
|
|
||||||
|
sent_message = await send_voice_message(
|
||||||
|
self.main_public, call.message, call.message.voice.file_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Сохраняем published_message_id
|
||||||
|
await self.db.update_published_message_id(
|
||||||
|
original_message_id=call.message.message_id,
|
||||||
|
published_message_id=sent_message.message_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Сохраняем медиафайл из опубликованного поста (используем уже сохраненный файл)
|
||||||
|
await self._save_published_post_content(
|
||||||
|
sent_message, sent_message.message_id, call.message.message_id
|
||||||
|
)
|
||||||
|
|
||||||
await self._delete_post_and_notify_author(call, author_id)
|
await self._delete_post_and_notify_author(call, author_id)
|
||||||
logger.info(f'Пост с войсом опубликован в канале {self.main_public}.')
|
logger.info(
|
||||||
|
f"Пост с войсом опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}."
|
||||||
|
)
|
||||||
|
|
||||||
@track_time("_publish_media_group", "post_publish_service")
|
@track_time("_publish_media_group", "post_publish_service")
|
||||||
@track_errors("post_publish_service", "_publish_media_group")
|
@track_errors("post_publish_service", "_publish_media_group")
|
||||||
@track_media_processing("media_group")
|
@track_media_processing("media_group")
|
||||||
async def _publish_media_group(self, call: CallbackQuery) -> None:
|
async def _publish_media_group(self, call: CallbackQuery) -> None:
|
||||||
"""Публикация медиагруппы"""
|
"""Публикация медиагруппы"""
|
||||||
logger.info(f"Начинаю публикацию медиагруппы. Helper message ID: {call.message.message_id}")
|
|
||||||
try:
|
try:
|
||||||
# call.message.message_id - это ID helper сообщения
|
|
||||||
helper_message_id = call.message.message_id
|
helper_message_id = call.message.message_id
|
||||||
|
|
||||||
# Получаем контент медиагруппы по helper_message_id
|
media_group_message_ids = await self.db.get_post_ids_by_helper_id(
|
||||||
logger.debug(f"Получаю контент медиагруппы для helper_message_id: {helper_message_id}")
|
helper_message_id
|
||||||
post_content = await self.db.get_post_content_by_helper_id(helper_message_id)
|
|
||||||
if not post_content:
|
|
||||||
logger.error(f"Контент медиагруппы не найден в базе данных для helper_message_id: {helper_message_id}")
|
|
||||||
raise PublishError("Контент медиагруппы не найден в базе данных")
|
|
||||||
|
|
||||||
# Получаем текст поста по helper_message_id
|
|
||||||
logger.debug(f"Получаю текст поста для helper_message_id: {helper_message_id}")
|
|
||||||
pre_text = await self.db.get_post_text_by_helper_id(helper_message_id)
|
|
||||||
post_text = html.escape(str(pre_text)) if pre_text else ""
|
|
||||||
logger.debug(f"Текст поста получен: {'пустой' if not post_text else f'длина: {len(post_text)} символов'}")
|
|
||||||
|
|
||||||
# Получаем ID автора по helper_message_id
|
|
||||||
logger.debug(f"Получаю ID автора для helper_message_id: {helper_message_id}")
|
|
||||||
author_id = await self.db.get_author_id_by_helper_message_id(helper_message_id)
|
|
||||||
if not author_id:
|
|
||||||
logger.error(f"Автор не найден для медиагруппы {helper_message_id}")
|
|
||||||
raise PostNotFoundError(f"Автор не найден для медиагруппы {helper_message_id}")
|
|
||||||
logger.debug(f"ID автора получен: {author_id}")
|
|
||||||
|
|
||||||
# Отправляем медиагруппу в канал
|
|
||||||
logger.info(f"Отправляю медиагруппу в канал {self.main_public}")
|
|
||||||
await send_media_group_to_channel(
|
|
||||||
bot=self._get_bot(call.message),
|
|
||||||
chat_id=self.main_public,
|
|
||||||
post_content=post_content,
|
|
||||||
post_text=post_text
|
|
||||||
)
|
)
|
||||||
|
if not media_group_message_ids:
|
||||||
logger.debug(f"Удаляю медиагруппу и уведомляю автора {author_id}")
|
logger.error(
|
||||||
await self._delete_media_group_and_notify_author(call, author_id)
|
f"_publish_media_group: Не найдены message_id медиагруппы для helper_message_id={helper_message_id}"
|
||||||
logger.info(f'Медиагруппа опубликована в канале {self.main_public}.')
|
)
|
||||||
|
raise PublishError("Не найдены message_id медиагруппы в базе данных")
|
||||||
|
|
||||||
|
post_content = await self.db.get_post_content_by_helper_id(
|
||||||
|
helper_message_id
|
||||||
|
)
|
||||||
|
if not post_content:
|
||||||
|
logger.error(
|
||||||
|
f"_publish_media_group: Контент медиагруппы не найден в базе данных для helper_message_id={helper_message_id}"
|
||||||
|
)
|
||||||
|
raise PublishError("Контент медиагруппы не найден в базе данных")
|
||||||
|
|
||||||
|
raw_text, is_anonymous = (
|
||||||
|
await self.db.get_post_text_and_anonymity_by_helper_id(
|
||||||
|
helper_message_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if raw_text is None:
|
||||||
|
raw_text = ""
|
||||||
|
|
||||||
|
author_id = await self.db.get_author_id_by_helper_message_id(
|
||||||
|
helper_message_id
|
||||||
|
)
|
||||||
|
if not author_id:
|
||||||
|
logger.error(
|
||||||
|
f"_publish_media_group: Автор не найден для медиагруппы helper_message_id={helper_message_id}"
|
||||||
|
)
|
||||||
|
raise PostNotFoundError(
|
||||||
|
f"Автор не найден для медиагруппы {helper_message_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
user = await self.db.get_user_by_id(author_id)
|
||||||
|
if not user:
|
||||||
|
raise PostNotFoundError(
|
||||||
|
f"Пользователь {author_id} не найден в базе данных"
|
||||||
|
)
|
||||||
|
|
||||||
|
formatted_text = get_publish_text(
|
||||||
|
raw_text, user.first_name, user.username, is_anonymous
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._get_bot(call.message).delete_messages(
|
||||||
|
chat_id=self.group_for_posts, message_ids=media_group_message_ids
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"_publish_media_group: Ошибка при удалении медиагруппы из чата модерации: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
sent_messages = await send_media_group_to_channel(
|
||||||
|
bot=self._get_bot(call.message),
|
||||||
|
chat_id=self.main_public,
|
||||||
|
post_content=post_content,
|
||||||
|
post_text=formatted_text,
|
||||||
|
s3_storage=self.s3_storage,
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(sent_messages) == len(media_group_message_ids):
|
||||||
|
for i, original_message_id in enumerate(media_group_message_ids):
|
||||||
|
published_message_id = sent_messages[i].message_id
|
||||||
|
try:
|
||||||
|
await self.db.update_published_message_id(
|
||||||
|
original_message_id=original_message_id,
|
||||||
|
published_message_id=published_message_id,
|
||||||
|
)
|
||||||
|
await self._save_published_post_content(
|
||||||
|
sent_messages[i], published_message_id, original_message_id
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"_publish_media_group: Ошибка при сохранении published_message_id для {original_message_id}: {e}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"_publish_media_group: Количество опубликованных сообщений ({len(sent_messages)}) не совпадает с количеством оригинальных ({len(media_group_message_ids)})"
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.db.update_status_for_media_group_by_helper_id(
|
||||||
|
helper_message_id, "approved"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Удаляем helper сообщение - это критично, делаем это всегда
|
||||||
|
try:
|
||||||
|
await self._get_bot(call.message).delete_message(
|
||||||
|
chat_id=self.group_for_posts, message_id=helper_message_id
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"_publish_media_group: Ошибка при удалении helper сообщения: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await send_text_message(author_id, call.message, MESSAGE_POST_PUBLISHED)
|
||||||
|
except Exception as e:
|
||||||
|
if str(e) == ERROR_BOT_BLOCKED:
|
||||||
|
logger.warning(
|
||||||
|
f"_publish_media_group: Пользователь {author_id} заблокировал бота"
|
||||||
|
)
|
||||||
|
raise UserBlockedBotError("Пользователь заблокировал бота")
|
||||||
|
logger.error(
|
||||||
|
f"_publish_media_group: Ошибка при отправке уведомления автору: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при публикации медиагруппы: {e}")
|
logger.error(
|
||||||
|
f"_publish_media_group: Ошибка при публикации медиагруппы: {e}"
|
||||||
|
)
|
||||||
|
# Пытаемся удалить helper сообщение даже при ошибке
|
||||||
|
try:
|
||||||
|
await self._get_bot(call.message).delete_message(
|
||||||
|
chat_id=self.group_for_posts, message_id=call.message.message_id
|
||||||
|
)
|
||||||
|
except Exception as delete_error:
|
||||||
|
logger.warning(
|
||||||
|
f"_publish_media_group: Не удалось удалить helper сообщение при ошибке: {delete_error}"
|
||||||
|
)
|
||||||
raise PublishError(f"Не удалось опубликовать медиагруппу: {str(e)}")
|
raise PublishError(f"Не удалось опубликовать медиагруппу: {str(e)}")
|
||||||
|
|
||||||
@track_time("decline_post", "post_publish_service")
|
@track_time("decline_post", "post_publish_service")
|
||||||
@track_errors("post_publish_service", "decline_post")
|
@track_errors("post_publish_service", "decline_post")
|
||||||
async def decline_post(self, call: CallbackQuery) -> None:
|
async def decline_post(self, call: CallbackQuery) -> None:
|
||||||
"""Отклонение поста"""
|
"""Отклонение поста"""
|
||||||
logger.info(f"Начинаю отклонение поста. Message ID: {call.message.message_id}, Content type: {call.message.content_type}")
|
|
||||||
# Проверяем, является ли сообщение частью медиагруппы (осознанный костыль, т.к. сообщение к которому прикреплен коллбек без медиагруппы)
|
# Проверяем, является ли сообщение частью медиагруппы (осознанный костыль, т.к. сообщение к которому прикреплен коллбек без медиагруппы)
|
||||||
if call.message.text == CONTENT_TYPE_MEDIA_GROUP:
|
if call.message.text == CONTENT_TYPE_MEDIA_GROUP:
|
||||||
logger.debug("Сообщение является частью медиагруппы, вызываю _decline_media_group")
|
|
||||||
await self._decline_media_group(call)
|
await self._decline_media_group(call)
|
||||||
return
|
return
|
||||||
|
|
||||||
content_type = call.message.content_type
|
content_type = call.message.content_type
|
||||||
|
|
||||||
if content_type in [CONTENT_TYPE_TEXT, CONTENT_TYPE_PHOTO, CONTENT_TYPE_AUDIO,
|
if content_type in [
|
||||||
CONTENT_TYPE_VOICE, CONTENT_TYPE_VIDEO, CONTENT_TYPE_VIDEO_NOTE]:
|
CONTENT_TYPE_TEXT,
|
||||||
logger.debug(f"Отклоняю одиночный пост типа: {content_type}")
|
CONTENT_TYPE_PHOTO,
|
||||||
|
CONTENT_TYPE_AUDIO,
|
||||||
|
CONTENT_TYPE_VOICE,
|
||||||
|
CONTENT_TYPE_VIDEO,
|
||||||
|
CONTENT_TYPE_VIDEO_NOTE,
|
||||||
|
]:
|
||||||
await self._decline_single_post(call)
|
await self._decline_single_post(call)
|
||||||
else:
|
else:
|
||||||
logger.error(f"Неподдерживаемый тип контента для отклонения: {content_type}")
|
logger.error(
|
||||||
raise PublishError(f"Неподдерживаемый тип контента для отклонения: {content_type}")
|
f"Неподдерживаемый тип контента для отклонения: {content_type}"
|
||||||
|
)
|
||||||
|
raise PublishError(
|
||||||
|
f"Неподдерживаемый тип контента для отклонения: {content_type}"
|
||||||
|
)
|
||||||
|
|
||||||
@track_time("_decline_single_post", "post_publish_service")
|
@track_time("_decline_single_post", "post_publish_service")
|
||||||
@track_errors("post_publish_service", "_decline_single_post")
|
@track_errors("post_publish_service", "_decline_single_post")
|
||||||
async def _decline_single_post(self, call: CallbackQuery) -> None:
|
async def _decline_single_post(self, call: CallbackQuery) -> None:
|
||||||
"""Отклонение одиночного поста"""
|
"""Отклонение одиночного поста"""
|
||||||
logger.debug(f"Отклоняю одиночный пост. Message ID: {call.message.message_id}")
|
|
||||||
author_id = await self._get_author_id(call.message.message_id)
|
author_id = await self._get_author_id(call.message.message_id)
|
||||||
logger.debug(f"ID автора получен: {author_id}")
|
|
||||||
|
# Обучаем RAG на отклоненном посте перед удалением
|
||||||
logger.debug(f"Удаляю сообщение из группы {self.group_for_posts}")
|
await self._train_on_declined(call.message.message_id)
|
||||||
await self._get_bot(call.message).delete_message(chat_id=self.group_for_posts, message_id=call.message.message_id)
|
|
||||||
|
updated_rows = await self.db.update_status_by_message_id(
|
||||||
|
call.message.message_id, "declined"
|
||||||
|
)
|
||||||
|
if updated_rows == 0:
|
||||||
|
logger.error(
|
||||||
|
f"Не удалось обновить статус поста message_id={call.message.message_id} на 'declined'"
|
||||||
|
)
|
||||||
|
raise PostNotFoundError(
|
||||||
|
f"Пост с message_id={call.message.message_id} не найден в базе данных"
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._get_bot(call.message).delete_message(
|
||||||
|
chat_id=self.group_for_posts, message_id=call.message.message_id
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.debug(f"Отправляю уведомление об отклонении автору {author_id}")
|
|
||||||
await send_text_message(author_id, call.message, MESSAGE_POST_DECLINED)
|
await send_text_message(author_id, call.message, MESSAGE_POST_DECLINED)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if str(e) == ERROR_BOT_BLOCKED:
|
if str(e) == ERROR_BOT_BLOCKED:
|
||||||
@@ -227,34 +594,48 @@ class PostPublishService:
|
|||||||
raise UserBlockedBotError("Пользователь заблокировал бота")
|
raise UserBlockedBotError("Пользователь заблокировал бота")
|
||||||
logger.error(f"Ошибка при отправке уведомления автору {author_id}: {e}")
|
logger.error(f"Ошибка при отправке уведомления автору {author_id}: {e}")
|
||||||
raise
|
raise
|
||||||
logger.info(f'Сообщение отклонено админом {call.from_user.full_name} (ID: {call.from_user.id}).')
|
logger.info(
|
||||||
|
f"Сообщение отклонено админом {call.from_user.full_name} (ID: {call.from_user.id})."
|
||||||
|
)
|
||||||
|
|
||||||
@track_time("_decline_media_group", "post_publish_service")
|
@track_time("_decline_media_group", "post_publish_service")
|
||||||
@track_errors("post_publish_service", "_decline_media_group")
|
@track_errors("post_publish_service", "_decline_media_group")
|
||||||
@track_media_processing("media_group")
|
@track_media_processing("media_group")
|
||||||
async def _decline_media_group(self, call: CallbackQuery) -> None:
|
async def _decline_media_group(self, call: CallbackQuery) -> None:
|
||||||
"""Отклонение медиагруппы"""
|
"""Отклонение медиагруппы"""
|
||||||
logger.debug(f"Отклоняю медиагруппу. Helper message ID: {call.message.message_id}")
|
helper_message_id = call.message.message_id
|
||||||
|
|
||||||
post_ids = await self.db.get_post_ids_from_telegram_by_last_id(call.message.message_id)
|
await self.db.update_status_for_media_group_by_helper_id(
|
||||||
message_ids = post_ids.copy()
|
helper_message_id, "declined"
|
||||||
message_ids.append(call.message.message_id)
|
)
|
||||||
logger.debug(f"Получены ID сообщений для удаления: {message_ids}")
|
|
||||||
|
media_group_message_ids = await self.db.get_post_ids_by_helper_id(
|
||||||
author_id = await self._get_author_id_for_media_group(call.message.message_id)
|
helper_message_id
|
||||||
logger.debug(f"ID автора медиагруппы получен: {author_id}")
|
)
|
||||||
|
|
||||||
logger.debug(f"Удаляю {len(message_ids)} сообщений из группы {self.group_for_posts}")
|
message_ids_to_delete = media_group_message_ids.copy()
|
||||||
await self._get_bot(call.message).delete_messages(chat_id=self.group_for_posts, message_ids=message_ids)
|
message_ids_to_delete.append(helper_message_id)
|
||||||
|
|
||||||
|
author_id = await self._get_author_id_for_media_group(helper_message_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._get_bot(call.message).delete_messages(
|
||||||
|
chat_id=self.group_for_posts, message_ids=message_ids_to_delete
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"_decline_media_group: Ошибка при удалении сообщений: {e}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.debug(f"Отправляю уведомление об отклонении автору медиагруппы {author_id}")
|
|
||||||
await send_text_message(author_id, call.message, MESSAGE_POST_DECLINED)
|
await send_text_message(author_id, call.message, MESSAGE_POST_DECLINED)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if str(e) == ERROR_BOT_BLOCKED:
|
if str(e) == ERROR_BOT_BLOCKED:
|
||||||
logger.warning(f"Пользователь {author_id} заблокировал бота")
|
logger.warning(
|
||||||
|
f"_decline_media_group: Пользователь {author_id} заблокировал бота"
|
||||||
|
)
|
||||||
raise UserBlockedBotError("Пользователь заблокировал бота")
|
raise UserBlockedBotError("Пользователь заблокировал бота")
|
||||||
logger.error(f"Ошибка при отправке уведомления автору медиагруппы {author_id}: {e}")
|
logger.error(
|
||||||
|
f"_decline_media_group: Ошибка при отправке уведомления автору {author_id}: {e}"
|
||||||
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@track_time("_get_author_id", "post_publish_service")
|
@track_time("_get_author_id", "post_publish_service")
|
||||||
@@ -274,7 +655,7 @@ class PostPublishService:
|
|||||||
author_id = await self.db.get_author_id_by_helper_message_id(message_id)
|
author_id = await self.db.get_author_id_by_helper_message_id(message_id)
|
||||||
if author_id:
|
if author_id:
|
||||||
return author_id
|
return author_id
|
||||||
|
|
||||||
# Если не найден, ищем по основному message_id медиагруппы
|
# Если не найден, ищем по основному message_id медиагруппы
|
||||||
# Для этого нужно найти связанные сообщения медиагруппы
|
# Для этого нужно найти связанные сообщения медиагруппы
|
||||||
try:
|
try:
|
||||||
@@ -288,7 +669,7 @@ class PostPublishService:
|
|||||||
return author_id
|
return author_id
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Не удалось найти автора через связанные сообщения: {e}")
|
logger.warning(f"Не удалось найти автора через связанные сообщения: {e}")
|
||||||
|
|
||||||
# Если все способы не сработали, ищем напрямую
|
# Если все способы не сработали, ищем напрямую
|
||||||
author_id = await self.db.get_author_id_by_message_id(message_id)
|
author_id = await self.db.get_author_id_by_message_id(message_id)
|
||||||
if not author_id:
|
if not author_id:
|
||||||
@@ -297,10 +678,17 @@ class PostPublishService:
|
|||||||
|
|
||||||
@track_time("_delete_post_and_notify_author", "post_publish_service")
|
@track_time("_delete_post_and_notify_author", "post_publish_service")
|
||||||
@track_errors("post_publish_service", "_delete_post_and_notify_author")
|
@track_errors("post_publish_service", "_delete_post_and_notify_author")
|
||||||
async def _delete_post_and_notify_author(self, call: CallbackQuery, author_id: int) -> None:
|
async def _delete_post_and_notify_author(
|
||||||
|
self, call: CallbackQuery, author_id: int
|
||||||
|
) -> None:
|
||||||
"""Удаление поста и уведомление автора"""
|
"""Удаление поста и уведомление автора"""
|
||||||
await self._get_bot(call.message).delete_message(chat_id=self.group_for_posts, message_id=call.message.message_id)
|
# Получаем текст поста для обучения RAG перед удалением
|
||||||
|
await self._train_on_published(call.message.message_id)
|
||||||
|
|
||||||
|
await self._get_bot(call.message).delete_message(
|
||||||
|
chat_id=self.group_for_posts, message_id=call.message.message_id
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await send_text_message(author_id, call.message, MESSAGE_POST_PUBLISHED)
|
await send_text_message(author_id, call.message, MESSAGE_POST_PUBLISHED)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -308,16 +696,53 @@ class PostPublishService:
|
|||||||
raise UserBlockedBotError("Пользователь заблокировал бота")
|
raise UserBlockedBotError("Пользователь заблокировал бота")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
async def _train_on_published(self, message_id: int) -> None:
|
||||||
|
"""Обучает RAG на опубликованном посте."""
|
||||||
|
if not self.scoring_manager:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
text = await self.db.get_post_text_by_message_id(message_id)
|
||||||
|
if text and text.strip() and text != "^":
|
||||||
|
await self.scoring_manager.on_post_published(text)
|
||||||
|
logger.debug(f"RAG обучен на опубликованном посте: {message_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Ошибка обучения RAG на опубликованном посте {message_id}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _train_on_declined(self, message_id: int) -> None:
|
||||||
|
"""Обучает RAG на отклоненном посте."""
|
||||||
|
if not self.scoring_manager:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
text = await self.db.get_post_text_by_message_id(message_id)
|
||||||
|
if text and text.strip() and text != "^":
|
||||||
|
await self.scoring_manager.on_post_declined(text)
|
||||||
|
logger.debug(f"RAG обучен на отклоненном посте: {message_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка обучения RAG на отклоненном посте {message_id}: {e}")
|
||||||
|
|
||||||
@track_time("_delete_media_group_and_notify_author", "post_publish_service")
|
@track_time("_delete_media_group_and_notify_author", "post_publish_service")
|
||||||
@track_errors("post_publish_service", "_delete_media_group_and_notify_author")
|
@track_errors("post_publish_service", "_delete_media_group_and_notify_author")
|
||||||
@track_media_processing("media_group")
|
@track_media_processing("media_group")
|
||||||
async def _delete_media_group_and_notify_author(self, call: CallbackQuery, author_id: int) -> None:
|
async def _delete_media_group_and_notify_author(
|
||||||
"""Удаление медиагруппы и уведомление автора"""
|
self, call: CallbackQuery, author_id: int
|
||||||
post_ids = await self.db.get_post_ids_from_telegram_by_last_id(call.message.message_id)
|
) -> None:
|
||||||
|
"""Удаление медиагруппы и уведомление автора (legacy метод, используется для обратной совместимости)"""
|
||||||
|
helper_message_id = call.message.message_id
|
||||||
|
|
||||||
#message_ids = post_ids.copy()
|
media_group_message_ids = await self.db.get_post_ids_by_helper_id(
|
||||||
post_ids.append(call.message.message_id)
|
helper_message_id
|
||||||
await self._get_bot(call.message).delete_messages(chat_id=self.group_for_posts, message_ids=post_ids)
|
)
|
||||||
|
|
||||||
|
message_ids_to_delete = media_group_message_ids.copy()
|
||||||
|
message_ids_to_delete.append(helper_message_id)
|
||||||
|
|
||||||
|
await self._get_bot(call.message).delete_messages(
|
||||||
|
chat_id=self.group_for_posts, message_ids=message_ids_to_delete
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
await send_text_message(author_id, call.message, MESSAGE_POST_PUBLISHED)
|
await send_text_message(author_id, call.message, MESSAGE_POST_PUBLISHED)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -325,14 +750,59 @@ class PostPublishService:
|
|||||||
raise UserBlockedBotError("Пользователь заблокировал бота")
|
raise UserBlockedBotError("Пользователь заблокировал бота")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
@track_time("_save_published_post_content", "post_publish_service")
|
||||||
|
@track_errors("post_publish_service", "_save_published_post_content")
|
||||||
|
async def _save_published_post_content(
|
||||||
|
self,
|
||||||
|
published_message: types.Message,
|
||||||
|
published_message_id: int,
|
||||||
|
original_message_id: int,
|
||||||
|
) -> None:
|
||||||
|
"""Сохраняет ссылку на медиафайл из опубликованного поста (файл уже в S3 или на диске)."""
|
||||||
|
try:
|
||||||
|
# Получаем уже сохраненный путь/S3 ключ из оригинального поста
|
||||||
|
saved_content = await self.db.get_post_content_by_message_id(
|
||||||
|
original_message_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if saved_content and len(saved_content) > 0:
|
||||||
|
# Копируем тот же путь/S3 ключ
|
||||||
|
file_path, content_type = saved_content[0]
|
||||||
|
logger.debug(
|
||||||
|
f"Копируем путь/S3 ключ для опубликованного поста: {file_path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
success = await self.db.add_published_post_content(
|
||||||
|
published_message_id=published_message_id,
|
||||||
|
content_path=file_path, # Тот же путь/S3 ключ
|
||||||
|
content_type=content_type,
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
logger.info(
|
||||||
|
f"Ссылка на файл сохранена для опубликованного поста: published_message_id={published_message_id}, path={file_path}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"Не удалось сохранить ссылку на файл: published_message_id={published_message_id}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"Контент не найден для оригинального поста message_id={original_message_id}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Ошибка при сохранении ссылки на контент опубликованного поста {published_message_id}: {e}"
|
||||||
|
)
|
||||||
|
# Не прерываем публикацию, если сохранение контента не удалось
|
||||||
|
|
||||||
|
|
||||||
class BanService:
|
class BanService:
|
||||||
def __init__(self, bot: Bot, db, settings: Dict[str, Any]):
|
def __init__(self, bot: Bot, db, settings: Dict[str, Any]):
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
self.db = db
|
self.db = db
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
self.group_for_posts = settings['Telegram']['group_for_posts']
|
self.group_for_posts = settings["Telegram"]["group_for_posts"]
|
||||||
self.important_logs = settings['Telegram']['important_logs']
|
self.important_logs = settings["Telegram"]["important_logs"]
|
||||||
|
|
||||||
def _get_bot(self, message) -> Bot:
|
def _get_bot(self, message) -> Bot:
|
||||||
"""Получает бота из контекста сообщения или использует переданного"""
|
"""Получает бота из контекста сообщения или использует переданного"""
|
||||||
@@ -345,30 +815,68 @@ class BanService:
|
|||||||
@db_query_time("ban_user_from_post", "users", "mixed")
|
@db_query_time("ban_user_from_post", "users", "mixed")
|
||||||
async def ban_user_from_post(self, call: CallbackQuery) -> None:
|
async def ban_user_from_post(self, call: CallbackQuery) -> None:
|
||||||
"""Бан пользователя за спам"""
|
"""Бан пользователя за спам"""
|
||||||
author_id = await self.db.get_author_id_by_message_id(call.message.message_id)
|
# Если это helper-сообщение медиагруппы, используем специальный метод
|
||||||
|
if call.message.text == CONTENT_TYPE_MEDIA_GROUP:
|
||||||
|
author_id = await self.db.get_author_id_by_helper_message_id(
|
||||||
|
call.message.message_id
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
author_id = await self.db.get_author_id_by_message_id(
|
||||||
|
call.message.message_id
|
||||||
|
)
|
||||||
|
|
||||||
if not author_id:
|
if not author_id:
|
||||||
raise UserNotFoundError(f"Автор не найден для сообщения {call.message.message_id}")
|
raise UserNotFoundError(
|
||||||
|
f"Автор не найден для сообщения {call.message.message_id}"
|
||||||
|
)
|
||||||
|
|
||||||
current_date = datetime.now()
|
current_date = datetime.now()
|
||||||
date_to_unban = int((current_date + timedelta(days=7)).timestamp())
|
date_to_unban = int((current_date + timedelta(days=7)).timestamp())
|
||||||
|
|
||||||
|
ban_author_id = call.from_user.id
|
||||||
|
|
||||||
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
await self._get_bot(call.message).delete_message(chat_id=self.group_for_posts, message_id=call.message.message_id)
|
# Обновляем статус поста на declined
|
||||||
|
if call.message.text == CONTENT_TYPE_MEDIA_GROUP:
|
||||||
|
# Для медиагруппы обновляем статус по helper_message_id
|
||||||
|
updated_rows = await self.db.update_status_for_media_group_by_helper_id(
|
||||||
|
call.message.message_id, "declined"
|
||||||
|
)
|
||||||
|
if updated_rows == 0:
|
||||||
|
logger.warning(
|
||||||
|
f"Не удалось обновить статус медиагруппы helper_message_id={call.message.message_id} на 'declined'"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Для одиночного поста обновляем статус по message_id
|
||||||
|
updated_rows = await self.db.update_status_by_message_id(
|
||||||
|
call.message.message_id, "declined"
|
||||||
|
)
|
||||||
|
if updated_rows == 0:
|
||||||
|
logger.warning(
|
||||||
|
f"Не удалось обновить статус поста message_id={call.message.message_id} на 'declined'"
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._get_bot(call.message).delete_message(
|
||||||
|
chat_id=self.group_for_posts, message_id=call.message.message_id
|
||||||
|
)
|
||||||
|
|
||||||
date_str = (current_date + timedelta(days=7)).strftime("%d.%m.%Y %H:%M")
|
date_str = (current_date + timedelta(days=7)).strftime("%d.%m.%Y %H:%M")
|
||||||
try:
|
try:
|
||||||
await send_text_message(author_id, call.message, MESSAGE_USER_BANNED_SPAM.format(date=date_str))
|
await send_text_message(
|
||||||
|
author_id, call.message, MESSAGE_USER_BANNED_SPAM.format(date=date_str)
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if str(e) == ERROR_BOT_BLOCKED:
|
if str(e) == ERROR_BOT_BLOCKED:
|
||||||
raise UserBlockedBotError("Пользователь заблокировал бота")
|
raise UserBlockedBotError("Пользователь заблокировал бота")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
logger.info(f"Пользователь {author_id} заблокирован за спам до {date_str}")
|
logger.info(f"Пользователь {author_id} заблокирован за спам до {date_str}")
|
||||||
|
|
||||||
@track_time("ban_user", "ban_service")
|
@track_time("ban_user", "ban_service")
|
||||||
@@ -378,7 +886,7 @@ class BanService:
|
|||||||
user_name = await self.db.get_username(int(user_id))
|
user_name = await self.db.get_username(int(user_id))
|
||||||
if not user_name:
|
if not user_name:
|
||||||
raise UserNotFoundError(f"Пользователь с ID {user_id} не найден в базе")
|
raise UserNotFoundError(f"Пользователь с ID {user_id} не найден в базе")
|
||||||
|
|
||||||
return user_name
|
return user_name
|
||||||
|
|
||||||
@track_time("unlock_user", "ban_service")
|
@track_time("unlock_user", "ban_service")
|
||||||
@@ -389,7 +897,7 @@ class BanService:
|
|||||||
user_name = await self.db.get_username(int(user_id))
|
user_name = await self.db.get_username(int(user_id))
|
||||||
if not user_name:
|
if not user_name:
|
||||||
raise UserNotFoundError(f"Пользователь с ID {user_id} не найден в базе")
|
raise UserNotFoundError(f"Пользователь с ID {user_id} не найден в базе")
|
||||||
|
|
||||||
await delete_user_blacklist(int(user_id), self.db)
|
await delete_user_blacklist(int(user_id), self.db)
|
||||||
logger.info(f"Разблокирован пользователь с ID: {user_id} username:{user_name}")
|
logger.info(f"Разблокирован пользователь с ID: {user_id} username:{user_name}")
|
||||||
return user_name
|
return user_name
|
||||||
|
|||||||
@@ -1,47 +1,29 @@
|
|||||||
"""Group handlers package for Telegram bot"""
|
"""Group handlers package for Telegram bot"""
|
||||||
|
|
||||||
# Local imports - main components
|
# Local imports - main components
|
||||||
from .group_handlers import (
|
# Local imports - constants and utilities
|
||||||
group_router,
|
from .constants import ERROR_MESSAGES, FSM_STATES
|
||||||
create_group_handlers,
|
from .decorators import error_handler
|
||||||
GroupHandlers
|
from .exceptions import NoReplyToMessageError, UserNotFoundError
|
||||||
)
|
from .group_handlers import GroupHandlers, create_group_handlers, group_router
|
||||||
|
|
||||||
# Local imports - services
|
# Local imports - services
|
||||||
from .services import (
|
from .services import AdminReplyService, DatabaseProtocol
|
||||||
AdminReplyService,
|
|
||||||
DatabaseProtocol
|
|
||||||
)
|
|
||||||
|
|
||||||
# Local imports - constants and utilities
|
|
||||||
from .constants import (
|
|
||||||
FSM_STATES,
|
|
||||||
ERROR_MESSAGES
|
|
||||||
)
|
|
||||||
from .exceptions import (
|
|
||||||
NoReplyToMessageError,
|
|
||||||
UserNotFoundError
|
|
||||||
)
|
|
||||||
from .decorators import error_handler
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Main components
|
# Main components
|
||||||
'group_router',
|
"group_router",
|
||||||
'create_group_handlers',
|
"create_group_handlers",
|
||||||
'GroupHandlers',
|
"GroupHandlers",
|
||||||
|
|
||||||
# Services
|
# Services
|
||||||
'AdminReplyService',
|
"AdminReplyService",
|
||||||
'DatabaseProtocol',
|
"DatabaseProtocol",
|
||||||
|
|
||||||
# Constants
|
# Constants
|
||||||
'FSM_STATES',
|
"FSM_STATES",
|
||||||
'ERROR_MESSAGES',
|
"ERROR_MESSAGES",
|
||||||
|
|
||||||
# Exceptions
|
# Exceptions
|
||||||
'NoReplyToMessageError',
|
"NoReplyToMessageError",
|
||||||
'UserNotFoundError',
|
"UserNotFoundError",
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
'error_handler'
|
"error_handler",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
"""Constants for group handlers"""
|
"""Constants for group handlers"""
|
||||||
|
|
||||||
from typing import Final, Dict
|
from typing import Dict, Final
|
||||||
|
|
||||||
# FSM States
|
# FSM States
|
||||||
FSM_STATES: Final[Dict[str, str]] = {
|
FSM_STATES: Final[Dict[str, str]] = {"CHAT": "CHAT"}
|
||||||
"CHAT": "CHAT"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Error messages
|
# Error messages
|
||||||
ERROR_MESSAGES: Final[Dict[str, str]] = {
|
ERROR_MESSAGES: Final[Dict[str, str]] = {
|
||||||
"NO_REPLY_TO_MESSAGE": "Блять, выдели сообщение!",
|
"NO_REPLY_TO_MESSAGE": "Блять, выдели сообщение!",
|
||||||
"USER_NOT_FOUND": "Не могу найти кому ответить в базе, проебали сообщение."
|
"USER_NOT_FOUND": "Не могу найти кому ответить в базе, проебали сообщение.",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from logs.custom_logger import logger
|
|||||||
|
|
||||||
def error_handler(func: Callable[..., Any]) -> Callable[..., Any]:
|
def error_handler(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||||
"""Decorator for centralized error handling"""
|
"""Decorator for centralized error handling"""
|
||||||
|
|
||||||
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||||
try:
|
try:
|
||||||
return await func(*args, **kwargs)
|
return await func(*args, **kwargs)
|
||||||
@@ -20,17 +21,23 @@ def error_handler(func: Callable[..., Any]) -> Callable[..., Any]:
|
|||||||
logger.error(f"Error in {func.__name__}: {str(e)}")
|
logger.error(f"Error in {func.__name__}: {str(e)}")
|
||||||
# Try to send error to logs if possible
|
# Try to send error to logs if possible
|
||||||
try:
|
try:
|
||||||
message = next((arg for arg in args if isinstance(arg, types.Message)), None)
|
message = next(
|
||||||
if message and hasattr(message, 'bot'):
|
(arg for arg in args if isinstance(arg, types.Message)), None
|
||||||
from helper_bot.utils.base_dependency_factory import get_global_instance
|
)
|
||||||
|
if message and hasattr(message, "bot"):
|
||||||
|
from helper_bot.utils.base_dependency_factory import (
|
||||||
|
get_global_instance,
|
||||||
|
)
|
||||||
|
|
||||||
bdf = get_global_instance()
|
bdf = get_global_instance()
|
||||||
important_logs = bdf.settings['Telegram']['important_logs']
|
important_logs = bdf.settings["Telegram"]["important_logs"]
|
||||||
await message.bot.send_message(
|
await message.bot.send_message(
|
||||||
chat_id=important_logs,
|
chat_id=important_logs,
|
||||||
text=f"Произошла ошибка в {func.__name__}: {str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
text=f"Произошла ошибка в {func.__name__}: {str(e)}\n\nTraceback:\n{traceback.format_exc()}",
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
# If we can't log the error, at least it was logged to logger
|
# If we can't log the error, at least it was logged to logger
|
||||||
pass
|
pass
|
||||||
raise
|
raise
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
|
|
||||||
class NoReplyToMessageError(Exception):
|
class NoReplyToMessageError(Exception):
|
||||||
"""Raised when admin tries to reply without selecting a message"""
|
"""Raised when admin tries to reply without selecting a message"""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class UserNotFoundError(Exception):
|
class UserNotFoundError(Exception):
|
||||||
"""Raised when user is not found in database for the given message_id"""
|
"""Raised when user is not found in database for the given message_id"""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -8,43 +8,39 @@ from aiogram.fsm.context import FSMContext
|
|||||||
from database.async_db import AsyncBotDB
|
from database.async_db import AsyncBotDB
|
||||||
from helper_bot.filters.main import ChatTypeFilter
|
from helper_bot.filters.main import ChatTypeFilter
|
||||||
|
|
||||||
# Local imports - modular components
|
# Local imports - metrics
|
||||||
from .constants import FSM_STATES, ERROR_MESSAGES
|
from helper_bot.utils.metrics import metrics, track_errors, track_time
|
||||||
from .services import AdminReplyService
|
|
||||||
from .decorators import error_handler
|
|
||||||
from .exceptions import UserNotFoundError
|
|
||||||
|
|
||||||
# Local imports - utilities
|
# Local imports - utilities
|
||||||
from logs.custom_logger import logger
|
from logs.custom_logger import logger
|
||||||
|
|
||||||
# Local imports - metrics
|
# Local imports - modular components
|
||||||
from helper_bot.utils.metrics import (
|
from .constants import ERROR_MESSAGES, FSM_STATES
|
||||||
metrics,
|
from .decorators import error_handler
|
||||||
track_time,
|
from .exceptions import UserNotFoundError
|
||||||
track_errors
|
from .services import AdminReplyService
|
||||||
)
|
|
||||||
|
|
||||||
class GroupHandlers:
|
class GroupHandlers:
|
||||||
"""Main handler class for group messages"""
|
"""Main handler class for group messages"""
|
||||||
|
|
||||||
def __init__(self, db: AsyncBotDB, keyboard_markup: types.ReplyKeyboardMarkup):
|
def __init__(self, db: AsyncBotDB, keyboard_markup: types.ReplyKeyboardMarkup):
|
||||||
self.db = db
|
self.db = db
|
||||||
self.keyboard_markup = keyboard_markup
|
self.keyboard_markup = keyboard_markup
|
||||||
self.admin_reply_service = AdminReplyService(db)
|
self.admin_reply_service = AdminReplyService(db)
|
||||||
|
|
||||||
# Create router
|
# Create router
|
||||||
self.router = Router()
|
self.router = Router()
|
||||||
|
|
||||||
# Register handlers
|
# Register handlers
|
||||||
self._register_handlers()
|
self._register_handlers()
|
||||||
|
|
||||||
def _register_handlers(self):
|
def _register_handlers(self):
|
||||||
"""Register all message handlers"""
|
"""Register all message handlers"""
|
||||||
self.router.message.register(
|
self.router.message.register(
|
||||||
self.handle_message,
|
self.handle_message, ChatTypeFilter(chat_type=["group", "supergroup"])
|
||||||
ChatTypeFilter(chat_type=["group", "supergroup"])
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@error_handler
|
@error_handler
|
||||||
@track_errors("group_handlers", "handle_message")
|
@track_errors("group_handlers", "handle_message")
|
||||||
@track_time("handle_message", "group_handlers")
|
@track_time("handle_message", "group_handlers")
|
||||||
@@ -52,44 +48,46 @@ class GroupHandlers:
|
|||||||
"""Handle admin reply to user through group chat"""
|
"""Handle admin reply to user through group chat"""
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f'Получено сообщение в группе {message.chat.title} (ID: {message.chat.id}) '
|
f"Получено сообщение в группе {message.chat.title} (ID: {message.chat.id}) "
|
||||||
f'от пользователя {message.from_user.full_name} (ID: {message.from_user.id}): "{message.text}"'
|
f'от пользователя {message.from_user.full_name} (ID: {message.from_user.id}): "{message.text}"'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if message is a reply
|
# Check if message is a reply
|
||||||
if not message.reply_to_message:
|
if not message.reply_to_message:
|
||||||
await message.answer(ERROR_MESSAGES["NO_REPLY_TO_MESSAGE"])
|
await message.answer(ERROR_MESSAGES["NO_REPLY_TO_MESSAGE"])
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f'В группе {message.chat.title} (ID: {message.chat.id}) '
|
f"В группе {message.chat.title} (ID: {message.chat.id}) "
|
||||||
f'админ не выделил сообщение для ответа.'
|
f"админ не выделил сообщение для ответа."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
message_id = message.reply_to_message.message_id
|
message_id = message.reply_to_message.message_id
|
||||||
reply_text = message.text
|
reply_text = message.text
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get user ID for reply
|
# Get user ID for reply
|
||||||
chat_id = await self.admin_reply_service.get_user_id_for_reply(message_id)
|
chat_id = await self.admin_reply_service.get_user_id_for_reply(message_id)
|
||||||
|
|
||||||
# Send reply to user
|
# Send reply to user
|
||||||
await self.admin_reply_service.send_reply_to_user(
|
await self.admin_reply_service.send_reply_to_user(
|
||||||
chat_id, message, reply_text, self.keyboard_markup
|
chat_id, message, reply_text, self.keyboard_markup
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set state
|
# Set state
|
||||||
await state.set_state(FSM_STATES["CHAT"])
|
await state.set_state(FSM_STATES["CHAT"])
|
||||||
|
|
||||||
except UserNotFoundError:
|
except UserNotFoundError:
|
||||||
await message.answer(ERROR_MESSAGES["USER_NOT_FOUND"])
|
await message.answer(ERROR_MESSAGES["USER_NOT_FOUND"])
|
||||||
logger.error(
|
logger.error(
|
||||||
f'Ошибка при поиске пользователя в базе для ответа на сообщение: {reply_text} '
|
f"Ошибка при поиске пользователя в базе для ответа на сообщение: {reply_text} "
|
||||||
f'в группе {message.chat.title} (ID сообщения: {message.message_id})'
|
f"в группе {message.chat.title} (ID сообщения: {message.message_id})"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Factory function to create handlers with dependencies
|
# Factory function to create handlers with dependencies
|
||||||
def create_group_handlers(db: AsyncBotDB, keyboard_markup: types.ReplyKeyboardMarkup) -> GroupHandlers:
|
def create_group_handlers(
|
||||||
|
db: AsyncBotDB, keyboard_markup: types.ReplyKeyboardMarkup
|
||||||
|
) -> GroupHandlers:
|
||||||
"""Create group handlers instance with dependencies"""
|
"""Create group handlers instance with dependencies"""
|
||||||
return GroupHandlers(db, keyboard_markup)
|
return GroupHandlers(db, keyboard_markup)
|
||||||
|
|
||||||
@@ -97,21 +95,23 @@ def create_group_handlers(db: AsyncBotDB, keyboard_markup: types.ReplyKeyboardMa
|
|||||||
# Legacy router for backward compatibility
|
# Legacy router for backward compatibility
|
||||||
group_router = Router()
|
group_router = Router()
|
||||||
|
|
||||||
|
|
||||||
# Initialize with global dependencies (for backward compatibility)
|
# Initialize with global dependencies (for backward compatibility)
|
||||||
def init_legacy_router():
|
def init_legacy_router():
|
||||||
"""Initialize legacy router with global dependencies"""
|
"""Initialize legacy router with global dependencies"""
|
||||||
global group_router
|
global group_router
|
||||||
|
|
||||||
from helper_bot.utils.base_dependency_factory import get_global_instance
|
|
||||||
from helper_bot.keyboards.keyboards import get_reply_keyboard_leave_chat
|
from helper_bot.keyboards.keyboards import get_reply_keyboard_leave_chat
|
||||||
|
from helper_bot.utils.base_dependency_factory import get_global_instance
|
||||||
|
|
||||||
bdf = get_global_instance()
|
bdf = get_global_instance()
|
||||||
#TODO: поменять архитектуру и подключить правильный BotDB
|
# TODO: поменять архитектуру и подключить правильный BotDB
|
||||||
db = bdf.get_db()
|
db = bdf.get_db()
|
||||||
keyboard_markup = get_reply_keyboard_leave_chat()
|
keyboard_markup = get_reply_keyboard_leave_chat()
|
||||||
|
|
||||||
handlers = create_group_handlers(db, keyboard_markup)
|
handlers = create_group_handlers(db, keyboard_markup)
|
||||||
group_router = handlers.router
|
group_router = handlers.router
|
||||||
|
|
||||||
|
|
||||||
# Initialize legacy router
|
# Initialize legacy router
|
||||||
init_legacy_router()
|
init_legacy_router()
|
||||||
|
|||||||
@@ -1,49 +1,49 @@
|
|||||||
"""Service classes for group handlers"""
|
"""Service classes for group handlers"""
|
||||||
|
|
||||||
# Standard library imports
|
# Standard library imports
|
||||||
from typing import Protocol, Optional
|
from typing import Optional, Protocol
|
||||||
|
|
||||||
# Third-party imports
|
# Third-party imports
|
||||||
from aiogram import types
|
from aiogram import types
|
||||||
|
|
||||||
# Local imports
|
# Local imports
|
||||||
from helper_bot.utils.helper_func import send_text_message
|
from helper_bot.utils.helper_func import send_text_message
|
||||||
from .exceptions import NoReplyToMessageError, UserNotFoundError
|
|
||||||
from logs.custom_logger import logger
|
|
||||||
|
|
||||||
# Local imports - metrics
|
# Local imports - metrics
|
||||||
from helper_bot.utils.metrics import (
|
from helper_bot.utils.metrics import db_query_time, track_errors, track_time
|
||||||
track_time,
|
from logs.custom_logger import logger
|
||||||
track_errors,
|
|
||||||
db_query_time
|
from .exceptions import NoReplyToMessageError, UserNotFoundError
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DatabaseProtocol(Protocol):
|
class DatabaseProtocol(Protocol):
|
||||||
"""Protocol for database operations"""
|
"""Protocol for database operations"""
|
||||||
|
|
||||||
async def get_user_by_message_id(self, message_id: int) -> Optional[int]: ...
|
async def get_user_by_message_id(self, message_id: int) -> Optional[int]: ...
|
||||||
async def add_message(self, message_text: str, user_id: int, message_id: int, date: int = None): ...
|
async def add_message(
|
||||||
|
self, message_text: str, user_id: int, message_id: int, date: int = None
|
||||||
|
): ...
|
||||||
|
|
||||||
|
|
||||||
class AdminReplyService:
|
class AdminReplyService:
|
||||||
"""Service for admin reply operations"""
|
"""Service for admin reply operations"""
|
||||||
|
|
||||||
def __init__(self, db: DatabaseProtocol) -> None:
|
def __init__(self, db: DatabaseProtocol) -> None:
|
||||||
self.db = db
|
self.db = db
|
||||||
|
|
||||||
@track_time("get_user_id_for_reply", "admin_reply_service")
|
@track_time("get_user_id_for_reply", "admin_reply_service")
|
||||||
@track_errors("admin_reply_service", "get_user_id_for_reply")
|
@track_errors("admin_reply_service", "get_user_id_for_reply")
|
||||||
@db_query_time("get_user_id_for_reply", "users", "select")
|
@db_query_time("get_user_id_for_reply", "users", "select")
|
||||||
async def get_user_id_for_reply(self, message_id: int) -> int:
|
async def get_user_id_for_reply(self, message_id: int) -> int:
|
||||||
"""
|
"""
|
||||||
Get user ID for reply by message ID.
|
Get user ID for reply by message ID.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
message_id: ID of the message to reply to
|
message_id: ID of the message to reply to
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
User ID for the reply
|
User ID for the reply
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
UserNotFoundError: If user is not found in database
|
UserNotFoundError: If user is not found in database
|
||||||
"""
|
"""
|
||||||
@@ -51,19 +51,19 @@ class AdminReplyService:
|
|||||||
if user_id is None:
|
if user_id is None:
|
||||||
raise UserNotFoundError(f"User not found for message_id: {message_id}")
|
raise UserNotFoundError(f"User not found for message_id: {message_id}")
|
||||||
return user_id
|
return user_id
|
||||||
|
|
||||||
@track_time("send_reply_to_user", "admin_reply_service")
|
@track_time("send_reply_to_user", "admin_reply_service")
|
||||||
@track_errors("admin_reply_service", "send_reply_to_user")
|
@track_errors("admin_reply_service", "send_reply_to_user")
|
||||||
async def send_reply_to_user(
|
async def send_reply_to_user(
|
||||||
self,
|
self,
|
||||||
chat_id: int,
|
chat_id: int,
|
||||||
message: types.Message,
|
message: types.Message,
|
||||||
reply_text: str,
|
reply_text: str,
|
||||||
markup: types.ReplyKeyboardMarkup
|
markup: types.ReplyKeyboardMarkup,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Send reply to user.
|
Send reply to user.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
chat_id: User's chat ID
|
chat_id: User's chat ID
|
||||||
message: Original message from admin
|
message: Original message from admin
|
||||||
|
|||||||
@@ -1,45 +1,28 @@
|
|||||||
"""Private handlers package for Telegram bot"""
|
"""Private handlers package for Telegram bot"""
|
||||||
|
|
||||||
# Local imports - main components
|
# Local imports - main components
|
||||||
from .private_handlers import (
|
# Local imports - constants and utilities
|
||||||
private_router,
|
from .constants import BUTTON_TEXTS, ERROR_MESSAGES, FSM_STATES
|
||||||
create_private_handlers,
|
from .decorators import error_handler
|
||||||
PrivateHandlers
|
from .private_handlers import PrivateHandlers, create_private_handlers, private_router
|
||||||
)
|
|
||||||
|
|
||||||
# Local imports - services
|
# Local imports - services
|
||||||
from .services import (
|
from .services import BotSettings, PostService, StickerService, UserService
|
||||||
BotSettings,
|
|
||||||
UserService,
|
|
||||||
PostService,
|
|
||||||
StickerService
|
|
||||||
)
|
|
||||||
|
|
||||||
# Local imports - constants and utilities
|
|
||||||
from .constants import (
|
|
||||||
FSM_STATES,
|
|
||||||
BUTTON_TEXTS,
|
|
||||||
ERROR_MESSAGES
|
|
||||||
)
|
|
||||||
from .decorators import error_handler
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Main components
|
# Main components
|
||||||
'private_router',
|
"private_router",
|
||||||
'create_private_handlers',
|
"create_private_handlers",
|
||||||
'PrivateHandlers',
|
"PrivateHandlers",
|
||||||
|
|
||||||
# Services
|
# Services
|
||||||
'BotSettings',
|
"BotSettings",
|
||||||
'UserService',
|
"UserService",
|
||||||
'PostService',
|
"PostService",
|
||||||
'StickerService',
|
"StickerService",
|
||||||
|
|
||||||
# Constants
|
# Constants
|
||||||
'FSM_STATES',
|
"FSM_STATES",
|
||||||
'BUTTON_TEXTS',
|
"BUTTON_TEXTS",
|
||||||
'ERROR_MESSAGES',
|
"ERROR_MESSAGES",
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
'error_handler'
|
"error_handler",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
"""Constants for private handlers"""
|
"""Constants for private handlers"""
|
||||||
|
|
||||||
from typing import Final, Dict
|
from typing import Dict, Final
|
||||||
|
|
||||||
# FSM States
|
# FSM States
|
||||||
FSM_STATES: Final[Dict[str, str]] = {
|
FSM_STATES: Final[Dict[str, str]] = {
|
||||||
"START": "START",
|
"START": "START",
|
||||||
"SUGGEST": "SUGGEST",
|
"SUGGEST": "SUGGEST",
|
||||||
"PRE_CHAT": "PRE_CHAT",
|
"PRE_CHAT": "PRE_CHAT",
|
||||||
"CHAT": "CHAT"
|
"CHAT": "CHAT",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Button texts
|
# Button texts
|
||||||
@@ -18,7 +18,7 @@ BUTTON_TEXTS: Final[Dict[str, str]] = {
|
|||||||
"RETURN_TO_BOT": "Вернуться в бота",
|
"RETURN_TO_BOT": "Вернуться в бота",
|
||||||
"WANT_STICKERS": "🤪Хочу стикеры",
|
"WANT_STICKERS": "🤪Хочу стикеры",
|
||||||
"CONNECT_ADMIN": "📩Связаться с админами",
|
"CONNECT_ADMIN": "📩Связаться с админами",
|
||||||
"VOICE_BOT": "🎤Голосовой бот"
|
"VOICE_BOT": "🎤Голосовой бот",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Button to command mapping for metrics
|
# Button to command mapping for metrics
|
||||||
@@ -29,15 +29,15 @@ BUTTON_COMMAND_MAPPING: Final[Dict[str, str]] = {
|
|||||||
"Вернуться в бота": "return_to_bot",
|
"Вернуться в бота": "return_to_bot",
|
||||||
"🤪Хочу стикеры": "want_stickers",
|
"🤪Хочу стикеры": "want_stickers",
|
||||||
"📩Связаться с админами": "connect_admin",
|
"📩Связаться с админами": "connect_admin",
|
||||||
"🎤Голосовой бот": "voice_bot"
|
"🎤Голосовой бот": "voice_bot",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Error messages
|
# Error messages
|
||||||
ERROR_MESSAGES: Final[Dict[str, str]] = {
|
ERROR_MESSAGES: Final[Dict[str, str]] = {
|
||||||
"UNSUPPORTED_CONTENT": (
|
"UNSUPPORTED_CONTENT": (
|
||||||
'Я пока не умею работать с таким сообщением. '
|
"Я пока не умею работать с таким сообщением. "
|
||||||
'Пришли текст и фото/фоты(ы). А лучше перешли это сообщение админу @kerrad1\n'
|
"Пришли текст и фото/фоты(ы). А лучше перешли это сообщение админу @kerrad1\n"
|
||||||
'Мы добавим его к обработке если необходимо'
|
"Мы добавим его к обработке если необходимо"
|
||||||
),
|
),
|
||||||
"STICKERS_LINK": "Хорошо, лови, добавить можно отсюда: https://t.me/addstickers/love_biysk"
|
"STICKERS_LINK": "Хорошо, лови, добавить можно отсюда: https://t.me/addstickers/love_biysk",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from logs.custom_logger import logger
|
|||||||
|
|
||||||
def error_handler(func: Callable[..., Any]) -> Callable[..., Any]:
|
def error_handler(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||||
"""Decorator for centralized error handling"""
|
"""Decorator for centralized error handling"""
|
||||||
|
|
||||||
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||||
try:
|
try:
|
||||||
return await func(*args, **kwargs)
|
return await func(*args, **kwargs)
|
||||||
@@ -20,17 +21,23 @@ def error_handler(func: Callable[..., Any]) -> Callable[..., Any]:
|
|||||||
logger.error(f"Error in {func.__name__}: {str(e)}")
|
logger.error(f"Error in {func.__name__}: {str(e)}")
|
||||||
# Try to send error to logs if possible
|
# Try to send error to logs if possible
|
||||||
try:
|
try:
|
||||||
message = next((arg for arg in args if isinstance(arg, types.Message)), None)
|
message = next(
|
||||||
if message and hasattr(message, 'bot'):
|
(arg for arg in args if isinstance(arg, types.Message)), None
|
||||||
from helper_bot.utils.base_dependency_factory import get_global_instance
|
)
|
||||||
|
if message and hasattr(message, "bot"):
|
||||||
|
from helper_bot.utils.base_dependency_factory import (
|
||||||
|
get_global_instance,
|
||||||
|
)
|
||||||
|
|
||||||
bdf = get_global_instance()
|
bdf = get_global_instance()
|
||||||
important_logs = bdf.settings['Telegram']['important_logs']
|
important_logs = bdf.settings["Telegram"]["important_logs"]
|
||||||
await message.bot.send_message(
|
await message.bot.send_message(
|
||||||
chat_id=important_logs,
|
chat_id=important_logs,
|
||||||
text=f"Произошла ошибка в {func.__name__}: {str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
text=f"Произошла ошибка в {func.__name__}: {str(e)}\n\nTraceback:\n{traceback.format_exc()}",
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
# If we can't log the error, at least it was logged to logger
|
# If we can't log the error, at least it was logged to logger
|
||||||
pass
|
pass
|
||||||
raise
|
raise
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|||||||
@@ -5,37 +5,39 @@ import asyncio
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
# Third-party imports
|
# Third-party imports
|
||||||
from aiogram import types, Router, F
|
from aiogram import F, Router, types
|
||||||
from aiogram.filters import Command, StateFilter
|
from aiogram.filters import Command, StateFilter
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
|
|
||||||
# Local imports - filters and middlewares
|
# Local imports - filters and middlewares
|
||||||
from database.async_db import AsyncBotDB
|
from database.async_db import AsyncBotDB
|
||||||
from helper_bot.filters.main import ChatTypeFilter
|
from helper_bot.filters.main import ChatTypeFilter
|
||||||
from helper_bot.middlewares.album_middleware import AlbumMiddleware
|
|
||||||
from helper_bot.middlewares.blacklist_middleware import BlacklistMiddleware
|
|
||||||
|
|
||||||
# Local imports - utilities
|
# Local imports - utilities
|
||||||
from helper_bot.keyboards import get_reply_keyboard, get_reply_keyboard_for_post
|
from helper_bot.keyboards import get_reply_keyboard, get_reply_keyboard_for_post
|
||||||
from helper_bot.keyboards.keyboards import get_reply_keyboard_leave_chat
|
from helper_bot.keyboards.keyboards import get_reply_keyboard_leave_chat
|
||||||
|
from helper_bot.middlewares.album_middleware import AlbumMiddleware
|
||||||
|
from helper_bot.middlewares.blacklist_middleware import BlacklistMiddleware
|
||||||
from helper_bot.utils import messages
|
from helper_bot.utils import messages
|
||||||
from helper_bot.utils.helper_func import (
|
from helper_bot.utils.helper_func import (
|
||||||
get_first_name,
|
check_user_emoji,
|
||||||
update_user_info,
|
get_first_name,
|
||||||
check_user_emoji
|
update_user_info,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Local imports - metrics
|
# Local imports - metrics
|
||||||
from helper_bot.utils.metrics import (
|
from helper_bot.utils.metrics import db_query_time, track_errors, track_time
|
||||||
track_time,
|
|
||||||
track_errors,
|
|
||||||
db_query_time
|
|
||||||
)
|
|
||||||
|
|
||||||
# Local imports - modular components
|
# Local imports - modular components
|
||||||
from .constants import FSM_STATES, BUTTON_TEXTS, ERROR_MESSAGES
|
from .constants import BUTTON_TEXTS, ERROR_MESSAGES, FSM_STATES
|
||||||
from .services import BotSettings, UserService, PostService, StickerService
|
|
||||||
from .decorators import error_handler
|
from .decorators import error_handler
|
||||||
|
from .services import (
|
||||||
|
AutoModerationService,
|
||||||
|
BotSettings,
|
||||||
|
PostService,
|
||||||
|
StickerService,
|
||||||
|
UserService,
|
||||||
|
)
|
||||||
|
|
||||||
# Expose sleep for tests (tests patch helper_bot.handlers.private.private_handlers.sleep)
|
# Expose sleep for tests (tests patch helper_bot.handlers.private.private_handlers.sleep)
|
||||||
sleep = asyncio.sleep
|
sleep = asyncio.sleep
|
||||||
@@ -43,84 +45,149 @@ sleep = asyncio.sleep
|
|||||||
|
|
||||||
class PrivateHandlers:
|
class PrivateHandlers:
|
||||||
"""Main handler class for private messages"""
|
"""Main handler class for private messages"""
|
||||||
|
|
||||||
def __init__(self, db: AsyncBotDB, settings: BotSettings):
|
def __init__(
|
||||||
|
self,
|
||||||
|
db: AsyncBotDB,
|
||||||
|
settings: BotSettings,
|
||||||
|
s3_storage=None,
|
||||||
|
scoring_manager=None,
|
||||||
|
):
|
||||||
self.db = db
|
self.db = db
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
self.user_service = UserService(db, settings)
|
self.user_service = UserService(db, settings)
|
||||||
self.post_service = PostService(db, settings)
|
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.sticker_service = StickerService(settings)
|
||||||
|
|
||||||
# Create router
|
|
||||||
self.router = Router()
|
self.router = Router()
|
||||||
self.router.message.middleware(AlbumMiddleware())
|
self.router.message.middleware(AlbumMiddleware(latency=5.0))
|
||||||
self.router.message.middleware(BlacklistMiddleware())
|
self.router.message.middleware(BlacklistMiddleware())
|
||||||
|
|
||||||
# Register handlers
|
# Register handlers
|
||||||
self._register_handlers()
|
self._register_handlers()
|
||||||
|
|
||||||
def _register_handlers(self):
|
def _register_handlers(self):
|
||||||
"""Register all message handlers"""
|
"""Register all message handlers"""
|
||||||
# Command handlers
|
# Command handlers
|
||||||
self.router.message.register(self.handle_emoji_message, ChatTypeFilter(chat_type=["private"]), Command("emoji"))
|
self.router.message.register(
|
||||||
self.router.message.register(self.handle_restart_message, ChatTypeFilter(chat_type=["private"]), Command("restart"))
|
self.handle_emoji_message,
|
||||||
self.router.message.register(self.handle_start_message, ChatTypeFilter(chat_type=["private"]), Command("start"))
|
ChatTypeFilter(chat_type=["private"]),
|
||||||
self.router.message.register(self.handle_start_message, ChatTypeFilter(chat_type=["private"]), F.text == BUTTON_TEXTS["RETURN_TO_BOT"])
|
Command("emoji"),
|
||||||
|
)
|
||||||
# Button handlers
|
self.router.message.register(
|
||||||
self.router.message.register(self.suggest_post, StateFilter(FSM_STATES["START"]), ChatTypeFilter(chat_type=["private"]), F.text == BUTTON_TEXTS["SUGGEST_POST"])
|
self.handle_restart_message,
|
||||||
self.router.message.register(self.end_message, ChatTypeFilter(chat_type=["private"]), F.text == BUTTON_TEXTS["SAY_GOODBYE"])
|
ChatTypeFilter(chat_type=["private"]),
|
||||||
self.router.message.register(self.end_message, ChatTypeFilter(chat_type=["private"]), F.text == BUTTON_TEXTS["LEAVE_CHAT"])
|
Command("restart"),
|
||||||
self.router.message.register(self.stickers, ChatTypeFilter(chat_type=["private"]), F.text == BUTTON_TEXTS["WANT_STICKERS"])
|
)
|
||||||
self.router.message.register(self.connect_with_admin, StateFilter(FSM_STATES["START"]), ChatTypeFilter(chat_type=["private"]), F.text == BUTTON_TEXTS["CONNECT_ADMIN"])
|
self.router.message.register(
|
||||||
|
self.handle_start_message,
|
||||||
|
ChatTypeFilter(chat_type=["private"]),
|
||||||
|
Command("start"),
|
||||||
|
)
|
||||||
|
self.router.message.register(
|
||||||
|
self.handle_start_message,
|
||||||
|
ChatTypeFilter(chat_type=["private"]),
|
||||||
|
F.text == BUTTON_TEXTS["RETURN_TO_BOT"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Button handlers
|
||||||
|
self.router.message.register(
|
||||||
|
self.suggest_post,
|
||||||
|
StateFilter(FSM_STATES["START"]),
|
||||||
|
ChatTypeFilter(chat_type=["private"]),
|
||||||
|
F.text == BUTTON_TEXTS["SUGGEST_POST"],
|
||||||
|
)
|
||||||
|
self.router.message.register(
|
||||||
|
self.end_message,
|
||||||
|
ChatTypeFilter(chat_type=["private"]),
|
||||||
|
F.text == BUTTON_TEXTS["SAY_GOODBYE"],
|
||||||
|
)
|
||||||
|
self.router.message.register(
|
||||||
|
self.end_message,
|
||||||
|
ChatTypeFilter(chat_type=["private"]),
|
||||||
|
F.text == BUTTON_TEXTS["LEAVE_CHAT"],
|
||||||
|
)
|
||||||
|
self.router.message.register(
|
||||||
|
self.stickers,
|
||||||
|
ChatTypeFilter(chat_type=["private"]),
|
||||||
|
F.text == BUTTON_TEXTS["WANT_STICKERS"],
|
||||||
|
)
|
||||||
|
self.router.message.register(
|
||||||
|
self.connect_with_admin,
|
||||||
|
StateFilter(FSM_STATES["START"]),
|
||||||
|
ChatTypeFilter(chat_type=["private"]),
|
||||||
|
F.text == BUTTON_TEXTS["CONNECT_ADMIN"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# State handlers
|
# State handlers
|
||||||
self.router.message.register(self.suggest_router, StateFilter(FSM_STATES["SUGGEST"]), ChatTypeFilter(chat_type=["private"]))
|
self.router.message.register(
|
||||||
self.router.message.register(self.resend_message_in_group_for_message, StateFilter(FSM_STATES["PRE_CHAT"]), ChatTypeFilter(chat_type=["private"]))
|
self.suggest_router,
|
||||||
self.router.message.register(self.resend_message_in_group_for_message, StateFilter(FSM_STATES["CHAT"]), ChatTypeFilter(chat_type=["private"]))
|
StateFilter(FSM_STATES["SUGGEST"]),
|
||||||
|
ChatTypeFilter(chat_type=["private"]),
|
||||||
|
)
|
||||||
|
self.router.message.register(
|
||||||
|
self.resend_message_in_group_for_message,
|
||||||
|
StateFilter(FSM_STATES["PRE_CHAT"]),
|
||||||
|
ChatTypeFilter(chat_type=["private"]),
|
||||||
|
)
|
||||||
|
self.router.message.register(
|
||||||
|
self.resend_message_in_group_for_message,
|
||||||
|
StateFilter(FSM_STATES["CHAT"]),
|
||||||
|
ChatTypeFilter(chat_type=["private"]),
|
||||||
|
)
|
||||||
|
|
||||||
@error_handler
|
@error_handler
|
||||||
@track_errors("private_handlers", "handle_emoji_message")
|
@track_errors("private_handlers", "handle_emoji_message")
|
||||||
@track_time("handle_emoji_message", "private_handlers")
|
@track_time("handle_emoji_message", "private_handlers")
|
||||||
async def handle_emoji_message(self, message: types.Message, state: FSMContext, **kwargs):
|
async def handle_emoji_message(
|
||||||
|
self, message: types.Message, state: FSMContext, **kwargs
|
||||||
|
):
|
||||||
"""Handle emoji command"""
|
"""Handle emoji command"""
|
||||||
await self.user_service.log_user_message(message)
|
await self.user_service.log_user_message(message)
|
||||||
user_emoji = await check_user_emoji(message)
|
user_emoji = await check_user_emoji(message)
|
||||||
await state.set_state(FSM_STATES["START"])
|
await state.set_state(FSM_STATES["START"])
|
||||||
if user_emoji is not None:
|
if user_emoji is not None:
|
||||||
await message.answer(f'Твоя эмодзя - {user_emoji}', parse_mode='HTML')
|
await message.answer(f"Твоя эмодзя - {user_emoji}", parse_mode="HTML")
|
||||||
|
|
||||||
@error_handler
|
@error_handler
|
||||||
@track_errors("private_handlers", "handle_restart_message")
|
@track_errors("private_handlers", "handle_restart_message")
|
||||||
@track_time("handle_restart_message", "private_handlers")
|
@track_time("handle_restart_message", "private_handlers")
|
||||||
async def handle_restart_message(self, message: types.Message, state: FSMContext, **kwargs):
|
async def handle_restart_message(
|
||||||
|
self, message: types.Message, state: FSMContext, **kwargs
|
||||||
|
):
|
||||||
"""Handle restart command"""
|
"""Handle restart command"""
|
||||||
markup = await get_reply_keyboard(self.db, message.from_user.id)
|
markup = await get_reply_keyboard(self.db, message.from_user.id)
|
||||||
await self.user_service.log_user_message(message)
|
await self.user_service.log_user_message(message)
|
||||||
await state.set_state(FSM_STATES["START"])
|
await state.set_state(FSM_STATES["START"])
|
||||||
await update_user_info('love', message)
|
await update_user_info("love", message)
|
||||||
await check_user_emoji(message)
|
await check_user_emoji(message)
|
||||||
await message.answer('Я перезапущен!', reply_markup=markup, parse_mode='HTML')
|
await message.answer("Я перезапущен!", reply_markup=markup, parse_mode="HTML")
|
||||||
|
|
||||||
@error_handler
|
@error_handler
|
||||||
@track_errors("private_handlers", "handle_start_message")
|
@track_errors("private_handlers", "handle_start_message")
|
||||||
@track_time("handle_start_message", "private_handlers")
|
@track_time("handle_start_message", "private_handlers")
|
||||||
async def handle_start_message(self, message: types.Message, state: FSMContext, **kwargs):
|
async def handle_start_message(
|
||||||
|
self, message: types.Message, state: FSMContext, **kwargs
|
||||||
|
):
|
||||||
"""Handle start command and return to bot button with metrics tracking"""
|
"""Handle start command and return to bot button with metrics tracking"""
|
||||||
# User service operations with metrics
|
# User service operations with metrics
|
||||||
await self.user_service.log_user_message(message)
|
await self.user_service.log_user_message(message)
|
||||||
await self.user_service.ensure_user_exists(message)
|
await self.user_service.ensure_user_exists(message)
|
||||||
await state.set_state(FSM_STATES["START"])
|
await state.set_state(FSM_STATES["START"])
|
||||||
|
|
||||||
# Send sticker with metrics
|
# Send sticker with metrics
|
||||||
await self.sticker_service.send_random_hello_sticker(message)
|
await self.sticker_service.send_random_hello_sticker(message)
|
||||||
|
|
||||||
# Send welcome message with metrics
|
# Send welcome message with metrics
|
||||||
markup = await get_reply_keyboard(self.db, message.from_user.id)
|
markup = await get_reply_keyboard(self.db, message.from_user.id)
|
||||||
hello_message = messages.get_message(get_first_name(message), 'HELLO_MESSAGE')
|
hello_message = messages.get_message(get_first_name(message), "HELLO_MESSAGE")
|
||||||
await message.answer(hello_message, reply_markup=markup, parse_mode='HTML')
|
await message.answer(hello_message, reply_markup=markup, parse_mode="HTML")
|
||||||
|
|
||||||
@error_handler
|
@error_handler
|
||||||
@track_errors("private_handlers", "suggest_post")
|
@track_errors("private_handlers", "suggest_post")
|
||||||
@track_time("suggest_post", "private_handlers")
|
@track_time("suggest_post", "private_handlers")
|
||||||
@@ -130,11 +197,11 @@ class PrivateHandlers:
|
|||||||
await self.user_service.update_user_activity(message.from_user.id)
|
await self.user_service.update_user_activity(message.from_user.id)
|
||||||
await self.user_service.log_user_message(message)
|
await self.user_service.log_user_message(message)
|
||||||
await state.set_state(FSM_STATES["SUGGEST"])
|
await state.set_state(FSM_STATES["SUGGEST"])
|
||||||
|
|
||||||
markup = types.ReplyKeyboardRemove()
|
markup = types.ReplyKeyboardRemove()
|
||||||
suggest_news = messages.get_message(get_first_name(message), 'SUGGEST_NEWS')
|
suggest_news = messages.get_message(get_first_name(message), "SUGGEST_NEWS")
|
||||||
await message.answer(suggest_news, reply_markup=markup)
|
await message.answer(suggest_news, reply_markup=markup)
|
||||||
|
|
||||||
@error_handler
|
@error_handler
|
||||||
@track_errors("private_handlers", "end_message")
|
@track_errors("private_handlers", "end_message")
|
||||||
@track_time("end_message", "private_handlers")
|
@track_time("end_message", "private_handlers")
|
||||||
@@ -143,32 +210,59 @@ class PrivateHandlers:
|
|||||||
# 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 self.user_service.log_user_message(message)
|
await self.user_service.log_user_message(message)
|
||||||
|
|
||||||
# Send sticker
|
# Send sticker
|
||||||
await self.sticker_service.send_random_goodbye_sticker(message)
|
await self.sticker_service.send_random_goodbye_sticker(message)
|
||||||
|
|
||||||
# Send goodbye message
|
# Send goodbye message
|
||||||
markup = types.ReplyKeyboardRemove()
|
markup = types.ReplyKeyboardRemove()
|
||||||
bye_message = messages.get_message(get_first_name(message), 'BYE_MESSAGE')
|
bye_message = messages.get_message(get_first_name(message), "BYE_MESSAGE")
|
||||||
await message.answer(bye_message, reply_markup=markup)
|
await message.answer(bye_message, reply_markup=markup)
|
||||||
await state.set_state(FSM_STATES["START"])
|
await state.set_state(FSM_STATES["START"])
|
||||||
|
|
||||||
@error_handler
|
@error_handler
|
||||||
@track_errors("private_handlers", "suggest_router")
|
@track_errors("private_handlers", "suggest_router")
|
||||||
@track_time("suggest_router", "private_handlers")
|
@track_time("suggest_router", "private_handlers")
|
||||||
async def suggest_router(self, message: types.Message, state: FSMContext, album: list = None, **kwargs):
|
async def suggest_router(
|
||||||
"""Handle post submission in suggest state"""
|
self, message: types.Message, state: FSMContext, album: list = None, **kwargs
|
||||||
# Post service operations with metrics
|
):
|
||||||
await self.user_service.update_user_activity(message.from_user.id)
|
"""Handle post submission in suggest state - сразу отвечает пользователю, обработка в фоне"""
|
||||||
await self.user_service.log_user_message(message)
|
# Сразу отвечаем пользователю
|
||||||
await self.post_service.process_post(message, album)
|
|
||||||
|
|
||||||
# Send success message and return to start state
|
|
||||||
markup_for_user = await get_reply_keyboard(self.db, message.from_user.id)
|
markup_for_user = await get_reply_keyboard(self.db, message.from_user.id)
|
||||||
success_send_message = messages.get_message(get_first_name(message), 'SUCCESS_SEND_MESSAGE')
|
success_send_message = messages.get_message(
|
||||||
|
get_first_name(message), "SUCCESS_SEND_MESSAGE"
|
||||||
|
)
|
||||||
await message.answer(success_send_message, reply_markup=markup_for_user)
|
await message.answer(success_send_message, reply_markup=markup_for_user)
|
||||||
await state.set_state(FSM_STATES["START"])
|
await state.set_state(FSM_STATES["START"])
|
||||||
|
|
||||||
|
# Проверяем, есть ли механизм для получения полной медиагруппы (для медиагрупп)
|
||||||
|
album_getter = kwargs.get("album_getter")
|
||||||
|
|
||||||
|
# В фоне обрабатываем пост
|
||||||
|
async def process_post_background():
|
||||||
|
try:
|
||||||
|
# Обновляем активность пользователя
|
||||||
|
await self.user_service.update_user_activity(message.from_user.id)
|
||||||
|
|
||||||
|
# Логируем сообщение (только для одиночных сообщений, не медиагрупп)
|
||||||
|
if message.media_group_id is None:
|
||||||
|
await self.user_service.log_user_message(message)
|
||||||
|
|
||||||
|
# Для медиагрупп ждем полную медиагруппу
|
||||||
|
if album_getter and message.media_group_id:
|
||||||
|
full_album = await album_getter.get_album(timeout=10.0)
|
||||||
|
if full_album:
|
||||||
|
await self.post_service.process_post(message, full_album)
|
||||||
|
else:
|
||||||
|
# Обычное сообщение или медиагруппа уже собрана
|
||||||
|
await self.post_service.process_post(message, album)
|
||||||
|
except Exception as e:
|
||||||
|
from logs.custom_logger import logger
|
||||||
|
|
||||||
|
logger.error(f"Ошибка при фоновой обработке поста: {e}")
|
||||||
|
|
||||||
|
asyncio.create_task(process_post_background())
|
||||||
|
|
||||||
@error_handler
|
@error_handler
|
||||||
@track_errors("private_handlers", "stickers")
|
@track_errors("private_handlers", "stickers")
|
||||||
@track_time("stickers", "private_handlers")
|
@track_time("stickers", "private_handlers")
|
||||||
@@ -179,41 +273,67 @@ class PrivateHandlers:
|
|||||||
markup = await get_reply_keyboard(self.db, message.from_user.id)
|
markup = await get_reply_keyboard(self.db, message.from_user.id)
|
||||||
await self.db.update_stickers_info(message.from_user.id)
|
await self.db.update_stickers_info(message.from_user.id)
|
||||||
await self.user_service.log_user_message(message)
|
await self.user_service.log_user_message(message)
|
||||||
await message.answer(
|
await message.answer(text=ERROR_MESSAGES["STICKERS_LINK"], reply_markup=markup)
|
||||||
text=ERROR_MESSAGES["STICKERS_LINK"],
|
|
||||||
reply_markup=markup
|
|
||||||
)
|
|
||||||
await state.set_state(FSM_STATES["START"])
|
await state.set_state(FSM_STATES["START"])
|
||||||
|
|
||||||
@error_handler
|
@error_handler
|
||||||
@track_errors("private_handlers", "connect_with_admin")
|
@track_errors("private_handlers", "connect_with_admin")
|
||||||
@track_time("connect_with_admin", "private_handlers")
|
@track_time("connect_with_admin", "private_handlers")
|
||||||
async def connect_with_admin(self, message: types.Message, state: FSMContext, **kwargs):
|
async def connect_with_admin(
|
||||||
|
self, message: types.Message, state: FSMContext, **kwargs
|
||||||
|
):
|
||||||
"""Handle connect with admin button"""
|
"""Handle connect with admin button"""
|
||||||
# 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)
|
||||||
admin_message = messages.get_message(get_first_name(message), 'CONNECT_WITH_ADMIN')
|
admin_message = messages.get_message(
|
||||||
|
get_first_name(message), "CONNECT_WITH_ADMIN"
|
||||||
|
)
|
||||||
await message.answer(admin_message, parse_mode="html")
|
await message.answer(admin_message, parse_mode="html")
|
||||||
await self.user_service.log_user_message(message)
|
await self.user_service.log_user_message(message)
|
||||||
await state.set_state(FSM_STATES["PRE_CHAT"])
|
await state.set_state(FSM_STATES["PRE_CHAT"])
|
||||||
|
|
||||||
@error_handler
|
@error_handler
|
||||||
@track_errors("private_handlers", "resend_message_in_group_for_message")
|
@track_errors("private_handlers", "resend_message_in_group_for_message")
|
||||||
@track_time("resend_message_in_group_for_message", "private_handlers")
|
@track_time("resend_message_in_group_for_message", "private_handlers")
|
||||||
@db_query_time("resend_message_in_group_for_message", "messages", "insert")
|
@db_query_time("resend_message_in_group_for_message", "messages", "insert")
|
||||||
async def resend_message_in_group_for_message(self, message: types.Message, state: FSMContext, **kwargs):
|
async def resend_message_in_group_for_message(
|
||||||
|
self, message: types.Message, state: FSMContext, **kwargs
|
||||||
|
):
|
||||||
"""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())
|
||||||
await self.db.add_message(message.text, message.from_user.id, message.message_id + 1, date)
|
|
||||||
|
# Сохраняем message_id из результата send_message
|
||||||
question = messages.get_message(get_first_name(message), 'QUESTION')
|
await self.db.add_message(
|
||||||
|
message.text, message.from_user.id, sent_message.message_id, date
|
||||||
|
)
|
||||||
|
|
||||||
|
question = messages.get_message(get_first_name(message), "QUESTION")
|
||||||
user_state = await state.get_state()
|
user_state = await state.get_state()
|
||||||
|
|
||||||
if user_state == FSM_STATES["PRE_CHAT"]:
|
if user_state == FSM_STATES["PRE_CHAT"]:
|
||||||
markup = await get_reply_keyboard(self.db, message.from_user.id)
|
markup = await get_reply_keyboard(self.db, message.from_user.id)
|
||||||
await message.answer(question, reply_markup=markup)
|
await message.answer(question, reply_markup=markup)
|
||||||
@@ -224,39 +344,52 @@ class PrivateHandlers:
|
|||||||
|
|
||||||
|
|
||||||
# Factory function to create handlers with dependencies
|
# Factory function to create handlers with dependencies
|
||||||
def create_private_handlers(db: AsyncBotDB, settings: BotSettings) -> PrivateHandlers:
|
def create_private_handlers(
|
||||||
|
db: AsyncBotDB, settings: BotSettings, s3_storage=None, scoring_manager=None
|
||||||
|
) -> PrivateHandlers:
|
||||||
"""Create private handlers instance with dependencies"""
|
"""Create private handlers instance with dependencies"""
|
||||||
return PrivateHandlers(db, settings)
|
return PrivateHandlers(db, settings, s3_storage, scoring_manager)
|
||||||
|
|
||||||
|
|
||||||
# Legacy router for backward compatibility
|
# Legacy router for backward compatibility
|
||||||
private_router = Router()
|
private_router = Router()
|
||||||
|
|
||||||
|
# Флаг инициализации для защиты от повторного вызова
|
||||||
|
_legacy_router_initialized = False
|
||||||
|
|
||||||
|
|
||||||
# Initialize with global dependencies (for backward compatibility)
|
# Initialize with global dependencies (for backward compatibility)
|
||||||
def init_legacy_router():
|
def init_legacy_router():
|
||||||
"""Initialize legacy router with global dependencies"""
|
"""Initialize legacy router with global dependencies"""
|
||||||
global private_router
|
global private_router, _legacy_router_initialized
|
||||||
|
|
||||||
|
if _legacy_router_initialized:
|
||||||
|
return
|
||||||
|
|
||||||
from helper_bot.utils.base_dependency_factory import get_global_instance
|
from helper_bot.utils.base_dependency_factory import get_global_instance
|
||||||
|
|
||||||
bdf = get_global_instance()
|
bdf = get_global_instance()
|
||||||
settings = BotSettings(
|
settings = BotSettings(
|
||||||
group_for_posts=bdf.settings['Telegram']['group_for_posts'],
|
group_for_posts=bdf.settings["Telegram"]["group_for_posts"],
|
||||||
group_for_message=bdf.settings['Telegram']['group_for_message'],
|
group_for_message=bdf.settings["Telegram"]["group_for_message"],
|
||||||
main_public=bdf.settings['Telegram']['main_public'],
|
main_public=bdf.settings["Telegram"]["main_public"],
|
||||||
group_for_logs=bdf.settings['Telegram']['group_for_logs'],
|
group_for_logs=bdf.settings["Telegram"]["group_for_logs"],
|
||||||
important_logs=bdf.settings['Telegram']['important_logs'],
|
important_logs=bdf.settings["Telegram"]["important_logs"],
|
||||||
preview_link=bdf.settings['Telegram']['preview_link'],
|
preview_link=bdf.settings["Telegram"]["preview_link"],
|
||||||
logs=bdf.settings['Settings']['logs'],
|
logs=bdf.settings["Settings"]["logs"],
|
||||||
test=bdf.settings['Settings']['test']
|
test=bdf.settings["Settings"]["test"],
|
||||||
)
|
)
|
||||||
|
|
||||||
db = bdf.get_db()
|
db = bdf.get_db()
|
||||||
handlers = create_private_handlers(db, settings)
|
s3_storage = bdf.get_s3_storage()
|
||||||
|
scoring_manager = bdf.get_scoring_manager()
|
||||||
|
handlers = create_private_handlers(db, settings, s3_storage, scoring_manager)
|
||||||
|
|
||||||
# Instead of trying to copy handlers, we'll use the new router directly
|
# Instead of trying to copy handlers, we'll use the new router directly
|
||||||
# This maintains backward compatibility while using the new architecture
|
# This maintains backward compatibility while using the new architecture
|
||||||
private_router = handlers.router
|
private_router = handlers.router
|
||||||
|
_legacy_router_initialized = True
|
||||||
|
|
||||||
|
|
||||||
# Initialize legacy router
|
# Initialize legacy router
|
||||||
init_legacy_router()
|
init_legacy_router()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,118 +1,134 @@
|
|||||||
"""
|
"""
|
||||||
Утилиты для очистки и диагностики проблем с голосовыми файлами
|
Утилиты для очистки и диагностики проблем с голосовыми файлами
|
||||||
"""
|
"""
|
||||||
import os
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Tuple
|
from typing import List, Tuple
|
||||||
from logs.custom_logger import logger
|
|
||||||
from helper_bot.handlers.voice.constants import VOICE_USERS_DIR
|
from helper_bot.handlers.voice.constants import VOICE_USERS_DIR
|
||||||
|
from logs.custom_logger import logger
|
||||||
|
|
||||||
|
|
||||||
class VoiceFileCleanupUtils:
|
class VoiceFileCleanupUtils:
|
||||||
"""Утилиты для очистки и диагностики голосовых файлов"""
|
"""Утилиты для очистки и диагностики голосовых файлов"""
|
||||||
|
|
||||||
def __init__(self, bot_db):
|
def __init__(self, bot_db):
|
||||||
self.bot_db = bot_db
|
self.bot_db = bot_db
|
||||||
|
|
||||||
async def find_orphaned_db_records(self) -> List[Tuple[str, int]]:
|
async def find_orphaned_db_records(self) -> List[Tuple[str, int]]:
|
||||||
"""Найти записи в БД, для которых нет соответствующих файлов"""
|
"""Найти записи в БД, для которых нет соответствующих файлов"""
|
||||||
try:
|
try:
|
||||||
# Получаем все записи из БД
|
# Получаем все записи из БД
|
||||||
all_audio_records = await self.bot_db.get_all_audio_records()
|
all_audio_records = await self.bot_db.get_all_audio_records()
|
||||||
orphaned_records = []
|
orphaned_records = []
|
||||||
|
|
||||||
for record in all_audio_records:
|
for record in all_audio_records:
|
||||||
file_name = record.get('file_name', '')
|
file_name = record.get("file_name", "")
|
||||||
user_id = record.get('author_id', 0)
|
user_id = record.get("author_id", 0)
|
||||||
|
|
||||||
file_path = f'{VOICE_USERS_DIR}/{file_name}.ogg'
|
file_path = f"{VOICE_USERS_DIR}/{file_name}.ogg"
|
||||||
if not os.path.exists(file_path):
|
if not os.path.exists(file_path):
|
||||||
orphaned_records.append((file_name, user_id))
|
orphaned_records.append((file_name, user_id))
|
||||||
logger.warning(f"Найдена запись в БД без файла: {file_name} (user_id: {user_id})")
|
logger.warning(
|
||||||
|
f"Найдена запись в БД без файла: {file_name} (user_id: {user_id})"
|
||||||
logger.info(f"Найдено {len(orphaned_records)} записей в БД без соответствующих файлов")
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Найдено {len(orphaned_records)} записей в БД без соответствующих файлов"
|
||||||
|
)
|
||||||
return orphaned_records
|
return orphaned_records
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при поиске orphaned записей: {e}")
|
logger.error(f"Ошибка при поиске orphaned записей: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
async def find_orphaned_files(self) -> List[str]:
|
async def find_orphaned_files(self) -> List[str]:
|
||||||
"""Найти файлы на диске, для которых нет записей в БД"""
|
"""Найти файлы на диске, для которых нет записей в БД"""
|
||||||
try:
|
try:
|
||||||
if not os.path.exists(VOICE_USERS_DIR):
|
if not os.path.exists(VOICE_USERS_DIR):
|
||||||
logger.warning(f"Директория {VOICE_USERS_DIR} не существует")
|
logger.warning(f"Директория {VOICE_USERS_DIR} не существует")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Получаем все файлы .ogg в директории
|
# Получаем все файлы .ogg в директории
|
||||||
ogg_files = list(Path(VOICE_USERS_DIR).glob("*.ogg"))
|
ogg_files = list(Path(VOICE_USERS_DIR).glob("*.ogg"))
|
||||||
orphaned_files = []
|
orphaned_files = []
|
||||||
|
|
||||||
# Получаем все записи из БД
|
# Получаем все записи из БД
|
||||||
all_audio_records = await self.bot_db.get_all_audio_records()
|
all_audio_records = await self.bot_db.get_all_audio_records()
|
||||||
db_file_names = {record.get('file_name', '') for record in all_audio_records}
|
db_file_names = {
|
||||||
|
record.get("file_name", "") for record in all_audio_records
|
||||||
|
}
|
||||||
|
|
||||||
for file_path in ogg_files:
|
for file_path in ogg_files:
|
||||||
file_name = file_path.stem # Имя файла без расширения
|
file_name = file_path.stem # Имя файла без расширения
|
||||||
if file_name not in db_file_names:
|
if file_name not in db_file_names:
|
||||||
orphaned_files.append(str(file_path))
|
orphaned_files.append(str(file_path))
|
||||||
logger.warning(f"Найден файл без записи в БД: {file_path}")
|
logger.warning(f"Найден файл без записи в БД: {file_path}")
|
||||||
|
|
||||||
logger.info(f"Найдено {len(orphaned_files)} файлов без записей в БД")
|
logger.info(f"Найдено {len(orphaned_files)} файлов без записей в БД")
|
||||||
return orphaned_files
|
return orphaned_files
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при поиске orphaned файлов: {e}")
|
logger.error(f"Ошибка при поиске orphaned файлов: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
async def cleanup_orphaned_db_records(self, dry_run: bool = True) -> int:
|
async def cleanup_orphaned_db_records(self, dry_run: bool = True) -> int:
|
||||||
"""Удалить записи в БД, для которых нет файлов"""
|
"""Удалить записи в БД, для которых нет файлов"""
|
||||||
try:
|
try:
|
||||||
orphaned_records = await self.find_orphaned_db_records()
|
orphaned_records = await self.find_orphaned_db_records()
|
||||||
|
|
||||||
if not orphaned_records:
|
if not orphaned_records:
|
||||||
logger.info("Нет orphaned записей для удаления")
|
logger.info("Нет orphaned записей для удаления")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
logger.info(f"DRY RUN: Найдено {len(orphaned_records)} записей для удаления")
|
logger.info(
|
||||||
|
f"DRY RUN: Найдено {len(orphaned_records)} записей для удаления"
|
||||||
|
)
|
||||||
for file_name, user_id in orphaned_records:
|
for file_name, user_id in orphaned_records:
|
||||||
logger.info(f"DRY RUN: Будет удалена запись: {file_name} (user_id: {user_id})")
|
logger.info(
|
||||||
|
f"DRY RUN: Будет удалена запись: {file_name} (user_id: {user_id})"
|
||||||
|
)
|
||||||
return len(orphaned_records)
|
return len(orphaned_records)
|
||||||
|
|
||||||
# Удаляем записи
|
# Удаляем записи
|
||||||
deleted_count = 0
|
deleted_count = 0
|
||||||
for file_name, user_id in orphaned_records:
|
for file_name, user_id in orphaned_records:
|
||||||
try:
|
try:
|
||||||
await self.bot_db.delete_audio_record_by_file_name(file_name)
|
await self.bot_db.delete_audio_record_by_file_name(file_name)
|
||||||
deleted_count += 1
|
deleted_count += 1
|
||||||
logger.info(f"Удалена запись в БД: {file_name} (user_id: {user_id})")
|
logger.info(
|
||||||
|
f"Удалена запись в БД: {file_name} (user_id: {user_id})"
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при удалении записи {file_name}: {e}")
|
logger.error(f"Ошибка при удалении записи {file_name}: {e}")
|
||||||
|
|
||||||
logger.info(f"Удалено {deleted_count} orphaned записей из БД")
|
logger.info(f"Удалено {deleted_count} orphaned записей из БД")
|
||||||
return deleted_count
|
return deleted_count
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при очистке orphaned записей: {e}")
|
logger.error(f"Ошибка при очистке orphaned записей: {e}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
async def cleanup_orphaned_files(self, dry_run: bool = True) -> int:
|
async def cleanup_orphaned_files(self, dry_run: bool = True) -> int:
|
||||||
"""Удалить файлы на диске, для которых нет записей в БД"""
|
"""Удалить файлы на диске, для которых нет записей в БД"""
|
||||||
try:
|
try:
|
||||||
orphaned_files = await self.find_orphaned_files()
|
orphaned_files = await self.find_orphaned_files()
|
||||||
|
|
||||||
if not orphaned_files:
|
if not orphaned_files:
|
||||||
logger.info("Нет orphaned файлов для удаления")
|
logger.info("Нет orphaned файлов для удаления")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
logger.info(f"DRY RUN: Найдено {len(orphaned_files)} файлов для удаления")
|
logger.info(
|
||||||
|
f"DRY RUN: Найдено {len(orphaned_files)} файлов для удаления"
|
||||||
|
)
|
||||||
for file_path in orphaned_files:
|
for file_path in orphaned_files:
|
||||||
logger.info(f"DRY RUN: Будет удален файл: {file_path}")
|
logger.info(f"DRY RUN: Будет удален файл: {file_path}")
|
||||||
return len(orphaned_files)
|
return len(orphaned_files)
|
||||||
|
|
||||||
# Удаляем файлы
|
# Удаляем файлы
|
||||||
deleted_count = 0
|
deleted_count = 0
|
||||||
for file_path in orphaned_files:
|
for file_path in orphaned_files:
|
||||||
@@ -122,70 +138,76 @@ class VoiceFileCleanupUtils:
|
|||||||
logger.info(f"Удален файл: {file_path}")
|
logger.info(f"Удален файл: {file_path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при удалении файла {file_path}: {e}")
|
logger.error(f"Ошибка при удалении файла {file_path}: {e}")
|
||||||
|
|
||||||
logger.info(f"Удалено {deleted_count} orphaned файлов")
|
logger.info(f"Удалено {deleted_count} orphaned файлов")
|
||||||
return deleted_count
|
return deleted_count
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при очистке orphaned файлов: {e}")
|
logger.error(f"Ошибка при очистке orphaned файлов: {e}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
async def get_disk_usage_stats(self) -> dict:
|
async def get_disk_usage_stats(self) -> dict:
|
||||||
"""Получить статистику использования диска"""
|
"""Получить статистику использования диска"""
|
||||||
try:
|
try:
|
||||||
if not os.path.exists(VOICE_USERS_DIR):
|
if not os.path.exists(VOICE_USERS_DIR):
|
||||||
return {"error": f"Директория {VOICE_USERS_DIR} не существует"}
|
return {"error": f"Директория {VOICE_USERS_DIR} не существует"}
|
||||||
|
|
||||||
total_size = 0
|
total_size = 0
|
||||||
file_count = 0
|
file_count = 0
|
||||||
|
|
||||||
for file_path in Path(VOICE_USERS_DIR).glob("*.ogg"):
|
for file_path in Path(VOICE_USERS_DIR).glob("*.ogg"):
|
||||||
if file_path.is_file():
|
if file_path.is_file():
|
||||||
total_size += file_path.stat().st_size
|
total_size += file_path.stat().st_size
|
||||||
file_count += 1
|
file_count += 1
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total_files": file_count,
|
"total_files": file_count,
|
||||||
"total_size_bytes": total_size,
|
"total_size_bytes": total_size,
|
||||||
"total_size_mb": round(total_size / (1024 * 1024), 2),
|
"total_size_mb": round(total_size / (1024 * 1024), 2),
|
||||||
"directory": VOICE_USERS_DIR
|
"directory": VOICE_USERS_DIR,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при получении статистики диска: {e}")
|
logger.error(f"Ошибка при получении статистики диска: {e}")
|
||||||
return {"error": str(e)}
|
return {"error": str(e)}
|
||||||
|
|
||||||
async def run_full_diagnostic(self) -> dict:
|
async def run_full_diagnostic(self) -> dict:
|
||||||
"""Запустить полную диагностику"""
|
"""Запустить полную диагностику"""
|
||||||
try:
|
try:
|
||||||
logger.info("Запуск полной диагностики голосовых файлов...")
|
logger.info("Запуск полной диагностики голосовых файлов...")
|
||||||
|
|
||||||
# Статистика диска
|
# Статистика диска
|
||||||
disk_stats = await self.get_disk_usage_stats()
|
disk_stats = await self.get_disk_usage_stats()
|
||||||
|
|
||||||
# Orphaned записи в БД
|
# Orphaned записи в БД
|
||||||
orphaned_db_records = await self.find_orphaned_db_records()
|
orphaned_db_records = await self.find_orphaned_db_records()
|
||||||
|
|
||||||
# Orphaned файлы
|
# Orphaned файлы
|
||||||
orphaned_files = await self.find_orphaned_files()
|
orphaned_files = await self.find_orphaned_files()
|
||||||
|
|
||||||
# Количество записей в БД
|
# Количество записей в БД
|
||||||
all_audio_records = await self.bot_db.get_all_audio_records()
|
all_audio_records = await self.bot_db.get_all_audio_records()
|
||||||
db_records_count = len(all_audio_records)
|
db_records_count = len(all_audio_records)
|
||||||
|
|
||||||
diagnostic_result = {
|
diagnostic_result = {
|
||||||
"disk_stats": disk_stats,
|
"disk_stats": disk_stats,
|
||||||
"db_records_count": db_records_count,
|
"db_records_count": db_records_count,
|
||||||
"orphaned_db_records_count": len(orphaned_db_records),
|
"orphaned_db_records_count": len(orphaned_db_records),
|
||||||
"orphaned_files_count": len(orphaned_files),
|
"orphaned_files_count": len(orphaned_files),
|
||||||
"orphaned_db_records": orphaned_db_records[:10], # Первые 10 для примера
|
"orphaned_db_records": orphaned_db_records[
|
||||||
|
:10
|
||||||
|
], # Первые 10 для примера
|
||||||
"orphaned_files": orphaned_files[:10], # Первые 10 для примера
|
"orphaned_files": orphaned_files[:10], # Первые 10 для примера
|
||||||
"status": "healthy" if len(orphaned_db_records) == 0 and len(orphaned_files) == 0 else "issues_found"
|
"status": (
|
||||||
|
"healthy"
|
||||||
|
if len(orphaned_db_records) == 0 and len(orphaned_files) == 0
|
||||||
|
else "issues_found"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(f"Диагностика завершена. Статус: {diagnostic_result['status']}")
|
logger.info(f"Диагностика завершена. Статус: {diagnostic_result['status']}")
|
||||||
return diagnostic_result
|
return diagnostic_result
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при диагностике: {e}")
|
logger.error(f"Ошибка при диагностике: {e}")
|
||||||
return {"error": str(e)}
|
return {"error": str(e)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from typing import Final, Dict
|
from typing import Dict, Final
|
||||||
|
|
||||||
# Voice bot constants
|
# Voice bot constants
|
||||||
VOICE_BOT_NAME = "voice"
|
VOICE_BOT_NAME = "voice"
|
||||||
@@ -17,10 +17,10 @@ CMD_REFRESH = "refresh"
|
|||||||
# Command to command mapping for metrics
|
# Command to command mapping for metrics
|
||||||
COMMAND_MAPPING: Final[Dict[str, str]] = {
|
COMMAND_MAPPING: Final[Dict[str, str]] = {
|
||||||
"start": "voice_start",
|
"start": "voice_start",
|
||||||
"help": "voice_help",
|
"help": "voice_help",
|
||||||
"restart": "voice_restart",
|
"restart": "voice_restart",
|
||||||
"emoji": "voice_emoji",
|
"emoji": "voice_emoji",
|
||||||
"refresh": "voice_refresh"
|
"refresh": "voice_refresh",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Button texts
|
# Button texts
|
||||||
@@ -33,7 +33,7 @@ BUTTON_COMMAND_MAPPING: Final[Dict[str, str]] = {
|
|||||||
"🎧Послушать": "voice_listen",
|
"🎧Послушать": "voice_listen",
|
||||||
"Отменить": "voice_cancel",
|
"Отменить": "voice_cancel",
|
||||||
"🔄Сбросить прослушивания": "voice_refresh_listen",
|
"🔄Сбросить прослушивания": "voice_refresh_listen",
|
||||||
"😊Узнать эмодзи": "voice_emoji"
|
"😊Узнать эмодзи": "voice_emoji",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Callback data
|
# Callback data
|
||||||
@@ -43,7 +43,7 @@ CALLBACK_DELETE = "delete"
|
|||||||
# Callback to command mapping for metrics
|
# Callback to command mapping for metrics
|
||||||
CALLBACK_COMMAND_MAPPING: Final[Dict[str, str]] = {
|
CALLBACK_COMMAND_MAPPING: Final[Dict[str, str]] = {
|
||||||
"save": "voice_save",
|
"save": "voice_save",
|
||||||
"delete": "voice_delete"
|
"delete": "voice_delete",
|
||||||
}
|
}
|
||||||
|
|
||||||
# File paths
|
# File paths
|
||||||
|
|||||||
@@ -1,23 +1,28 @@
|
|||||||
class VoiceBotError(Exception):
|
class VoiceBotError(Exception):
|
||||||
"""Базовое исключение для voice_bot"""
|
"""Базовое исключение для voice_bot"""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class VoiceMessageError(VoiceBotError):
|
class VoiceMessageError(VoiceBotError):
|
||||||
"""Ошибка при работе с голосовыми сообщениями"""
|
"""Ошибка при работе с голосовыми сообщениями"""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class AudioProcessingError(VoiceBotError):
|
class AudioProcessingError(VoiceBotError):
|
||||||
"""Ошибка при обработке аудио"""
|
"""Ошибка при обработке аудио"""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class DatabaseError(VoiceBotError):
|
class DatabaseError(VoiceBotError):
|
||||||
"""Ошибка базы данных"""
|
"""Ошибка базы данных"""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class FileOperationError(VoiceBotError):
|
class FileOperationError(VoiceBotError):
|
||||||
"""Ошибка при работе с файлами"""
|
"""Ошибка при работе с файлами"""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -1,42 +1,54 @@
|
|||||||
import random
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import traceback
|
|
||||||
import os
|
import os
|
||||||
|
import random
|
||||||
|
import traceback
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional, Tuple
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
from aiogram.types import FSInputFile
|
from aiogram.types import FSInputFile
|
||||||
|
|
||||||
from helper_bot.handlers.voice.exceptions import VoiceMessageError, AudioProcessingError, DatabaseError, FileOperationError
|
|
||||||
from helper_bot.handlers.voice.constants import (
|
from helper_bot.handlers.voice.constants import (
|
||||||
VOICE_USERS_DIR, STICK_DIR, STICK_PATTERN, STICKER_DELAY,
|
MESSAGE_DELAY_1,
|
||||||
MESSAGE_DELAY_1, MESSAGE_DELAY_2, MESSAGE_DELAY_3, MESSAGE_DELAY_4
|
MESSAGE_DELAY_2,
|
||||||
|
MESSAGE_DELAY_3,
|
||||||
|
MESSAGE_DELAY_4,
|
||||||
|
STICK_DIR,
|
||||||
|
STICK_PATTERN,
|
||||||
|
STICKER_DELAY,
|
||||||
|
VOICE_USERS_DIR,
|
||||||
|
)
|
||||||
|
from helper_bot.handlers.voice.exceptions import (
|
||||||
|
AudioProcessingError,
|
||||||
|
DatabaseError,
|
||||||
|
FileOperationError,
|
||||||
|
VoiceMessageError,
|
||||||
)
|
)
|
||||||
from logs.custom_logger import logger
|
|
||||||
|
|
||||||
# Local imports - metrics
|
# Local imports - metrics
|
||||||
from helper_bot.utils.metrics import (
|
from helper_bot.utils.metrics import db_query_time, track_errors, track_time
|
||||||
track_time,
|
from logs.custom_logger import logger
|
||||||
track_errors,
|
|
||||||
db_query_time
|
|
||||||
)
|
|
||||||
|
|
||||||
class VoiceMessage:
|
class VoiceMessage:
|
||||||
"""Модель голосового сообщения"""
|
"""Модель голосового сообщения"""
|
||||||
def __init__(self, file_name: str, user_id: int, date_added: datetime, file_id: int):
|
|
||||||
|
def __init__(
|
||||||
|
self, file_name: str, user_id: int, date_added: datetime, file_id: int
|
||||||
|
):
|
||||||
self.file_name = file_name
|
self.file_name = file_name
|
||||||
self.user_id = user_id
|
self.user_id = user_id
|
||||||
self.date_added = date_added
|
self.date_added = date_added
|
||||||
self.file_id = file_id
|
self.file_id = file_id
|
||||||
|
|
||||||
|
|
||||||
class VoiceBotService:
|
class VoiceBotService:
|
||||||
"""Сервис для работы с голосовыми сообщениями"""
|
"""Сервис для работы с голосовыми сообщениями"""
|
||||||
|
|
||||||
def __init__(self, bot_db, settings):
|
def __init__(self, bot_db, settings):
|
||||||
self.bot_db = bot_db
|
self.bot_db = bot_db
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
|
|
||||||
@track_time("get_welcome_sticker", "voice_bot_service")
|
@track_time("get_welcome_sticker", "voice_bot_service")
|
||||||
@track_errors("voice_bot_service", "get_welcome_sticker")
|
@track_errors("voice_bot_service", "get_welcome_sticker")
|
||||||
async def get_welcome_sticker(self) -> Optional[FSInputFile]:
|
async def get_welcome_sticker(self) -> Optional[FSInputFile]:
|
||||||
@@ -45,17 +57,21 @@ class VoiceBotService:
|
|||||||
name_stick_hello = list(Path(STICK_DIR).rglob(STICK_PATTERN))
|
name_stick_hello = list(Path(STICK_DIR).rglob(STICK_PATTERN))
|
||||||
if not name_stick_hello:
|
if not name_stick_hello:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
random_stick_hello = random.choice(name_stick_hello)
|
random_stick_hello = random.choice(name_stick_hello)
|
||||||
random_stick_hello = FSInputFile(path=random_stick_hello)
|
random_stick_hello = FSInputFile(path=random_stick_hello)
|
||||||
logger.info(f"Стикер успешно получен. Наименование стикера: {random_stick_hello}")
|
logger.info(
|
||||||
|
f"Стикер успешно получен. Наименование стикера: {random_stick_hello}"
|
||||||
|
)
|
||||||
return random_stick_hello
|
return random_stick_hello
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при получении стикера: {e}")
|
logger.error(f"Ошибка при получении стикера: {e}")
|
||||||
if self.settings['Settings']['logs']:
|
if self.settings["Settings"]["logs"]:
|
||||||
await self._send_error_to_logs(f'Отправка приветственных стикеров лажает. Ошибка: {e}')
|
await self._send_error_to_logs(
|
||||||
|
f"Отправка приветственных стикеров лажает. Ошибка: {e}"
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@track_time("send_welcome_messages", "voice_bot_service")
|
@track_time("send_welcome_messages", "voice_bot_service")
|
||||||
@track_errors("voice_bot_service", "send_welcome_messages")
|
@track_errors("voice_bot_service", "send_welcome_messages")
|
||||||
async def send_welcome_messages(self, message, user_emoji: str):
|
async def send_welcome_messages(self, message, user_emoji: str):
|
||||||
@@ -66,92 +82,94 @@ class VoiceBotService:
|
|||||||
if sticker:
|
if sticker:
|
||||||
await message.answer_sticker(sticker)
|
await message.answer_sticker(sticker)
|
||||||
await asyncio.sleep(STICKER_DELAY)
|
await asyncio.sleep(STICKER_DELAY)
|
||||||
|
|
||||||
# Отправляем приветственное сообщение
|
# Отправляем приветственное сообщение
|
||||||
markup = self._get_main_keyboard()
|
markup = self._get_main_keyboard()
|
||||||
await message.answer(
|
await message.answer(
|
||||||
text="<b>Привет.</b>",
|
text="<b>Привет.</b>",
|
||||||
parse_mode='html',
|
parse_mode="html",
|
||||||
reply_markup=markup,
|
reply_markup=markup,
|
||||||
disable_web_page_preview=not self.settings['Telegram']['preview_link']
|
disable_web_page_preview=not self.settings["Telegram"]["preview_link"],
|
||||||
)
|
)
|
||||||
await asyncio.sleep(STICKER_DELAY)
|
await asyncio.sleep(STICKER_DELAY)
|
||||||
|
|
||||||
# Отправляем описание
|
# Отправляем описание
|
||||||
await message.answer(
|
await message.answer(
|
||||||
text="<i>Здесь можно послушать голосовые сообщения от совершенно незнакомых людей из Бийска</i>",
|
text="<i>Здесь можно послушать голосовые сообщения от совершенно незнакомых людей из Бийска</i>",
|
||||||
parse_mode='html',
|
parse_mode="html",
|
||||||
reply_markup=markup,
|
reply_markup=markup,
|
||||||
disable_web_page_preview=not self.settings['Telegram']['preview_link']
|
disable_web_page_preview=not self.settings["Telegram"]["preview_link"],
|
||||||
)
|
)
|
||||||
await asyncio.sleep(MESSAGE_DELAY_1)
|
await asyncio.sleep(MESSAGE_DELAY_1)
|
||||||
|
|
||||||
# Отправляем аналогию
|
# Отправляем аналогию
|
||||||
await message.answer(
|
await message.answer(
|
||||||
text="Это почти как написать письмо, положить его в бутылку и швырнуть в океан. Никогда не узнаешь, послушал его кто-то или нет и ответить тоже не получится..",
|
text="Это почти как написать письмо, положить его в бутылку и швырнуть в океан. Никогда не узнаешь, послушал его кто-то или нет и ответить тоже не получится..",
|
||||||
parse_mode='html',
|
parse_mode="html",
|
||||||
reply_markup=markup,
|
reply_markup=markup,
|
||||||
disable_web_page_preview=not self.settings['Telegram']['preview_link']
|
disable_web_page_preview=not self.settings["Telegram"]["preview_link"],
|
||||||
)
|
)
|
||||||
await asyncio.sleep(MESSAGE_DELAY_2)
|
await asyncio.sleep(MESSAGE_DELAY_2)
|
||||||
|
|
||||||
# Отправляем правила
|
# Отправляем правила
|
||||||
await message.answer(
|
await message.answer(
|
||||||
text="Записывать можно всё что угодно — никаких правил нет. Главное — твой голос, <i>хотя бы на 5-10 секунд</i>",
|
text="Записывать можно всё что угодно — никаких правил нет. Главное — твой голос, <i>хотя бы на 5-10 секунд</i>",
|
||||||
parse_mode='html',
|
parse_mode="html",
|
||||||
reply_markup=markup,
|
reply_markup=markup,
|
||||||
disable_web_page_preview=not self.settings['Telegram']['preview_link']
|
disable_web_page_preview=not self.settings["Telegram"]["preview_link"],
|
||||||
)
|
)
|
||||||
await asyncio.sleep(MESSAGE_DELAY_3)
|
await asyncio.sleep(MESSAGE_DELAY_3)
|
||||||
|
|
||||||
# Отправляем информацию об анонимности
|
# Отправляем информацию об анонимности
|
||||||
await message.answer(
|
await message.answer(
|
||||||
text="Здесь всё анонимно: тот, кому я отправлю твое сообщение, не узнает ни твое имя, ни твой аккаунт (так что можно не стесняться говорить то, что не стал(а) бы выкладывать в собственные соцсети)",
|
text="Здесь всё анонимно: тот, кому я отправлю твое сообщение, не узнает ни твое имя, ни твой аккаунт (так что можно не стесняться говорить то, что не стал(а) бы выкладывать в собственные соцсети)",
|
||||||
parse_mode='html',
|
parse_mode="html",
|
||||||
reply_markup=markup,
|
reply_markup=markup,
|
||||||
disable_web_page_preview=not self.settings['Telegram']['preview_link']
|
disable_web_page_preview=not self.settings["Telegram"]["preview_link"],
|
||||||
)
|
)
|
||||||
await asyncio.sleep(MESSAGE_DELAY_4)
|
await asyncio.sleep(MESSAGE_DELAY_4)
|
||||||
|
|
||||||
# Отправляем предложения
|
# Отправляем предложения
|
||||||
await message.answer(
|
await message.answer(
|
||||||
text="Если не знаешь, что сказать, можешь просто прочитать любое текстовое сообщение из недавно полученных или отправленных (или спеть, рассказать стихотворенье)",
|
text="Если не знаешь, что сказать, можешь просто прочитать любое текстовое сообщение из недавно полученных или отправленных (или спеть, рассказать стихотворенье)",
|
||||||
parse_mode='html',
|
parse_mode="html",
|
||||||
reply_markup=markup,
|
reply_markup=markup,
|
||||||
disable_web_page_preview=not self.settings['Telegram']['preview_link']
|
disable_web_page_preview=not self.settings["Telegram"]["preview_link"],
|
||||||
)
|
)
|
||||||
await asyncio.sleep(MESSAGE_DELAY_4)
|
await asyncio.sleep(MESSAGE_DELAY_4)
|
||||||
|
|
||||||
# Отправляем информацию об эмодзи
|
# Отправляем информацию об эмодзи
|
||||||
await message.answer(
|
await message.answer(
|
||||||
text=f"Любые войсы будут помечены эмоджи. <b>Твой эмоджи - </b>{user_emoji}Таким эмоджи будут помечены твои сообщения для других Но другие люди не узнают кто за каким эмоджи скрывается:)",
|
text=f"Любые войсы будут помечены эмоджи. <b>Твой эмоджи - </b>{user_emoji}Таким эмоджи будут помечены твои сообщения для других Но другие люди не узнают кто за каким эмоджи скрывается:)",
|
||||||
parse_mode='html',
|
parse_mode="html",
|
||||||
reply_markup=markup,
|
reply_markup=markup,
|
||||||
disable_web_page_preview=not self.settings['Telegram']['preview_link']
|
disable_web_page_preview=not self.settings["Telegram"]["preview_link"],
|
||||||
)
|
)
|
||||||
await asyncio.sleep(MESSAGE_DELAY_4)
|
await asyncio.sleep(MESSAGE_DELAY_4)
|
||||||
|
|
||||||
# Отправляем информацию о помощи
|
# Отправляем информацию о помощи
|
||||||
await message.answer(
|
await message.answer(
|
||||||
text="Так же можешь ознакомиться с инструкцией к боту по команде /help",
|
text="Так же можешь ознакомиться с инструкцией к боту по команде /help",
|
||||||
parse_mode='html',
|
parse_mode="html",
|
||||||
reply_markup=markup,
|
reply_markup=markup,
|
||||||
disable_web_page_preview=not self.settings['Telegram']['preview_link']
|
disable_web_page_preview=not self.settings["Telegram"]["preview_link"],
|
||||||
)
|
)
|
||||||
await asyncio.sleep(MESSAGE_DELAY_4)
|
await asyncio.sleep(MESSAGE_DELAY_4)
|
||||||
|
|
||||||
# Отправляем финальное сообщение
|
# Отправляем финальное сообщение
|
||||||
await message.answer(
|
await message.answer(
|
||||||
text="<b>Ну всё, достаточно инструкций. записывайся! Микрофон твой - </b> 🎤",
|
text="<b>Ну всё, достаточно инструкций. записывайся! Микрофон твой - </b> 🎤",
|
||||||
parse_mode='html',
|
parse_mode="html",
|
||||||
reply_markup=markup,
|
reply_markup=markup,
|
||||||
disable_web_page_preview=not self.settings['Telegram']['preview_link']
|
disable_web_page_preview=not self.settings["Telegram"]["preview_link"],
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при отправке приветственных сообщений: {e}")
|
logger.error(f"Ошибка при отправке приветственных сообщений: {e}")
|
||||||
raise VoiceMessageError(f"Не удалось отправить приветственные сообщения: {e}")
|
raise VoiceMessageError(
|
||||||
|
f"Не удалось отправить приветственные сообщения: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
@track_time("get_random_audio", "voice_bot_service")
|
@track_time("get_random_audio", "voice_bot_service")
|
||||||
@track_errors("voice_bot_service", "get_random_audio")
|
@track_errors("voice_bot_service", "get_random_audio")
|
||||||
async def get_random_audio(self, user_id: int) -> Optional[Tuple[str, str, str]]:
|
async def get_random_audio(self, user_id: int) -> Optional[Tuple[str, str, str]]:
|
||||||
@@ -159,25 +177,25 @@ class VoiceBotService:
|
|||||||
try:
|
try:
|
||||||
check_audio = await self.bot_db.check_listen_audio(user_id=user_id)
|
check_audio = await self.bot_db.check_listen_audio(user_id=user_id)
|
||||||
list_audio = list(check_audio)
|
list_audio = list(check_audio)
|
||||||
|
|
||||||
if not list_audio:
|
if not list_audio:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Получаем случайное аудио
|
# Получаем случайное аудио
|
||||||
number_element = random.randint(0, len(list_audio) - 1)
|
number_element = random.randint(0, len(list_audio) - 1)
|
||||||
audio_for_user = check_audio[number_element]
|
audio_for_user = check_audio[number_element]
|
||||||
|
|
||||||
# Получаем информацию об авторе
|
# Получаем информацию об авторе
|
||||||
user_id_author = await self.bot_db.get_user_id_by_file_name(audio_for_user)
|
user_id_author = await self.bot_db.get_user_id_by_file_name(audio_for_user)
|
||||||
date_added = await self.bot_db.get_date_by_file_name(audio_for_user)
|
date_added = await self.bot_db.get_date_by_file_name(audio_for_user)
|
||||||
user_emoji = await self.bot_db.get_user_emoji(user_id_author)
|
user_emoji = await self.bot_db.get_user_emoji(user_id_author)
|
||||||
|
|
||||||
return audio_for_user, date_added, user_emoji
|
return audio_for_user, date_added, user_emoji
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при получении случайного аудио: {e}")
|
logger.error(f"Ошибка при получении случайного аудио: {e}")
|
||||||
raise AudioProcessingError(f"Не удалось получить случайное аудио: {e}")
|
raise AudioProcessingError(f"Не удалось получить случайное аудио: {e}")
|
||||||
|
|
||||||
@track_time("mark_audio_as_listened", "voice_bot_service")
|
@track_time("mark_audio_as_listened", "voice_bot_service")
|
||||||
@track_errors("voice_bot_service", "mark_audio_as_listened")
|
@track_errors("voice_bot_service", "mark_audio_as_listened")
|
||||||
async def mark_audio_as_listened(self, file_name: str, user_id: int) -> None:
|
async def mark_audio_as_listened(self, file_name: str, user_id: int) -> None:
|
||||||
@@ -187,7 +205,7 @@ class VoiceBotService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при пометке аудио как прослушанного: {e}")
|
logger.error(f"Ошибка при пометке аудио как прослушанного: {e}")
|
||||||
raise DatabaseError(f"Не удалось пометить аудио как прослушанное: {e}")
|
raise DatabaseError(f"Не удалось пометить аудио как прослушанное: {e}")
|
||||||
|
|
||||||
@track_time("clear_user_listenings", "voice_bot_service")
|
@track_time("clear_user_listenings", "voice_bot_service")
|
||||||
@track_errors("voice_bot_service", "clear_user_listenings")
|
@track_errors("voice_bot_service", "clear_user_listenings")
|
||||||
@db_query_time("clear_user_listenings", "audio_moderate", "delete")
|
@db_query_time("clear_user_listenings", "audio_moderate", "delete")
|
||||||
@@ -198,7 +216,7 @@ class VoiceBotService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при очистке прослушиваний: {e}")
|
logger.error(f"Ошибка при очистке прослушиваний: {e}")
|
||||||
raise DatabaseError(f"Не удалось очистить прослушивания: {e}")
|
raise DatabaseError(f"Не удалось очистить прослушивания: {e}")
|
||||||
|
|
||||||
@track_time("get_remaining_audio_count", "voice_bot_service")
|
@track_time("get_remaining_audio_count", "voice_bot_service")
|
||||||
@track_errors("voice_bot_service", "get_remaining_audio_count")
|
@track_errors("voice_bot_service", "get_remaining_audio_count")
|
||||||
async def get_remaining_audio_count(self, user_id: int) -> int:
|
async def get_remaining_audio_count(self, user_id: int) -> int:
|
||||||
@@ -209,25 +227,24 @@ class VoiceBotService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при получении количества аудио: {e}")
|
logger.error(f"Ошибка при получении количества аудио: {e}")
|
||||||
raise DatabaseError(f"Не удалось получить количество аудио: {e}")
|
raise DatabaseError(f"Не удалось получить количество аудио: {e}")
|
||||||
|
|
||||||
@track_time("get_main_keyboard", "voice_bot_service")
|
@track_time("get_main_keyboard", "voice_bot_service")
|
||||||
@track_errors("voice_bot_service", "get_main_keyboard")
|
@track_errors("voice_bot_service", "get_main_keyboard")
|
||||||
def _get_main_keyboard(self):
|
def _get_main_keyboard(self):
|
||||||
"""Получить основную клавиатуру"""
|
"""Получить основную клавиатуру"""
|
||||||
from helper_bot.keyboards.keyboards import get_main_keyboard
|
from helper_bot.keyboards.keyboards import get_main_keyboard
|
||||||
|
|
||||||
return get_main_keyboard()
|
return get_main_keyboard()
|
||||||
|
|
||||||
@track_time("send_error_to_logs", "voice_bot_service")
|
@track_time("send_error_to_logs", "voice_bot_service")
|
||||||
@track_errors("voice_bot_service", "send_error_to_logs")
|
@track_errors("voice_bot_service", "send_error_to_logs")
|
||||||
async def _send_error_to_logs(self, message: str) -> None:
|
async def _send_error_to_logs(self, message: str) -> None:
|
||||||
"""Отправить ошибку в логи"""
|
"""Отправить ошибку в логи"""
|
||||||
try:
|
try:
|
||||||
from helper_bot.utils.helper_func import send_voice_message
|
from helper_bot.utils.helper_func import send_voice_message
|
||||||
|
|
||||||
await send_voice_message(
|
await send_voice_message(
|
||||||
self.settings['Telegram']['important_logs'],
|
self.settings["Telegram"]["important_logs"], None, None, None
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None
|
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Не удалось отправить ошибку в логи: {e}")
|
logger.error(f"Не удалось отправить ошибку в логи: {e}")
|
||||||
@@ -235,45 +252,49 @@ class VoiceBotService:
|
|||||||
|
|
||||||
class AudioFileService:
|
class AudioFileService:
|
||||||
"""Сервис для работы с аудио файлами"""
|
"""Сервис для работы с аудио файлами"""
|
||||||
|
|
||||||
def __init__(self, bot_db):
|
def __init__(self, bot_db):
|
||||||
self.bot_db = bot_db
|
self.bot_db = bot_db
|
||||||
|
|
||||||
@track_time("generate_file_name", "audio_file_service")
|
@track_time("generate_file_name", "audio_file_service")
|
||||||
@track_errors("audio_file_service", "generate_file_name")
|
@track_errors("audio_file_service", "generate_file_name")
|
||||||
async def generate_file_name(self, user_id: int) -> str:
|
async def generate_file_name(self, user_id: int) -> str:
|
||||||
"""Сгенерировать имя файла для аудио"""
|
"""Сгенерировать имя файла для аудио"""
|
||||||
try:
|
try:
|
||||||
# Проверяем есть ли запись о файле в базе данных
|
# Проверяем есть ли запись о файле в базе данных
|
||||||
user_audio_count = await self.bot_db.get_user_audio_records_count(user_id=user_id)
|
user_audio_count = await self.bot_db.get_user_audio_records_count(
|
||||||
|
user_id=user_id
|
||||||
|
)
|
||||||
|
|
||||||
if user_audio_count == 0:
|
if user_audio_count == 0:
|
||||||
# Если нет, то генерируем имя файла
|
# Если нет, то генерируем имя файла
|
||||||
file_name = f'message_from_{user_id}_number_1'
|
file_name = f"message_from_{user_id}_number_1"
|
||||||
else:
|
else:
|
||||||
# Иначе берем последнюю запись из БД, добавляем к ней 1
|
# Иначе берем последнюю запись из БД, добавляем к ней 1
|
||||||
file_name = await self.bot_db.get_path_for_audio_record(user_id=user_id)
|
file_name = await self.bot_db.get_path_for_audio_record(user_id=user_id)
|
||||||
if file_name:
|
if file_name:
|
||||||
# Извлекаем номер из имени файла и увеличиваем на 1
|
# Извлекаем номер из имени файла и увеличиваем на 1
|
||||||
try:
|
try:
|
||||||
current_number = int(file_name.split('_')[-1])
|
current_number = int(file_name.split("_")[-1])
|
||||||
new_number = current_number + 1
|
new_number = current_number + 1
|
||||||
except (ValueError, IndexError):
|
except (ValueError, IndexError):
|
||||||
new_number = user_audio_count + 1
|
new_number = user_audio_count + 1
|
||||||
else:
|
else:
|
||||||
new_number = user_audio_count + 1
|
new_number = user_audio_count + 1
|
||||||
|
|
||||||
file_name = f'message_from_{user_id}_number_{new_number}'
|
file_name = f"message_from_{user_id}_number_{new_number}"
|
||||||
|
|
||||||
return file_name
|
return file_name
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при генерации имени файла: {e}")
|
logger.error(f"Ошибка при генерации имени файла: {e}")
|
||||||
raise FileOperationError(f"Не удалось сгенерировать имя файла: {e}")
|
raise FileOperationError(f"Не удалось сгенерировать имя файла: {e}")
|
||||||
|
|
||||||
@track_time("save_audio_file", "audio_file_service")
|
@track_time("save_audio_file", "audio_file_service")
|
||||||
@track_errors("audio_file_service", "save_audio_file")
|
@track_errors("audio_file_service", "save_audio_file")
|
||||||
async def save_audio_file(self, file_name: str, user_id: int, date_added: datetime, file_id: str) -> None:
|
async def save_audio_file(
|
||||||
|
self, file_name: str, user_id: int, date_added: datetime, file_id: str
|
||||||
|
) -> None:
|
||||||
"""Сохранить информацию об аудио файле в базу данных"""
|
"""Сохранить информацию об аудио файле в базу данных"""
|
||||||
try:
|
try:
|
||||||
# Проверяем существование файла перед сохранением в БД
|
# Проверяем существование файла перед сохранением в БД
|
||||||
@@ -281,16 +302,20 @@ class AudioFileService:
|
|||||||
error_msg = f"Файл {file_name} не существует или поврежден, отменяем сохранение в БД"
|
error_msg = f"Файл {file_name} не существует или поврежден, отменяем сохранение в БД"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
raise FileOperationError(error_msg)
|
raise FileOperationError(error_msg)
|
||||||
|
|
||||||
await self.bot_db.add_audio_record_simple(file_name, user_id, date_added)
|
await self.bot_db.add_audio_record_simple(file_name, user_id, date_added)
|
||||||
logger.info(f"Информация об аудио файле успешно сохранена в БД: {file_name}")
|
logger.info(
|
||||||
|
f"Информация об аудио файле успешно сохранена в БД: {file_name}"
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при сохранении аудио файла в БД: {e}")
|
logger.error(f"Ошибка при сохранении аудио файла в БД: {e}")
|
||||||
raise DatabaseError(f"Не удалось сохранить аудио файл в БД: {e}")
|
raise DatabaseError(f"Не удалось сохранить аудио файл в БД: {e}")
|
||||||
|
|
||||||
@track_time("save_audio_file_with_transaction", "audio_file_service")
|
@track_time("save_audio_file_with_transaction", "audio_file_service")
|
||||||
@track_errors("audio_file_service", "save_audio_file_with_transaction")
|
@track_errors("audio_file_service", "save_audio_file_with_transaction")
|
||||||
async def save_audio_file_with_transaction(self, file_name: str, user_id: int, date_added: datetime, file_id: str) -> None:
|
async def save_audio_file_with_transaction(
|
||||||
|
self, file_name: str, user_id: int, date_added: datetime, file_id: str
|
||||||
|
) -> None:
|
||||||
"""Сохранить информацию об аудио файле в базу данных с транзакцией"""
|
"""Сохранить информацию об аудио файле в базу данных с транзакцией"""
|
||||||
try:
|
try:
|
||||||
# Проверяем существование файла перед сохранением в БД
|
# Проверяем существование файла перед сохранением в БД
|
||||||
@@ -298,68 +323,80 @@ class AudioFileService:
|
|||||||
error_msg = f"Файл {file_name} не существует или поврежден, отменяем сохранение в БД"
|
error_msg = f"Файл {file_name} не существует или поврежден, отменяем сохранение в БД"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
raise FileOperationError(error_msg)
|
raise FileOperationError(error_msg)
|
||||||
|
|
||||||
# Используем транзакцию для атомарности операции
|
# Используем транзакцию для атомарности операции
|
||||||
await self.bot_db.add_audio_record_simple(file_name, user_id, date_added)
|
await self.bot_db.add_audio_record_simple(file_name, user_id, date_added)
|
||||||
logger.info(f"Информация об аудио файле успешно сохранена в БД с транзакцией: {file_name}")
|
logger.info(
|
||||||
|
f"Информация об аудио файле успешно сохранена в БД с транзакцией: {file_name}"
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при сохранении аудио файла в БД с транзакцией: {e}")
|
logger.error(f"Ошибка при сохранении аудио файла в БД с транзакцией: {e}")
|
||||||
raise DatabaseError(f"Не удалось сохранить аудио файл в БД с транзакцией: {e}")
|
raise DatabaseError(
|
||||||
|
f"Не удалось сохранить аудио файл в БД с транзакцией: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
@track_time("download_and_save_audio", "audio_file_service")
|
@track_time("download_and_save_audio", "audio_file_service")
|
||||||
@track_errors("audio_file_service", "download_and_save_audio")
|
@track_errors("audio_file_service", "download_and_save_audio")
|
||||||
async def download_and_save_audio(self, bot, message, file_name: str, max_retries: int = 3) -> None:
|
async def download_and_save_audio(
|
||||||
|
self, bot, message, file_name: str, max_retries: int = 3
|
||||||
|
) -> None:
|
||||||
"""Скачать и сохранить аудио файл с retry механизмом"""
|
"""Скачать и сохранить аудио файл с retry механизмом"""
|
||||||
last_exception = None
|
last_exception = None
|
||||||
|
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
try:
|
try:
|
||||||
logger.info(f"Попытка {attempt + 1}/{max_retries} скачивания и сохранения аудио: {file_name}")
|
logger.info(
|
||||||
|
f"Попытка {attempt + 1}/{max_retries} скачивания и сохранения аудио: {file_name}"
|
||||||
|
)
|
||||||
|
|
||||||
# Проверяем наличие голосового сообщения
|
# Проверяем наличие голосового сообщения
|
||||||
if not message or not message.voice:
|
if not message or not message.voice:
|
||||||
error_msg = "Сообщение или голосовое сообщение не найдено"
|
error_msg = "Сообщение или голосовое сообщение не найдено"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
raise FileOperationError(error_msg)
|
raise FileOperationError(error_msg)
|
||||||
|
|
||||||
file_id = message.voice.file_id
|
file_id = message.voice.file_id
|
||||||
logger.info(f"Получен file_id: {file_id}")
|
logger.info(f"Получен file_id: {file_id}")
|
||||||
|
|
||||||
# Получаем информацию о файле
|
# Получаем информацию о файле
|
||||||
try:
|
try:
|
||||||
file_info = await bot.get_file(file_id=file_id)
|
file_info = await bot.get_file(file_id=file_id)
|
||||||
logger.info(f"Получена информация о файле: {file_info.file_path}")
|
logger.info(f"Получена информация о файле: {file_info.file_path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при получении информации о файле: {e}")
|
logger.error(f"Ошибка при получении информации о файле: {e}")
|
||||||
raise FileOperationError(f"Не удалось получить информацию о файле: {e}")
|
raise FileOperationError(
|
||||||
|
f"Не удалось получить информацию о файле: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
# Скачиваем файл
|
# Скачиваем файл
|
||||||
try:
|
try:
|
||||||
downloaded_file = await bot.download_file(file_path=file_info.file_path)
|
downloaded_file = await bot.download_file(
|
||||||
|
file_path=file_info.file_path
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при скачивании файла: {e}")
|
logger.error(f"Ошибка при скачивании файла: {e}")
|
||||||
raise FileOperationError(f"Не удалось скачать файл: {e}")
|
raise FileOperationError(f"Не удалось скачать файл: {e}")
|
||||||
|
|
||||||
# Проверяем что файл успешно скачан
|
# Проверяем что файл успешно скачан
|
||||||
if not downloaded_file:
|
if not downloaded_file:
|
||||||
error_msg = "Не удалось скачать файл - получен пустой объект"
|
error_msg = "Не удалось скачать файл - получен пустой объект"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
raise FileOperationError(error_msg)
|
raise FileOperationError(error_msg)
|
||||||
|
|
||||||
# Получаем размер файла без изменения позиции
|
# Получаем размер файла без изменения позиции
|
||||||
current_pos = downloaded_file.tell()
|
current_pos = downloaded_file.tell()
|
||||||
downloaded_file.seek(0, 2) # Переходим в конец файла
|
downloaded_file.seek(0, 2) # Переходим в конец файла
|
||||||
file_size = downloaded_file.tell()
|
file_size = downloaded_file.tell()
|
||||||
downloaded_file.seek(current_pos) # Возвращаемся в исходную позицию
|
downloaded_file.seek(current_pos) # Возвращаемся в исходную позицию
|
||||||
|
|
||||||
logger.info(f"Файл скачан, размер: {file_size} bytes")
|
logger.info(f"Файл скачан, размер: {file_size} bytes")
|
||||||
|
|
||||||
# Проверяем минимальный размер файла
|
# Проверяем минимальный размер файла
|
||||||
if file_size < 100: # Минимальный размер для аудио файла
|
if file_size < 100: # Минимальный размер для аудио файла
|
||||||
error_msg = f"Файл слишком маленький: {file_size} bytes"
|
error_msg = f"Файл слишком маленький: {file_size} bytes"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
raise FileOperationError(error_msg)
|
raise FileOperationError(error_msg)
|
||||||
|
|
||||||
# Создаем директорию если она не существует
|
# Создаем директорию если она не существует
|
||||||
try:
|
try:
|
||||||
os.makedirs(VOICE_USERS_DIR, exist_ok=True)
|
os.makedirs(VOICE_USERS_DIR, exist_ok=True)
|
||||||
@@ -367,27 +404,27 @@ class AudioFileService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при создании директории: {e}")
|
logger.error(f"Ошибка при создании директории: {e}")
|
||||||
raise FileOperationError(f"Не удалось создать директорию: {e}")
|
raise FileOperationError(f"Не удалось создать директорию: {e}")
|
||||||
|
|
||||||
file_path = f'{VOICE_USERS_DIR}/{file_name}.ogg'
|
file_path = f"{VOICE_USERS_DIR}/{file_name}.ogg"
|
||||||
logger.info(f"Сохраняем файл по пути: {file_path}")
|
logger.info(f"Сохраняем файл по пути: {file_path}")
|
||||||
|
|
||||||
# Сбрасываем позицию в файле перед сохранением
|
# Сбрасываем позицию в файле перед сохранением
|
||||||
downloaded_file.seek(0)
|
downloaded_file.seek(0)
|
||||||
|
|
||||||
# Сохраняем файл
|
# Сохраняем файл
|
||||||
try:
|
try:
|
||||||
with open(file_path, 'wb') as new_file:
|
with open(file_path, "wb") as new_file:
|
||||||
new_file.write(downloaded_file.read())
|
new_file.write(downloaded_file.read())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при записи файла на диск: {e}")
|
logger.error(f"Ошибка при записи файла на диск: {e}")
|
||||||
raise FileOperationError(f"Не удалось записать файл на диск: {e}")
|
raise FileOperationError(f"Не удалось записать файл на диск: {e}")
|
||||||
|
|
||||||
# Проверяем что файл действительно создался и имеет правильный размер
|
# Проверяем что файл действительно создался и имеет правильный размер
|
||||||
if not os.path.exists(file_path):
|
if not os.path.exists(file_path):
|
||||||
error_msg = f"Файл не был создан: {file_path}"
|
error_msg = f"Файл не был создан: {file_path}"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
raise FileOperationError(error_msg)
|
raise FileOperationError(error_msg)
|
||||||
|
|
||||||
saved_file_size = os.path.getsize(file_path)
|
saved_file_size = os.path.getsize(file_path)
|
||||||
if saved_file_size != file_size:
|
if saved_file_size != file_size:
|
||||||
error_msg = f"Размер сохраненного файла не совпадает: ожидалось {file_size}, получено {saved_file_size}"
|
error_msg = f"Размер сохраненного файла не совпадает: ожидалось {file_size}, получено {saved_file_size}"
|
||||||
@@ -398,48 +435,62 @@ class AudioFileService:
|
|||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
raise FileOperationError(error_msg)
|
raise FileOperationError(error_msg)
|
||||||
|
|
||||||
logger.info(f"Файл успешно сохранен: {file_path}, размер: {saved_file_size} bytes")
|
logger.info(
|
||||||
|
f"Файл успешно сохранен: {file_path}, размер: {saved_file_size} bytes"
|
||||||
|
)
|
||||||
return # Успешное завершение
|
return # Успешное завершение
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
last_exception = e
|
last_exception = e
|
||||||
logger.error(f"Попытка {attempt + 1}/{max_retries} неудачна: {e}")
|
logger.error(f"Попытка {attempt + 1}/{max_retries} неудачна: {e}")
|
||||||
|
|
||||||
if attempt < max_retries - 1:
|
if attempt < max_retries - 1:
|
||||||
wait_time = (attempt + 1) * 2 # Экспоненциальная задержка: 2, 4, 6 секунд
|
wait_time = (
|
||||||
logger.info(f"Ожидание {wait_time} секунд перед следующей попыткой...")
|
attempt + 1
|
||||||
|
) * 2 # Экспоненциальная задержка: 2, 4, 6 секунд
|
||||||
|
logger.info(
|
||||||
|
f"Ожидание {wait_time} секунд перед следующей попыткой..."
|
||||||
|
)
|
||||||
await asyncio.sleep(wait_time)
|
await asyncio.sleep(wait_time)
|
||||||
else:
|
else:
|
||||||
logger.error(f"Все {max_retries} попыток скачивания неудачны")
|
logger.error(f"Все {max_retries} попыток скачивания неудачны")
|
||||||
logger.error(f"Traceback последней ошибки: {traceback.format_exc()}")
|
logger.error(
|
||||||
|
f"Traceback последней ошибки: {traceback.format_exc()}"
|
||||||
|
)
|
||||||
|
|
||||||
# Если все попытки неудачны
|
# Если все попытки неудачны
|
||||||
raise FileOperationError(f"Не удалось скачать и сохранить аудио после {max_retries} попыток. Последняя ошибка: {last_exception}")
|
raise FileOperationError(
|
||||||
|
f"Не удалось скачать и сохранить аудио после {max_retries} попыток. Последняя ошибка: {last_exception}"
|
||||||
|
)
|
||||||
|
|
||||||
@track_time("verify_file_exists", "audio_file_service")
|
@track_time("verify_file_exists", "audio_file_service")
|
||||||
@track_errors("audio_file_service", "verify_file_exists")
|
@track_errors("audio_file_service", "verify_file_exists")
|
||||||
async def verify_file_exists(self, file_name: str) -> bool:
|
async def verify_file_exists(self, file_name: str) -> bool:
|
||||||
"""Проверить существование и валидность файла"""
|
"""Проверить существование и валидность файла"""
|
||||||
try:
|
try:
|
||||||
file_path = f'{VOICE_USERS_DIR}/{file_name}.ogg'
|
file_path = f"{VOICE_USERS_DIR}/{file_name}.ogg"
|
||||||
|
|
||||||
if not os.path.exists(file_path):
|
if not os.path.exists(file_path):
|
||||||
logger.warning(f"Файл не существует: {file_path}")
|
logger.warning(f"Файл не существует: {file_path}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
file_size = os.path.getsize(file_path)
|
file_size = os.path.getsize(file_path)
|
||||||
if file_size == 0:
|
if file_size == 0:
|
||||||
logger.warning(f"Файл пустой: {file_path}")
|
logger.warning(f"Файл пустой: {file_path}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if file_size < 100: # Минимальный размер для аудио файла
|
if file_size < 100: # Минимальный размер для аудио файла
|
||||||
logger.warning(f"Файл слишком маленький: {file_path}, размер: {file_size} bytes")
|
logger.warning(
|
||||||
|
f"Файл слишком маленький: {file_path}, размер: {file_size} bytes"
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
logger.info(f"Файл проверен и валиден: {file_path}, размер: {file_size} bytes")
|
logger.info(
|
||||||
|
f"Файл проверен и валиден: {file_path}, размер: {file_size} bytes"
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при проверке файла {file_name}: {e}")
|
logger.error(f"Ошибка при проверке файла {file_name}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
import time
|
|
||||||
import html
|
import html
|
||||||
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from helper_bot.handlers.voice.exceptions import DatabaseError
|
from helper_bot.handlers.voice.exceptions import DatabaseError
|
||||||
|
from helper_bot.utils.metrics import db_query_time, track_errors, track_time
|
||||||
from logs.custom_logger import logger
|
from logs.custom_logger import logger
|
||||||
|
|
||||||
from helper_bot.utils.metrics import (
|
|
||||||
track_time,
|
|
||||||
track_errors,
|
|
||||||
db_query_time
|
|
||||||
)
|
|
||||||
|
|
||||||
def format_time_ago(date_from_db: str) -> Optional[str]:
|
def format_time_ago(date_from_db: str) -> Optional[str]:
|
||||||
"""Форматировать время с момента последней записи"""
|
"""Форматировать время с момента последней записи"""
|
||||||
@@ -22,31 +18,37 @@ def format_time_ago(date_from_db: str) -> Optional[str]:
|
|||||||
last_voice_time_timestamp = time.mktime(parse_date.timetuple())
|
last_voice_time_timestamp = time.mktime(parse_date.timetuple())
|
||||||
time_now_timestamp = time.time()
|
time_now_timestamp = time.time()
|
||||||
date_difference = time_now_timestamp - last_voice_time_timestamp
|
date_difference = time_now_timestamp - last_voice_time_timestamp
|
||||||
|
|
||||||
# Считаем минуты, часы, дни
|
# Считаем минуты, часы, дни
|
||||||
much_minutes_ago = round(date_difference / 60, 0)
|
much_minutes_ago = round(date_difference / 60, 0)
|
||||||
much_hour_ago = round(date_difference / 3600, 0)
|
much_hour_ago = round(date_difference / 3600, 0)
|
||||||
much_days_ago = int(round(much_hour_ago / 24, 0))
|
much_days_ago = int(round(much_hour_ago / 24, 0))
|
||||||
|
|
||||||
message_with_date = ''
|
message_with_date = ""
|
||||||
if much_minutes_ago <= 60:
|
if much_minutes_ago <= 60:
|
||||||
word_minute = plural_time(1, much_minutes_ago)
|
word_minute = plural_time(1, much_minutes_ago)
|
||||||
# Экранируем потенциально проблемные символы
|
# Экранируем потенциально проблемные символы
|
||||||
word_minute_escaped = html.escape(word_minute)
|
word_minute_escaped = html.escape(word_minute)
|
||||||
message_with_date = f'<b>Последнее сообщение было записано {word_minute_escaped} назад</b>'
|
message_with_date = (
|
||||||
|
f"<b>Последнее сообщение было записано {word_minute_escaped} назад</b>"
|
||||||
|
)
|
||||||
elif much_minutes_ago > 60 and much_hour_ago <= 24:
|
elif much_minutes_ago > 60 and much_hour_ago <= 24:
|
||||||
word_hour = plural_time(2, much_hour_ago)
|
word_hour = plural_time(2, much_hour_ago)
|
||||||
# Экранируем потенциально проблемные символы
|
# Экранируем потенциально проблемные символы
|
||||||
word_hour_escaped = html.escape(word_hour)
|
word_hour_escaped = html.escape(word_hour)
|
||||||
message_with_date = f'<b>Последнее сообщение было записано {word_hour_escaped} назад</b>'
|
message_with_date = (
|
||||||
|
f"<b>Последнее сообщение было записано {word_hour_escaped} назад</b>"
|
||||||
|
)
|
||||||
elif much_hour_ago > 24:
|
elif much_hour_ago > 24:
|
||||||
word_day = plural_time(3, much_days_ago)
|
word_day = plural_time(3, much_days_ago)
|
||||||
# Экранируем потенциально проблемные символы
|
# Экранируем потенциально проблемные символы
|
||||||
word_day_escaped = html.escape(word_day)
|
word_day_escaped = html.escape(word_day)
|
||||||
message_with_date = f'<b>Последнее сообщение было записано {word_day_escaped} назад</b>'
|
message_with_date = (
|
||||||
|
f"<b>Последнее сообщение было записано {word_day_escaped} назад</b>"
|
||||||
|
)
|
||||||
|
|
||||||
return message_with_date
|
return message_with_date
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при форматировании времени: {e}")
|
logger.error(f"Ошибка при форматировании времени: {e}")
|
||||||
return None
|
return None
|
||||||
@@ -56,11 +58,11 @@ def plural_time(type: int, n: float) -> str:
|
|||||||
"""Форматировать множественное число для времени"""
|
"""Форматировать множественное число для времени"""
|
||||||
word = []
|
word = []
|
||||||
if type == 1:
|
if type == 1:
|
||||||
word = ['минуту', 'минуты', 'минут']
|
word = ["минуту", "минуты", "минут"]
|
||||||
elif type == 2:
|
elif type == 2:
|
||||||
word = ['час', 'часа', 'часов']
|
word = ["час", "часа", "часов"]
|
||||||
elif type == 3:
|
elif type == 3:
|
||||||
word = ['день', 'дня', 'дней']
|
word = ["день", "дня", "дней"]
|
||||||
else:
|
else:
|
||||||
return str(int(n))
|
return str(int(n))
|
||||||
|
|
||||||
@@ -70,9 +72,10 @@ def plural_time(type: int, n: float) -> str:
|
|||||||
p = 1
|
p = 1
|
||||||
else:
|
else:
|
||||||
p = 2
|
p = 2
|
||||||
|
|
||||||
new_number = int(n)
|
new_number = int(n)
|
||||||
return str(new_number) + ' ' + word[p]
|
return str(new_number) + " " + word[p]
|
||||||
|
|
||||||
|
|
||||||
@track_time("get_last_message_text", "voice_utils")
|
@track_time("get_last_message_text", "voice_utils")
|
||||||
@track_errors("voice_utils", "get_last_message_text")
|
@track_errors("voice_utils", "get_last_message_text")
|
||||||
@@ -93,7 +96,8 @@ async def get_last_message_text(bot_db) -> Optional[str]:
|
|||||||
|
|
||||||
async def validate_voice_message(message) -> bool:
|
async def validate_voice_message(message) -> bool:
|
||||||
"""Проверить валидность голосового сообщения"""
|
"""Проверить валидность голосового сообщения"""
|
||||||
return message.content_type == 'voice'
|
return message.content_type == "voice"
|
||||||
|
|
||||||
|
|
||||||
@track_time("get_user_emoji_safe", "voice_utils")
|
@track_time("get_user_emoji_safe", "voice_utils")
|
||||||
@track_errors("voice_utils", "get_user_emoji_safe")
|
@track_errors("voice_utils", "get_user_emoji_safe")
|
||||||
@@ -102,7 +106,11 @@ async def get_user_emoji_safe(bot_db, user_id: int) -> str:
|
|||||||
"""Безопасно получить эмодзи пользователя"""
|
"""Безопасно получить эмодзи пользователя"""
|
||||||
try:
|
try:
|
||||||
user_emoji = await bot_db.get_user_emoji(user_id)
|
user_emoji = await bot_db.get_user_emoji(user_id)
|
||||||
return user_emoji if user_emoji and user_emoji != "Смайл еще не определен" else "😊"
|
return (
|
||||||
|
user_emoji
|
||||||
|
if user_emoji and user_emoji != "Смайл еще не определен"
|
||||||
|
else "😊"
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при получении эмодзи пользователя {user_id}: {e}")
|
logger.error(f"Ошибка при получении эмодзи пользователя {user_id}: {e}")
|
||||||
return "😊"
|
return "😊"
|
||||||
|
|||||||
@@ -2,37 +2,48 @@ import asyncio
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from aiogram import Router, types, F
|
from aiogram import F, Router, types
|
||||||
from aiogram.filters import Command, StateFilter, MagicData
|
from aiogram.filters import Command, MagicData, StateFilter
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
from aiogram.types import FSInputFile
|
from aiogram.types import FSInputFile
|
||||||
|
|
||||||
from helper_bot.filters.main import ChatTypeFilter
|
from helper_bot.filters.main import ChatTypeFilter
|
||||||
from helper_bot.middlewares.blacklist_middleware import BlacklistMiddleware
|
from helper_bot.handlers.private.constants import BUTTON_TEXTS, FSM_STATES
|
||||||
from helper_bot.middlewares.dependencies_middleware import DependenciesMiddleware
|
|
||||||
|
|
||||||
from helper_bot.utils import messages
|
|
||||||
from helper_bot.utils.helper_func import get_first_name, update_user_info, check_user_emoji, send_voice_message
|
|
||||||
from logs.custom_logger import logger
|
|
||||||
from helper_bot.handlers.voice.constants import *
|
from helper_bot.handlers.voice.constants import *
|
||||||
from helper_bot.handlers.voice.services import VoiceBotService
|
from helper_bot.handlers.voice.services import VoiceBotService
|
||||||
from helper_bot.handlers.voice.utils import get_last_message_text, validate_voice_message, get_user_emoji_safe
|
from helper_bot.handlers.voice.utils import (
|
||||||
from helper_bot.keyboards.keyboards import get_main_keyboard, get_reply_keyboard_for_voice
|
get_last_message_text,
|
||||||
|
get_user_emoji_safe,
|
||||||
|
validate_voice_message,
|
||||||
|
)
|
||||||
from helper_bot.keyboards import get_reply_keyboard
|
from helper_bot.keyboards import get_reply_keyboard
|
||||||
from helper_bot.handlers.private.constants import FSM_STATES
|
from helper_bot.keyboards.keyboards import (
|
||||||
from helper_bot.handlers.private.constants import BUTTON_TEXTS
|
get_main_keyboard,
|
||||||
|
get_reply_keyboard_for_voice,
|
||||||
|
)
|
||||||
|
from helper_bot.middlewares.blacklist_middleware import BlacklistMiddleware
|
||||||
|
from helper_bot.middlewares.dependencies_middleware import DependenciesMiddleware
|
||||||
|
from helper_bot.utils import messages
|
||||||
|
from helper_bot.utils.helper_func import (
|
||||||
|
check_user_emoji,
|
||||||
|
get_first_name,
|
||||||
|
send_voice_message,
|
||||||
|
update_user_info,
|
||||||
|
)
|
||||||
|
|
||||||
# Local imports - metrics
|
# Local imports - metrics
|
||||||
from helper_bot.utils.metrics import (
|
from helper_bot.utils.metrics import (
|
||||||
track_time,
|
|
||||||
track_errors,
|
|
||||||
db_query_time,
|
db_query_time,
|
||||||
track_file_operations
|
track_errors,
|
||||||
|
track_file_operations,
|
||||||
|
track_time,
|
||||||
)
|
)
|
||||||
|
from logs.custom_logger import logger
|
||||||
|
|
||||||
|
|
||||||
class VoiceHandlers:
|
class VoiceHandlers:
|
||||||
def __init__(self, db, settings):
|
def __init__(self, db, settings):
|
||||||
self.db = db.get_db() if hasattr(db, 'get_db') else db
|
self.db = db.get_db() if hasattr(db, "get_db") else db
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
self.router = Router()
|
self.router = Router()
|
||||||
self._setup_handlers()
|
self._setup_handlers()
|
||||||
@@ -46,102 +57,114 @@ class VoiceHandlers:
|
|||||||
self.router.message.register(
|
self.router.message.register(
|
||||||
self.cancel_handler,
|
self.cancel_handler,
|
||||||
ChatTypeFilter(chat_type=["private"]),
|
ChatTypeFilter(chat_type=["private"]),
|
||||||
F.text == "Отменить"
|
F.text == "Отменить",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Обработчик кнопки "Голосовой бот"
|
# Обработчик кнопки "Голосовой бот"
|
||||||
self.router.message.register(
|
self.router.message.register(
|
||||||
self.voice_bot_button_handler,
|
self.voice_bot_button_handler,
|
||||||
ChatTypeFilter(chat_type=["private"]),
|
ChatTypeFilter(chat_type=["private"]),
|
||||||
F.text == BUTTON_TEXTS["VOICE_BOT"]
|
F.text == BUTTON_TEXTS["VOICE_BOT"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Команды
|
# Команды
|
||||||
self.router.message.register(
|
self.router.message.register(
|
||||||
self.restart_function,
|
self.restart_function,
|
||||||
ChatTypeFilter(chat_type=["private"]),
|
ChatTypeFilter(chat_type=["private"]),
|
||||||
Command(CMD_RESTART)
|
Command(CMD_RESTART),
|
||||||
)
|
)
|
||||||
|
|
||||||
self.router.message.register(
|
self.router.message.register(
|
||||||
self.handle_emoji_message,
|
self.handle_emoji_message,
|
||||||
ChatTypeFilter(chat_type=["private"]),
|
ChatTypeFilter(chat_type=["private"]),
|
||||||
Command(CMD_EMOJI)
|
Command(CMD_EMOJI),
|
||||||
)
|
)
|
||||||
|
|
||||||
self.router.message.register(
|
self.router.message.register(
|
||||||
self.help_function,
|
self.help_function, ChatTypeFilter(chat_type=["private"]), Command(CMD_HELP)
|
||||||
ChatTypeFilter(chat_type=["private"]),
|
|
||||||
Command(CMD_HELP)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.router.message.register(
|
self.router.message.register(
|
||||||
self.start,
|
self.start, ChatTypeFilter(chat_type=["private"]), Command(CMD_START)
|
||||||
ChatTypeFilter(chat_type=["private"]),
|
|
||||||
Command(CMD_START)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Дополнительные команды
|
# Дополнительные команды
|
||||||
self.router.message.register(
|
self.router.message.register(
|
||||||
self.refresh_listen_function,
|
self.refresh_listen_function,
|
||||||
ChatTypeFilter(chat_type=["private"]),
|
ChatTypeFilter(chat_type=["private"]),
|
||||||
Command(CMD_REFRESH)
|
Command(CMD_REFRESH),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Обработчики состояний и кнопок
|
# Обработчики состояний и кнопок
|
||||||
self.router.message.register(
|
self.router.message.register(
|
||||||
self.standup_write,
|
self.standup_write,
|
||||||
StateFilter(STATE_START),
|
StateFilter(STATE_START),
|
||||||
ChatTypeFilter(chat_type=["private"]),
|
ChatTypeFilter(chat_type=["private"]),
|
||||||
F.text == BTN_SPEAK
|
F.text == BTN_SPEAK,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.router.message.register(
|
self.router.message.register(
|
||||||
self.suggest_voice,
|
self.suggest_voice,
|
||||||
StateFilter(STATE_STANDUP_WRITE),
|
StateFilter(STATE_STANDUP_WRITE),
|
||||||
ChatTypeFilter(chat_type=["private"]),
|
ChatTypeFilter(chat_type=["private"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
self.router.message.register(
|
self.router.message.register(
|
||||||
self.standup_listen_audio,
|
self.standup_listen_audio,
|
||||||
StateFilter(STATE_START),
|
StateFilter(STATE_START),
|
||||||
ChatTypeFilter(chat_type=["private"]),
|
ChatTypeFilter(chat_type=["private"]),
|
||||||
F.text == BTN_LISTEN
|
F.text == BTN_LISTEN,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Новые обработчики кнопок
|
# Новые обработчики кнопок
|
||||||
self.router.message.register(
|
self.router.message.register(
|
||||||
self.refresh_listen_function,
|
self.refresh_listen_function,
|
||||||
ChatTypeFilter(chat_type=["private"]),
|
ChatTypeFilter(chat_type=["private"]),
|
||||||
F.text == "🔄Сбросить прослушивания"
|
F.text == "🔄Сбросить прослушивания",
|
||||||
)
|
)
|
||||||
|
|
||||||
self.router.message.register(
|
self.router.message.register(
|
||||||
self.handle_emoji_message,
|
self.handle_emoji_message,
|
||||||
ChatTypeFilter(chat_type=["private"]),
|
ChatTypeFilter(chat_type=["private"]),
|
||||||
F.text == "😊Узнать эмодзи"
|
F.text == "😊Узнать эмодзи",
|
||||||
)
|
)
|
||||||
|
|
||||||
@track_time("voice_bot_button_handler", "voice_handlers")
|
@track_time("voice_bot_button_handler", "voice_handlers")
|
||||||
@track_errors("voice_handlers", "voice_bot_button_handler")
|
@track_errors("voice_handlers", "voice_bot_button_handler")
|
||||||
async def voice_bot_button_handler(self, message: types.Message, state: FSMContext, bot_db: MagicData("bot_db"), settings: MagicData("settings")):
|
async def voice_bot_button_handler(
|
||||||
|
self,
|
||||||
|
message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
|
bot_db: MagicData("bot_db"),
|
||||||
|
settings: MagicData("settings"),
|
||||||
|
):
|
||||||
"""Обработчик кнопки 'Голосовой бот' из основной клавиатуры"""
|
"""Обработчик кнопки 'Голосовой бот' из основной клавиатуры"""
|
||||||
logger.info(f"Пользователь {message.from_user.id} ({message.from_user.full_name}) нажал кнопку 'Голосовой бот'")
|
logger.info(
|
||||||
|
f"Пользователь {message.from_user.id} ({message.from_user.full_name}) нажал кнопку 'Голосовой бот'"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
# Проверяем, получал ли пользователь приветственное сообщение
|
# Проверяем, получал ли пользователь приветственное сообщение
|
||||||
welcome_received = await bot_db.check_voice_bot_welcome_received(message.from_user.id)
|
welcome_received = await bot_db.check_voice_bot_welcome_received(
|
||||||
logger.info(f"Пользователь {message.from_user.id}: welcome_received = {welcome_received}")
|
message.from_user.id
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Пользователь {message.from_user.id}: welcome_received = {welcome_received}"
|
||||||
|
)
|
||||||
|
|
||||||
if welcome_received:
|
if welcome_received:
|
||||||
# Если уже получал приветствие, вызываем restart_function
|
# Если уже получал приветствие, вызываем restart_function
|
||||||
logger.info(f"Пользователь {message.from_user.id}: вызываем restart_function")
|
logger.info(
|
||||||
|
f"Пользователь {message.from_user.id}: вызываем restart_function"
|
||||||
|
)
|
||||||
await self.restart_function(message, state, bot_db, settings)
|
await self.restart_function(message, state, bot_db, settings)
|
||||||
else:
|
else:
|
||||||
# Если не получал, вызываем start
|
# Если не получал, вызываем start
|
||||||
logger.info(f"Пользователь {message.from_user.id}: вызываем start")
|
logger.info(f"Пользователь {message.from_user.id}: вызываем start")
|
||||||
await self.start(message, state, bot_db, settings)
|
await self.start(message, state, bot_db, settings)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при проверке приветственного сообщения для пользователя {message.from_user.id}: {e}")
|
logger.error(
|
||||||
|
f"Ошибка при проверке приветственного сообщения для пользователя {message.from_user.id}: {e}"
|
||||||
|
)
|
||||||
# В случае ошибки вызываем start
|
# В случае ошибки вызываем start
|
||||||
await self.start(message, state, bot_db, settings)
|
await self.start(message, state, bot_db, settings)
|
||||||
|
|
||||||
@@ -149,49 +172,49 @@ class VoiceHandlers:
|
|||||||
@track_errors("voice_handlers", "restart_function")
|
@track_errors("voice_handlers", "restart_function")
|
||||||
async def restart_function(
|
async def restart_function(
|
||||||
self,
|
self,
|
||||||
message: types.Message,
|
message: types.Message,
|
||||||
state: FSMContext,
|
state: FSMContext,
|
||||||
bot_db: MagicData("bot_db"),
|
bot_db: MagicData("bot_db"),
|
||||||
settings: MagicData("settings")
|
settings: MagicData("settings"),
|
||||||
):
|
):
|
||||||
logger.info(f"Пользователь {message.from_user.id}: вызывается функция restart_function")
|
logger.info(
|
||||||
await message.forward(chat_id=settings['Telegram']['group_for_logs'])
|
f"Пользователь {message.from_user.id}: вызывается функция restart_function"
|
||||||
|
)
|
||||||
|
await message.forward(chat_id=settings["Telegram"]["group_for_logs"])
|
||||||
await update_user_info(VOICE_BOT_NAME, message)
|
await update_user_info(VOICE_BOT_NAME, message)
|
||||||
await check_user_emoji(message)
|
await check_user_emoji(message)
|
||||||
markup = get_main_keyboard()
|
markup = get_main_keyboard()
|
||||||
await message.answer(text='🎤 Записывайся или слушай!', reply_markup=markup)
|
await message.answer(text="🎤 Записывайся или слушай!", reply_markup=markup)
|
||||||
await state.set_state(STATE_START)
|
await state.set_state(STATE_START)
|
||||||
|
|
||||||
@track_time("handle_emoji_message", "voice_handlers")
|
@track_time("handle_emoji_message", "voice_handlers")
|
||||||
@track_errors("voice_handlers", "handle_emoji_message")
|
@track_errors("voice_handlers", "handle_emoji_message")
|
||||||
async def handle_emoji_message(
|
async def handle_emoji_message(
|
||||||
self,
|
self, message: types.Message, state: FSMContext, settings: MagicData("settings")
|
||||||
message: types.Message,
|
|
||||||
state: FSMContext,
|
|
||||||
settings: MagicData("settings")
|
|
||||||
):
|
):
|
||||||
logger.info(f"Пользователь {message.from_user.id} ({message.from_user.full_name}) запросил информацию об эмодзи")
|
logger.info(
|
||||||
await message.forward(chat_id=settings['Telegram']['group_for_logs'])
|
f"Пользователь {message.from_user.id} ({message.from_user.full_name}) запросил информацию об эмодзи"
|
||||||
|
)
|
||||||
|
await message.forward(chat_id=settings["Telegram"]["group_for_logs"])
|
||||||
user_emoji = await check_user_emoji(message)
|
user_emoji = await check_user_emoji(message)
|
||||||
await state.set_state(STATE_START)
|
await state.set_state(STATE_START)
|
||||||
if user_emoji is not None:
|
if user_emoji is not None:
|
||||||
await message.answer(f'Твоя эмодзя - {user_emoji}', parse_mode='HTML')
|
await message.answer(f"Твоя эмодзя - {user_emoji}", parse_mode="HTML")
|
||||||
|
|
||||||
@track_time("help_function", "voice_handlers")
|
@track_time("help_function", "voice_handlers")
|
||||||
@track_errors("voice_handlers", "help_function")
|
@track_errors("voice_handlers", "help_function")
|
||||||
async def help_function(
|
async def help_function(
|
||||||
self,
|
self, message: types.Message, state: FSMContext, settings: MagicData("settings")
|
||||||
message: types.Message,
|
|
||||||
state: FSMContext,
|
|
||||||
settings: MagicData("settings")
|
|
||||||
):
|
):
|
||||||
logger.info(f"Пользователь {message.from_user.id} ({message.from_user.full_name}) вызвал функцию help_function")
|
logger.info(
|
||||||
await message.forward(chat_id=settings['Telegram']['group_for_logs'])
|
f"Пользователь {message.from_user.id} ({message.from_user.full_name}) вызвал функцию help_function"
|
||||||
|
)
|
||||||
|
await message.forward(chat_id=settings["Telegram"]["group_for_logs"])
|
||||||
await update_user_info(VOICE_BOT_NAME, message)
|
await update_user_info(VOICE_BOT_NAME, message)
|
||||||
help_message = messages.get_message(get_first_name(message), 'HELP_MESSAGE')
|
help_message = messages.get_message(get_first_name(message), "HELP_MESSAGE")
|
||||||
await message.answer(
|
await message.answer(
|
||||||
text=help_message,
|
text=help_message,
|
||||||
disable_web_page_preview=not settings['Telegram']['preview_link']
|
disable_web_page_preview=not settings["Telegram"]["preview_link"],
|
||||||
)
|
)
|
||||||
await state.set_state(STATE_START)
|
await state.set_state(STATE_START)
|
||||||
|
|
||||||
@@ -200,43 +223,53 @@ class VoiceHandlers:
|
|||||||
@db_query_time("mark_voice_bot_welcome_received", "audio_moderate", "update")
|
@db_query_time("mark_voice_bot_welcome_received", "audio_moderate", "update")
|
||||||
async def start(
|
async def start(
|
||||||
self,
|
self,
|
||||||
message: types.Message,
|
message: types.Message,
|
||||||
state: FSMContext,
|
state: FSMContext,
|
||||||
bot_db: MagicData("bot_db"),
|
bot_db: MagicData("bot_db"),
|
||||||
settings: MagicData("settings")
|
settings: MagicData("settings"),
|
||||||
):
|
):
|
||||||
logger.info(f"Пользователь {message.from_user.id} ({message.from_user.full_name}): вызывается функция start")
|
logger.info(
|
||||||
|
f"Пользователь {message.from_user.id} ({message.from_user.full_name}): вызывается функция start"
|
||||||
|
)
|
||||||
await state.set_state(STATE_START)
|
await state.set_state(STATE_START)
|
||||||
await message.forward(chat_id=settings['Telegram']['group_for_logs'])
|
await message.forward(chat_id=settings["Telegram"]["group_for_logs"])
|
||||||
await update_user_info(VOICE_BOT_NAME, message)
|
await update_user_info(VOICE_BOT_NAME, message)
|
||||||
user_emoji = await get_user_emoji_safe(bot_db, message.from_user.id)
|
user_emoji = await get_user_emoji_safe(bot_db, message.from_user.id)
|
||||||
|
|
||||||
# Создаем сервис и отправляем приветственные сообщения
|
# Создаем сервис и отправляем приветственные сообщения
|
||||||
voice_service = VoiceBotService(bot_db, settings)
|
voice_service = VoiceBotService(bot_db, settings)
|
||||||
await voice_service.send_welcome_messages(message, user_emoji)
|
await voice_service.send_welcome_messages(message, user_emoji)
|
||||||
logger.info(f"Приветственные сообщения отправлены пользователю {message.from_user.id}")
|
logger.info(
|
||||||
|
f"Приветственные сообщения отправлены пользователю {message.from_user.id}"
|
||||||
|
)
|
||||||
|
|
||||||
# Отмечаем, что пользователь получил приветственное сообщение
|
# Отмечаем, что пользователь получил приветственное сообщение
|
||||||
try:
|
try:
|
||||||
await bot_db.mark_voice_bot_welcome_received(message.from_user.id)
|
await bot_db.mark_voice_bot_welcome_received(message.from_user.id)
|
||||||
logger.info(f"Пользователь {message.from_user.id}: отмечен как получивший приветствие")
|
logger.info(
|
||||||
|
f"Пользователь {message.from_user.id}: отмечен как получивший приветствие"
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при отметке получения приветствия для пользователя {message.from_user.id}: {e}")
|
logger.error(
|
||||||
|
f"Ошибка при отметке получения приветствия для пользователя {message.from_user.id}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
@track_time("cancel_handler", "voice_handlers")
|
@track_time("cancel_handler", "voice_handlers")
|
||||||
@track_errors("voice_handlers", "cancel_handler")
|
@track_errors("voice_handlers", "cancel_handler")
|
||||||
async def cancel_handler(
|
async def cancel_handler(
|
||||||
self,
|
self,
|
||||||
message: types.Message,
|
message: types.Message,
|
||||||
state: FSMContext,
|
state: FSMContext,
|
||||||
bot_db: MagicData("bot_db"),
|
bot_db: MagicData("bot_db"),
|
||||||
settings: MagicData("settings")
|
settings: MagicData("settings"),
|
||||||
):
|
):
|
||||||
"""Обработчик кнопки 'Отменить' - возвращает в начальное состояние"""
|
"""Обработчик кнопки 'Отменить' - возвращает в начальное состояние"""
|
||||||
await message.forward(chat_id=settings['Telegram']['group_for_logs'])
|
await message.forward(chat_id=settings["Telegram"]["group_for_logs"])
|
||||||
await update_user_info(VOICE_BOT_NAME, message)
|
await update_user_info(VOICE_BOT_NAME, message)
|
||||||
markup = await get_reply_keyboard(self.db, message.from_user.id)
|
markup = await get_reply_keyboard(self.db, message.from_user.id)
|
||||||
await message.answer(text='Добро пожаловать в меню!', reply_markup=markup, parse_mode='HTML')
|
await message.answer(
|
||||||
|
text="Добро пожаловать в меню!", reply_markup=markup, parse_mode="HTML"
|
||||||
|
)
|
||||||
await state.set_state(FSM_STATES["START"])
|
await state.set_state(FSM_STATES["START"])
|
||||||
logger.info(f"Пользователь {message.from_user.id} возвращен в главное меню")
|
logger.info(f"Пользователь {message.from_user.id} возвращен в главное меню")
|
||||||
|
|
||||||
@@ -244,208 +277,253 @@ class VoiceHandlers:
|
|||||||
@track_errors("voice_handlers", "refresh_listen_function")
|
@track_errors("voice_handlers", "refresh_listen_function")
|
||||||
async def refresh_listen_function(
|
async def refresh_listen_function(
|
||||||
self,
|
self,
|
||||||
message: types.Message,
|
message: types.Message,
|
||||||
state: FSMContext,
|
state: FSMContext,
|
||||||
bot_db: MagicData("bot_db"),
|
bot_db: MagicData("bot_db"),
|
||||||
settings: MagicData("settings")
|
settings: MagicData("settings"),
|
||||||
):
|
):
|
||||||
logger.info(f"Пользователь {message.from_user.id} ({message.from_user.full_name}) вызвал функцию refresh_listen_function")
|
logger.info(
|
||||||
await message.forward(chat_id=settings['Telegram']['group_for_logs'])
|
f"Пользователь {message.from_user.id} ({message.from_user.full_name}) вызвал функцию refresh_listen_function"
|
||||||
|
)
|
||||||
|
await message.forward(chat_id=settings["Telegram"]["group_for_logs"])
|
||||||
await update_user_info(VOICE_BOT_NAME, message)
|
await update_user_info(VOICE_BOT_NAME, message)
|
||||||
markup = get_main_keyboard()
|
markup = get_main_keyboard()
|
||||||
|
|
||||||
# Очищаем прослушивания через сервис
|
# Очищаем прослушивания через сервис
|
||||||
voice_service = VoiceBotService(bot_db, settings)
|
voice_service = VoiceBotService(bot_db, settings)
|
||||||
await voice_service.clear_user_listenings(message.from_user.id)
|
await voice_service.clear_user_listenings(message.from_user.id)
|
||||||
|
|
||||||
listenings_cleared_message = messages.get_message(get_first_name(message), 'LISTENINGS_CLEARED_MESSAGE')
|
listenings_cleared_message = messages.get_message(
|
||||||
|
get_first_name(message), "LISTENINGS_CLEARED_MESSAGE"
|
||||||
|
)
|
||||||
await message.answer(
|
await message.answer(
|
||||||
text=listenings_cleared_message,
|
text=listenings_cleared_message,
|
||||||
disable_web_page_preview=not settings['Telegram']['preview_link'],
|
disable_web_page_preview=not settings["Telegram"]["preview_link"],
|
||||||
reply_markup=markup
|
reply_markup=markup,
|
||||||
)
|
)
|
||||||
await state.set_state(STATE_START)
|
await state.set_state(STATE_START)
|
||||||
|
|
||||||
|
|
||||||
@track_time("standup_write", "voice_handlers")
|
@track_time("standup_write", "voice_handlers")
|
||||||
@track_errors("voice_handlers", "standup_write")
|
@track_errors("voice_handlers", "standup_write")
|
||||||
async def standup_write(
|
async def standup_write(
|
||||||
self,
|
self,
|
||||||
message: types.Message,
|
message: types.Message,
|
||||||
state: FSMContext,
|
state: FSMContext,
|
||||||
bot_db: MagicData("bot_db"),
|
bot_db: MagicData("bot_db"),
|
||||||
settings: MagicData("settings")
|
settings: MagicData("settings"),
|
||||||
):
|
):
|
||||||
logger.info(f"Пользователь {message.from_user.id} ({message.from_user.full_name}) вызвал функцию standup_write")
|
logger.info(
|
||||||
await message.forward(chat_id=settings['Telegram']['group_for_logs'])
|
f"Пользователь {message.from_user.id} ({message.from_user.full_name}) вызвал функцию standup_write"
|
||||||
|
)
|
||||||
|
await message.forward(chat_id=settings["Telegram"]["group_for_logs"])
|
||||||
markup = types.ReplyKeyboardRemove()
|
markup = types.ReplyKeyboardRemove()
|
||||||
record_voice_message = messages.get_message(get_first_name(message), 'RECORD_VOICE_MESSAGE')
|
record_voice_message = messages.get_message(
|
||||||
|
get_first_name(message), "RECORD_VOICE_MESSAGE"
|
||||||
|
)
|
||||||
await message.answer(text=record_voice_message, reply_markup=markup)
|
await message.answer(text=record_voice_message, reply_markup=markup)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
message_with_date = await get_last_message_text(bot_db)
|
message_with_date = await get_last_message_text(bot_db)
|
||||||
if message_with_date:
|
if message_with_date:
|
||||||
await message.answer(text=message_with_date, parse_mode="html")
|
await message.answer(text=message_with_date, parse_mode="html")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Не удалось получить дату последнего сообщения для пользователя {message.from_user.id}: {e}')
|
logger.error(
|
||||||
|
f"Не удалось получить дату последнего сообщения для пользователя {message.from_user.id}: {e}"
|
||||||
await state.set_state(STATE_STANDUP_WRITE)
|
)
|
||||||
|
|
||||||
|
await state.set_state(STATE_STANDUP_WRITE)
|
||||||
|
|
||||||
@track_time("suggest_voice", "voice_handlers")
|
@track_time("suggest_voice", "voice_handlers")
|
||||||
@track_errors("voice_handlers", "suggest_voice")
|
@track_errors("voice_handlers", "suggest_voice")
|
||||||
async def suggest_voice(
|
async def suggest_voice(
|
||||||
self,
|
self,
|
||||||
message: types.Message,
|
message: types.Message,
|
||||||
state: FSMContext,
|
state: FSMContext,
|
||||||
bot_db: MagicData("bot_db"),
|
bot_db: MagicData("bot_db"),
|
||||||
settings: MagicData("settings")
|
settings: MagicData("settings"),
|
||||||
):
|
):
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Вызов функции suggest_voice. Пользователь: {message.from_user.id} Имя автора сообщения: {message.from_user.full_name}"
|
f"Вызов функции suggest_voice. Пользователь: {message.from_user.id} Имя автора сообщения: {message.from_user.full_name}"
|
||||||
)
|
)
|
||||||
await message.forward(chat_id=settings['Telegram']['group_for_logs'])
|
await message.forward(chat_id=settings["Telegram"]["group_for_logs"])
|
||||||
markup = get_main_keyboard()
|
markup = get_main_keyboard()
|
||||||
|
|
||||||
if await validate_voice_message(message):
|
if await validate_voice_message(message):
|
||||||
markup_for_voice = get_reply_keyboard_for_voice()
|
markup_for_voice = get_reply_keyboard_for_voice()
|
||||||
|
|
||||||
# Отправляем аудио в приватный канал
|
# Отправляем аудио в приватный канал
|
||||||
sent_message = await send_voice_message(
|
sent_message = await send_voice_message(
|
||||||
settings['Telegram']['group_for_posts'],
|
settings["Telegram"]["group_for_posts"],
|
||||||
message,
|
message,
|
||||||
message.voice.file_id,
|
message.voice.file_id,
|
||||||
markup_for_voice
|
markup_for_voice,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Голосовое сообщение пользователя {message.from_user.id} отправлено в группу постов (message_id: {sent_message.message_id})"
|
||||||
)
|
)
|
||||||
logger.info(f"Голосовое сообщение пользователя {message.from_user.id} отправлено в группу постов (message_id: {sent_message.message_id})")
|
|
||||||
|
|
||||||
# Сохраняем в базу инфо о посте
|
# Сохраняем в базу инфо о посте
|
||||||
await bot_db.set_user_id_and_message_id_for_voice_bot(sent_message.message_id, message.from_user.id)
|
await bot_db.set_user_id_and_message_id_for_voice_bot(
|
||||||
|
sent_message.message_id, message.from_user.id
|
||||||
|
)
|
||||||
|
|
||||||
# Отправляем юзеру ответ и возвращаем его в меню
|
# Отправляем юзеру ответ и возвращаем его в меню
|
||||||
voice_saved_message = messages.get_message(get_first_name(message), 'VOICE_SAVED_MESSAGE')
|
voice_saved_message = messages.get_message(
|
||||||
|
get_first_name(message), "VOICE_SAVED_MESSAGE"
|
||||||
|
)
|
||||||
await message.answer(text=voice_saved_message, reply_markup=markup)
|
await message.answer(text=voice_saved_message, reply_markup=markup)
|
||||||
await state.set_state(STATE_START)
|
await state.set_state(STATE_START)
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Голосовое сообщение пользователя {message.from_user.id} не прошло валидацию")
|
logger.warning(
|
||||||
unknown_content_message = messages.get_message(get_first_name(message), 'UNKNOWN_CONTENT_MESSAGE')
|
f"Голосовое сообщение пользователя {message.from_user.id} не прошло валидацию"
|
||||||
await message.forward(chat_id=settings['Telegram']['group_for_logs'])
|
)
|
||||||
|
unknown_content_message = messages.get_message(
|
||||||
|
get_first_name(message), "UNKNOWN_CONTENT_MESSAGE"
|
||||||
|
)
|
||||||
|
await message.forward(chat_id=settings["Telegram"]["group_for_logs"])
|
||||||
await message.answer(text=unknown_content_message, reply_markup=markup)
|
await message.answer(text=unknown_content_message, reply_markup=markup)
|
||||||
await state.set_state(STATE_STANDUP_WRITE)
|
await state.set_state(STATE_STANDUP_WRITE)
|
||||||
|
|
||||||
|
|
||||||
@track_time("standup_listen_audio", "voice_handlers")
|
@track_time("standup_listen_audio", "voice_handlers")
|
||||||
@track_errors("voice_handlers", "standup_listen_audio")
|
@track_errors("voice_handlers", "standup_listen_audio")
|
||||||
@track_file_operations("voice")
|
@track_file_operations("voice")
|
||||||
@db_query_time("standup_listen_audio", "audio_moderate", "mixed")
|
@db_query_time("standup_listen_audio", "audio_moderate", "mixed")
|
||||||
async def standup_listen_audio(
|
async def standup_listen_audio(
|
||||||
self,
|
self,
|
||||||
message: types.Message,
|
message: types.Message,
|
||||||
bot_db: MagicData("bot_db"),
|
bot_db: MagicData("bot_db"),
|
||||||
settings: MagicData("settings")
|
settings: MagicData("settings"),
|
||||||
):
|
):
|
||||||
logger.info(f"Пользователь {message.from_user.id} ({message.from_user.full_name}) запросил прослушивание аудио")
|
logger.info(
|
||||||
|
f"Пользователь {message.from_user.id} ({message.from_user.full_name}) запросил прослушивание аудио"
|
||||||
|
)
|
||||||
markup = get_main_keyboard()
|
markup = get_main_keyboard()
|
||||||
|
|
||||||
# Создаем сервис для работы с аудио
|
# Создаем сервис для работы с аудио
|
||||||
voice_service = VoiceBotService(bot_db, settings)
|
voice_service = VoiceBotService(bot_db, settings)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
#TODO: удалить логику из хендлера
|
# TODO: удалить логику из хендлера
|
||||||
# Получаем случайное аудио
|
# Получаем случайное аудио
|
||||||
audio_data = await voice_service.get_random_audio(message.from_user.id)
|
audio_data = await voice_service.get_random_audio(message.from_user.id)
|
||||||
|
|
||||||
if not audio_data:
|
if not audio_data:
|
||||||
logger.warning(f"Для пользователя {message.from_user.id} не найдено доступных аудио для прослушивания")
|
logger.warning(
|
||||||
no_audio_message = messages.get_message(get_first_name(message), 'NO_AUDIO_MESSAGE')
|
f"Для пользователя {message.from_user.id} не найдено доступных аудио для прослушивания"
|
||||||
|
)
|
||||||
|
no_audio_message = messages.get_message(
|
||||||
|
get_first_name(message), "NO_AUDIO_MESSAGE"
|
||||||
|
)
|
||||||
await message.answer(text=no_audio_message, reply_markup=markup)
|
await message.answer(text=no_audio_message, reply_markup=markup)
|
||||||
try:
|
try:
|
||||||
message_with_date = await get_last_message_text(bot_db)
|
message_with_date = await get_last_message_text(bot_db)
|
||||||
if message_with_date:
|
if message_with_date:
|
||||||
await message.answer(text=message_with_date, parse_mode="html")
|
await message.answer(text=message_with_date, parse_mode="html")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Не удалось получить последнюю дату для пользователя {message.from_user.id}: {e}')
|
logger.error(
|
||||||
|
f"Не удалось получить последнюю дату для пользователя {message.from_user.id}: {e}"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
audio_for_user, date_added, user_emoji = audio_data
|
audio_for_user, date_added, user_emoji = audio_data
|
||||||
|
|
||||||
# Получаем путь к файлу
|
# Получаем путь к файлу
|
||||||
path = Path(f'{VOICE_USERS_DIR}/{audio_for_user}.ogg')
|
path = Path(f"{VOICE_USERS_DIR}/{audio_for_user}.ogg")
|
||||||
|
|
||||||
# Проверяем существование файла
|
# Проверяем существование файла
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
logger.error(f"Файл не найден: {path} для пользователя {message.from_user.id}")
|
logger.error(
|
||||||
|
f"Файл не найден: {path} для пользователя {message.from_user.id}"
|
||||||
|
)
|
||||||
# Дополнительная диагностика
|
# Дополнительная диагностика
|
||||||
logger.error(f"Директория {VOICE_USERS_DIR} существует: {Path(VOICE_USERS_DIR).exists()}")
|
logger.error(
|
||||||
|
f"Директория {VOICE_USERS_DIR} существует: {Path(VOICE_USERS_DIR).exists()}"
|
||||||
|
)
|
||||||
if Path(VOICE_USERS_DIR).exists():
|
if Path(VOICE_USERS_DIR).exists():
|
||||||
files_in_dir = list(Path(VOICE_USERS_DIR).glob("*.ogg"))
|
files_in_dir = list(Path(VOICE_USERS_DIR).glob("*.ogg"))
|
||||||
logger.error(f"Файлы в директории: {[f.name for f in files_in_dir]}")
|
logger.error(
|
||||||
|
f"Файлы в директории: {[f.name for f in files_in_dir]}"
|
||||||
|
)
|
||||||
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
text="Файл аудио не найден. Обратитесь к администратору.",
|
text="Файл аудио не найден. Обратитесь к администратору.",
|
||||||
reply_markup=markup
|
reply_markup=markup,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Проверяем размер файла
|
# Проверяем размер файла
|
||||||
if path.stat().st_size == 0:
|
if path.stat().st_size == 0:
|
||||||
logger.error(f"Файл пустой: {path} для пользователя {message.from_user.id}")
|
logger.error(
|
||||||
|
f"Файл пустой: {path} для пользователя {message.from_user.id}"
|
||||||
|
)
|
||||||
await message.answer(
|
await message.answer(
|
||||||
text="Файл аудио поврежден. Обратитесь к администратору.",
|
text="Файл аудио поврежден. Обратитесь к администратору.",
|
||||||
reply_markup=markup
|
reply_markup=markup,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
voice = FSInputFile(path)
|
voice = FSInputFile(path)
|
||||||
|
|
||||||
# Формируем подпись
|
# Формируем подпись
|
||||||
if user_emoji:
|
if user_emoji:
|
||||||
caption = f'{user_emoji}\nДата записи: {date_added}'
|
caption = f"{user_emoji}\nДата записи: {date_added}"
|
||||||
else:
|
else:
|
||||||
caption = f'Дата записи: {date_added}'
|
caption = f"Дата записи: {date_added}"
|
||||||
|
|
||||||
logger.info(f"Подготовлено голосовое сообщение для пользователя {message.from_user.id}: {caption}")
|
logger.info(
|
||||||
|
f"Подготовлено голосовое сообщение для пользователя {message.from_user.id}: {caption}"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from helper_bot.utils.rate_limiter import send_with_rate_limit
|
from helper_bot.utils.rate_limiter import send_with_rate_limit
|
||||||
|
|
||||||
async def _send_voice():
|
async def _send_voice():
|
||||||
return await message.bot.send_voice(
|
return await message.bot.send_voice(
|
||||||
chat_id=message.chat.id,
|
chat_id=message.chat.id,
|
||||||
voice=voice,
|
voice=voice,
|
||||||
caption=caption,
|
caption=caption,
|
||||||
reply_markup=markup
|
reply_markup=markup,
|
||||||
)
|
)
|
||||||
|
|
||||||
await send_with_rate_limit(_send_voice, message.chat.id)
|
await send_with_rate_limit(_send_voice, message.chat.id)
|
||||||
|
|
||||||
# Маркируем сообщение как прослушанное только после успешной отправки
|
# Маркируем сообщение как прослушанное только после успешной отправки
|
||||||
await voice_service.mark_audio_as_listened(audio_for_user, message.from_user.id)
|
await voice_service.mark_audio_as_listened(
|
||||||
|
audio_for_user, message.from_user.id
|
||||||
# Получаем количество оставшихся аудио только после успешной отправки
|
|
||||||
remaining_count = await voice_service.get_remaining_audio_count(message.from_user.id)
|
|
||||||
await message.answer(
|
|
||||||
text=f'Осталось непрослушанных: <b>{remaining_count}</b>',
|
|
||||||
reply_markup=markup
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Получаем количество оставшихся аудио только после успешной отправки
|
||||||
|
remaining_count = await voice_service.get_remaining_audio_count(
|
||||||
|
message.from_user.id
|
||||||
|
)
|
||||||
|
await message.answer(
|
||||||
|
text=f"Осталось непрослушанных: <b>{remaining_count}</b>",
|
||||||
|
reply_markup=markup,
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as voice_error:
|
except Exception as voice_error:
|
||||||
if "VOICE_MESSAGES_FORBIDDEN" in str(voice_error):
|
if "VOICE_MESSAGES_FORBIDDEN" in str(voice_error):
|
||||||
# Если голосовые сообщения запрещены, отправляем информативное сообщение
|
# Если голосовые сообщения запрещены, отправляем информативное сообщение
|
||||||
logger.warning(f"Пользователь {message.from_user.id} запретил получение голосовых сообщений")
|
logger.warning(
|
||||||
|
f"Пользователь {message.from_user.id} запретил получение голосовых сообщений"
|
||||||
|
)
|
||||||
|
|
||||||
privacy_message = "🔇 К сожалению, у тебя закрыт доступ к получению голосовых сообщений.\n\nДля продолжения взаимодействия с ботом необходимо дать возможность мне присылать войсы в настройках приватности Telegram.\n\n💡 Как это сделать:\n1. Открой настройки Telegram\n2. Перейди в 'Конфиденциальность и безопасность'\n3. Выбери 'Голосовые сообщения'\n4. Разреши получение от 'Всех' или добавь меня в исключения"
|
privacy_message = "🔇 К сожалению, у тебя закрыт доступ к получению голосовых сообщений.\n\nДля продолжения взаимодействия с ботом необходимо дать возможность мне присылать войсы в настройках приватности Telegram.\n\n💡 Как это сделать:\n1. Открой настройки Telegram\n2. Перейди в 'Конфиденциальность и безопасность'\n3. Выбери 'Голосовые сообщения'\n4. Разреши получение от 'Всех' или добавь меня в исключения"
|
||||||
|
|
||||||
await message.answer(text=privacy_message, reply_markup=markup)
|
await message.answer(text=privacy_message, reply_markup=markup)
|
||||||
return # Выходим без записи о прослушивании
|
return # Выходим без записи о прослушивании
|
||||||
|
|
||||||
else:
|
else:
|
||||||
logger.error(f"Ошибка при отправке голосового сообщения пользователю {message.from_user.id}: {voice_error}")
|
logger.error(
|
||||||
|
f"Ошибка при отправке голосового сообщения пользователю {message.from_user.id}: {voice_error}"
|
||||||
|
)
|
||||||
raise voice_error
|
raise voice_error
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при прослушивании аудио для пользователя {message.from_user.id}: {e}")
|
logger.error(
|
||||||
|
f"Ошибка при прослушивании аудио для пользователя {message.from_user.id}: {e}"
|
||||||
|
)
|
||||||
await message.answer(
|
await message.answer(
|
||||||
text="Произошла ошибка при получении аудио. Попробуйте позже.",
|
text="Произошла ошибка при получении аудио. Попробуйте позже.",
|
||||||
reply_markup=markup
|
reply_markup=markup,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
from .keyboards import get_reply_keyboard_for_post, get_reply_keyboard
|
from .keyboards import get_reply_keyboard, get_reply_keyboard_for_post
|
||||||
|
|||||||
@@ -1,28 +1,21 @@
|
|||||||
from aiogram import types
|
from aiogram import types
|
||||||
from aiogram.utils.keyboard import ReplyKeyboardBuilder, InlineKeyboardBuilder
|
from aiogram.utils.keyboard import InlineKeyboardBuilder, ReplyKeyboardBuilder
|
||||||
|
|
||||||
# Local imports - metrics
|
# Local imports - metrics
|
||||||
from helper_bot.utils.metrics import (
|
from helper_bot.utils.metrics import track_errors, track_time
|
||||||
track_time,
|
|
||||||
track_errors
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_reply_keyboard_for_post():
|
def get_reply_keyboard_for_post():
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
builder.row(types.InlineKeyboardButton(
|
builder.row(
|
||||||
text="Опубликовать", callback_data="publish"),
|
types.InlineKeyboardButton(text="Опубликовать", callback_data="publish"),
|
||||||
types.InlineKeyboardButton(
|
types.InlineKeyboardButton(text="Отклонить", callback_data="decline"),
|
||||||
text="Отклонить", callback_data="decline")
|
|
||||||
)
|
|
||||||
builder.row(types.InlineKeyboardButton(
|
|
||||||
text="👮♂️ Забанить", callback_data="ban")
|
|
||||||
)
|
)
|
||||||
|
builder.row(types.InlineKeyboardButton(text="👮♂️ Забанить", callback_data="ban"))
|
||||||
markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True)
|
markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True)
|
||||||
return markup
|
return markup
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def get_reply_keyboard(db, user_id):
|
async def get_reply_keyboard(db, user_id):
|
||||||
builder = ReplyKeyboardBuilder()
|
builder = ReplyKeyboardBuilder()
|
||||||
builder.row(types.KeyboardButton(text="📢Предложить свой пост"))
|
builder.row(types.KeyboardButton(text="📢Предложить свой пост"))
|
||||||
@@ -47,18 +40,75 @@ def get_reply_keyboard_admin():
|
|||||||
builder.row(
|
builder.row(
|
||||||
types.KeyboardButton(text="Бан (Список)"),
|
types.KeyboardButton(text="Бан (Список)"),
|
||||||
types.KeyboardButton(text="Бан по нику"),
|
types.KeyboardButton(text="Бан по нику"),
|
||||||
types.KeyboardButton(text="Бан по ID")
|
types.KeyboardButton(text="Бан по ID"),
|
||||||
)
|
)
|
||||||
builder.row(
|
builder.row(
|
||||||
types.KeyboardButton(text="Разбан (список)"),
|
types.KeyboardButton(text="Разбан (список)"),
|
||||||
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)
|
markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True)
|
||||||
return markup
|
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_time("create_keyboard_with_pagination", "keyboard_service")
|
||||||
@track_errors("keyboard_service", "create_keyboard_with_pagination")
|
@track_errors("keyboard_service", "create_keyboard_with_pagination")
|
||||||
def create_keyboard_with_pagination(page: int, total_items: int, array_items: list, callback: str):
|
def create_keyboard_with_pagination(
|
||||||
|
page: int, total_items: int, array_items: list, callback: str
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Создает клавиатуру с пагинацией для заданного набора элементов и устанавливает необходимый callback
|
Создает клавиатуру с пагинацией для заданного набора элементов и устанавливает необходимый callback
|
||||||
|
|
||||||
@@ -71,74 +121,79 @@ def create_keyboard_with_pagination(page: int, total_items: int, array_items: li
|
|||||||
Returns:
|
Returns:
|
||||||
InlineKeyboardMarkup: Клавиатура с кнопками пагинации.
|
InlineKeyboardMarkup: Клавиатура с кнопками пагинации.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Проверяем валидность входных данных
|
# Проверяем валидность входных данных
|
||||||
if page < 1:
|
if page < 1:
|
||||||
page = 1
|
page = 1
|
||||||
if not array_items:
|
if not array_items:
|
||||||
# Если нет элементов, возвращаем только кнопку "Назад"
|
# Если нет элементов, возвращаем только кнопку "Назад"
|
||||||
keyboard = InlineKeyboardBuilder()
|
keyboard = InlineKeyboardBuilder()
|
||||||
home_button = types.InlineKeyboardButton(text="🏠 Назад", callback_data="return")
|
home_button = types.InlineKeyboardButton(
|
||||||
|
text="🏠 Назад", callback_data="return"
|
||||||
|
)
|
||||||
keyboard.row(home_button)
|
keyboard.row(home_button)
|
||||||
return keyboard.as_markup()
|
return keyboard.as_markup()
|
||||||
|
|
||||||
# Определяем общее количество страниц
|
# Определяем общее количество страниц
|
||||||
items_per_page = 9
|
items_per_page = 9
|
||||||
total_pages = (total_items + items_per_page - 1) // items_per_page
|
total_pages = (total_items + items_per_page - 1) // items_per_page
|
||||||
|
|
||||||
# Ограничиваем страницу максимальным значением
|
# Ограничиваем страницу максимальным значением
|
||||||
if page > total_pages:
|
if page > total_pages:
|
||||||
page = total_pages
|
page = total_pages
|
||||||
|
|
||||||
# Создаем билдер для клавиатуры
|
# Создаем билдер для клавиатуры
|
||||||
keyboard = InlineKeyboardBuilder()
|
keyboard = InlineKeyboardBuilder()
|
||||||
|
|
||||||
# Вычисляем стартовый номер для текущей страницы
|
# Вычисляем стартовый номер для текущей страницы
|
||||||
start_index = (page - 1) * items_per_page
|
start_index = (page - 1) * items_per_page
|
||||||
|
|
||||||
# Кнопки с элементами текущей страницы
|
# Кнопки с элементами текущей страницы
|
||||||
end_index = min(start_index + items_per_page, len(array_items))
|
end_index = min(start_index + items_per_page, len(array_items))
|
||||||
current_row = []
|
current_row = []
|
||||||
|
|
||||||
for i in range(start_index, end_index):
|
for i in range(start_index, end_index):
|
||||||
current_row.append(types.InlineKeyboardButton(
|
current_row.append(
|
||||||
text=f"{array_items[i][0]}", callback_data=f"{callback}_{array_items[i][1]}"
|
types.InlineKeyboardButton(
|
||||||
))
|
text=f"{array_items[i][0]}",
|
||||||
|
callback_data=f"{callback}_{array_items[i][1]}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Когда набирается 3 кнопки, добавляем ряд
|
# Когда набирается 3 кнопки, добавляем ряд
|
||||||
if len(current_row) == 3:
|
if len(current_row) == 3:
|
||||||
keyboard.row(*current_row)
|
keyboard.row(*current_row)
|
||||||
current_row = []
|
current_row = []
|
||||||
|
|
||||||
# Добавляем оставшиеся кнопки, если они есть
|
# Добавляем оставшиеся кнопки, если они есть
|
||||||
if current_row:
|
if current_row:
|
||||||
keyboard.row(*current_row)
|
keyboard.row(*current_row)
|
||||||
|
|
||||||
# Создаем кнопки навигации только если нужно
|
# Создаем кнопки навигации только если нужно
|
||||||
navigation_buttons = []
|
navigation_buttons = []
|
||||||
|
|
||||||
# Кнопка "Предыдущая" - показываем только если не первая страница
|
# Кнопка "Предыдущая" - показываем только если не первая страница
|
||||||
if page > 1:
|
if page > 1:
|
||||||
prev_button = types.InlineKeyboardButton(
|
prev_button = types.InlineKeyboardButton(
|
||||||
text="⬅️ Предыдущая", callback_data=f"page_{page - 1}"
|
text="⬅️ Предыдущая", callback_data=f"page_{page - 1}"
|
||||||
)
|
)
|
||||||
navigation_buttons.append(prev_button)
|
navigation_buttons.append(prev_button)
|
||||||
|
|
||||||
# Кнопка "Следующая" - показываем только если не последняя страница
|
# Кнопка "Следующая" - показываем только если не последняя страница
|
||||||
if page < total_pages:
|
if page < total_pages:
|
||||||
next_button = types.InlineKeyboardButton(
|
next_button = types.InlineKeyboardButton(
|
||||||
text="➡️ Следующая", callback_data=f"page_{page + 1}"
|
text="➡️ Следующая", callback_data=f"page_{page + 1}"
|
||||||
)
|
)
|
||||||
navigation_buttons.append(next_button)
|
navigation_buttons.append(next_button)
|
||||||
|
|
||||||
# Добавляем кнопки навигации, если они есть
|
# Добавляем кнопки навигации, если они есть
|
||||||
if navigation_buttons:
|
if navigation_buttons:
|
||||||
keyboard.row(*navigation_buttons)
|
keyboard.row(*navigation_buttons)
|
||||||
|
|
||||||
# Кнопка "Назад"
|
# Кнопка "Назад"
|
||||||
home_button = types.InlineKeyboardButton(text="🏠 Назад", callback_data="return")
|
home_button = types.InlineKeyboardButton(text="🏠 Назад", callback_data="return")
|
||||||
keyboard.row(home_button)
|
keyboard.row(home_button)
|
||||||
|
|
||||||
return keyboard.as_markup()
|
return keyboard.as_markup()
|
||||||
|
|
||||||
|
|
||||||
@@ -147,7 +202,11 @@ def create_keyboard_for_ban_reason():
|
|||||||
builder.add(types.KeyboardButton(text="Спам"))
|
builder.add(types.KeyboardButton(text="Спам"))
|
||||||
builder.add(types.KeyboardButton(text="Заебал стикерами"))
|
builder.add(types.KeyboardButton(text="Заебал стикерами"))
|
||||||
builder.row(types.KeyboardButton(text="Реклама здесь: @kerrad1 "))
|
builder.row(types.KeyboardButton(text="Реклама здесь: @kerrad1 "))
|
||||||
builder.row(types.KeyboardButton(text="Тема с лагерями: https://vk.com/topic-75343895_50049913"))
|
builder.row(
|
||||||
|
types.KeyboardButton(
|
||||||
|
text="Тема с лагерями: https://vk.com/topic-75343895_50049913"
|
||||||
|
)
|
||||||
|
)
|
||||||
builder.row(types.KeyboardButton(text="Отменить"))
|
builder.row(types.KeyboardButton(text="Отменить"))
|
||||||
markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True)
|
markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True)
|
||||||
return markup
|
return markup
|
||||||
@@ -177,12 +236,12 @@ def get_main_keyboard():
|
|||||||
# Первая строка: Высказаться и послушать
|
# Первая строка: Высказаться и послушать
|
||||||
builder.row(
|
builder.row(
|
||||||
types.KeyboardButton(text="🎤Высказаться"),
|
types.KeyboardButton(text="🎤Высказаться"),
|
||||||
types.KeyboardButton(text="🎧Послушать")
|
types.KeyboardButton(text="🎧Послушать"),
|
||||||
)
|
)
|
||||||
# Вторая строка: сбросить прослушивания и узнать эмодзи
|
# Вторая строка: сбросить прослушивания и узнать эмодзи
|
||||||
builder.row(
|
builder.row(
|
||||||
types.KeyboardButton(text="🔄Сбросить прослушивания"),
|
types.KeyboardButton(text="🔄Сбросить прослушивания"),
|
||||||
types.KeyboardButton(text="😊Узнать эмодзи")
|
types.KeyboardButton(text="😊Узнать эмодзи"),
|
||||||
)
|
)
|
||||||
# Третья строка: Вернуться в меню
|
# Третья строка: Вернуться в меню
|
||||||
builder.row(types.KeyboardButton(text="Отменить"))
|
builder.row(types.KeyboardButton(text="Отменить"))
|
||||||
@@ -192,11 +251,7 @@ def get_main_keyboard():
|
|||||||
|
|
||||||
def get_reply_keyboard_for_voice():
|
def get_reply_keyboard_for_voice():
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
builder.row(types.InlineKeyboardButton(
|
builder.row(types.InlineKeyboardButton(text="Сохранить", callback_data="save"))
|
||||||
text="Сохранить", callback_data="save")
|
builder.row(types.InlineKeyboardButton(text="Удалить", callback_data="delete"))
|
||||||
)
|
|
||||||
builder.row(types.InlineKeyboardButton(
|
|
||||||
text="Удалить", callback_data="delete")
|
|
||||||
)
|
|
||||||
markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True)
|
markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True)
|
||||||
return markup
|
return markup
|
||||||
|
|||||||
@@ -1,24 +1,30 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from aiogram import Bot, Dispatcher
|
from aiogram import Bot, Dispatcher
|
||||||
from aiogram.client.default import DefaultBotProperties
|
from aiogram.client.default import DefaultBotProperties
|
||||||
from aiogram.fsm.storage.memory import MemoryStorage
|
from aiogram.fsm.storage.memory import MemoryStorage
|
||||||
from aiogram.fsm.strategy import FSMStrategy
|
from aiogram.fsm.strategy import FSMStrategy
|
||||||
import logging
|
|
||||||
import asyncio
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from helper_bot.handlers.admin import admin_router
|
from helper_bot.handlers.admin import admin_router
|
||||||
from helper_bot.handlers.callback import callback_router
|
from helper_bot.handlers.callback import callback_router
|
||||||
from helper_bot.handlers.group import group_router
|
from helper_bot.handlers.group import group_router
|
||||||
from helper_bot.handlers.private import private_router
|
from helper_bot.handlers.private import private_router
|
||||||
from helper_bot.handlers.voice import VoiceHandlers
|
from helper_bot.handlers.voice import VoiceHandlers
|
||||||
from helper_bot.middlewares.dependencies_middleware import DependenciesMiddleware
|
|
||||||
from helper_bot.middlewares.blacklist_middleware import BlacklistMiddleware
|
from helper_bot.middlewares.blacklist_middleware import BlacklistMiddleware
|
||||||
from helper_bot.middlewares.metrics_middleware import MetricsMiddleware, ErrorMetricsMiddleware
|
from helper_bot.middlewares.dependencies_middleware import DependenciesMiddleware
|
||||||
|
from helper_bot.middlewares.metrics_middleware import (
|
||||||
|
ErrorMetricsMiddleware,
|
||||||
|
MetricsMiddleware,
|
||||||
|
)
|
||||||
from helper_bot.middlewares.rate_limit_middleware import RateLimitMiddleware
|
from helper_bot.middlewares.rate_limit_middleware import RateLimitMiddleware
|
||||||
from helper_bot.server_prometheus import start_metrics_server, stop_metrics_server
|
from helper_bot.server_prometheus import start_metrics_server, stop_metrics_server
|
||||||
|
|
||||||
|
|
||||||
async def start_bot_with_retry(bot: Bot, dp: Dispatcher, max_retries: int = 5, base_delay: float = 1.0):
|
async def start_bot_with_retry(
|
||||||
|
bot: Bot, dp: Dispatcher, max_retries: int = 5, base_delay: float = 1.0
|
||||||
|
):
|
||||||
"""Запуск бота с автоматическим перезапуском при сетевых ошибках"""
|
"""Запуск бота с автоматическим перезапуском при сетевых ошибках"""
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
try:
|
try:
|
||||||
@@ -27,14 +33,21 @@ async def start_bot_with_retry(bot: Bot, dp: Dispatcher, max_retries: int = 5, b
|
|||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = str(e).lower()
|
error_msg = str(e).lower()
|
||||||
if any(keyword in error_msg for keyword in ['network', 'disconnected', 'timeout', 'connection']):
|
if any(
|
||||||
|
keyword in error_msg
|
||||||
|
for keyword in ["network", "disconnected", "timeout", "connection"]
|
||||||
|
):
|
||||||
if attempt < max_retries - 1:
|
if attempt < max_retries - 1:
|
||||||
delay = base_delay * (2 ** attempt) # Exponential backoff
|
delay = base_delay * (2**attempt) # Exponential backoff
|
||||||
logging.warning(f"Сетевая ошибка при запуске бота: {e}. Повтор через {delay:.1f}с (попытка {attempt + 1}/{max_retries})")
|
logging.warning(
|
||||||
|
f"Сетевая ошибка при запуске бота: {e}. Повтор через {delay:.1f}с (попытка {attempt + 1}/{max_retries})"
|
||||||
|
)
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
logging.error(f"Превышено максимальное количество попыток запуска бота: {e}")
|
logging.error(
|
||||||
|
f"Превышено максимальное количество попыток запуска бота: {e}"
|
||||||
|
)
|
||||||
raise
|
raise
|
||||||
else:
|
else:
|
||||||
logging.error(f"Критическая ошибка при запуске бота: {e}")
|
logging.error(f"Критическая ошибка при запуске бота: {e}")
|
||||||
@@ -42,50 +55,67 @@ async def start_bot_with_retry(bot: Bot, dp: Dispatcher, max_retries: int = 5, b
|
|||||||
|
|
||||||
|
|
||||||
async def start_bot(bdf):
|
async def start_bot(bdf):
|
||||||
token = bdf.settings['Telegram']['bot_token']
|
token = bdf.settings["Telegram"]["bot_token"]
|
||||||
bot = Bot(token=token, default=DefaultBotProperties(
|
bot = Bot(
|
||||||
parse_mode='HTML',
|
token=token,
|
||||||
link_preview_is_disabled=bdf.settings['Telegram']['preview_link']
|
default=DefaultBotProperties(
|
||||||
), timeout=60.0) # Увеличиваем timeout для стабильности
|
parse_mode="HTML",
|
||||||
|
link_preview_is_disabled=bdf.settings["Telegram"]["preview_link"],
|
||||||
|
),
|
||||||
|
timeout=60.0,
|
||||||
|
) # Увеличиваем timeout для стабильности
|
||||||
|
|
||||||
dp = Dispatcher(storage=MemoryStorage(), fsm_strategy=FSMStrategy.GLOBAL_USER)
|
dp = Dispatcher(storage=MemoryStorage(), fsm_strategy=FSMStrategy.GLOBAL_USER)
|
||||||
|
|
||||||
# ✅ Оптимизированная регистрация middleware
|
# ✅ Оптимизированная регистрация middleware
|
||||||
dp.update.outer_middleware(DependenciesMiddleware())
|
dp.update.outer_middleware(DependenciesMiddleware())
|
||||||
dp.update.outer_middleware(MetricsMiddleware())
|
dp.update.outer_middleware(MetricsMiddleware())
|
||||||
dp.update.outer_middleware(BlacklistMiddleware())
|
dp.update.outer_middleware(BlacklistMiddleware())
|
||||||
dp.update.outer_middleware(RateLimitMiddleware())
|
dp.update.outer_middleware(RateLimitMiddleware())
|
||||||
|
|
||||||
# Создаем экземпляр VoiceHandlers
|
# Создаем экземпляр VoiceHandlers
|
||||||
voice_handlers = VoiceHandlers(bdf, bdf.settings)
|
voice_handlers = VoiceHandlers(bdf, bdf.settings)
|
||||||
voice_router = voice_handlers.router
|
voice_router = voice_handlers.router
|
||||||
|
|
||||||
# Middleware уже добавлены на уровне dispatcher
|
# Middleware уже добавлены на уровне dispatcher
|
||||||
dp.include_routers(admin_router, private_router, callback_router, group_router, voice_router)
|
dp.include_routers(
|
||||||
|
admin_router, private_router, callback_router, group_router, voice_router
|
||||||
|
)
|
||||||
|
|
||||||
|
# Получаем scoring_manager для использования в shutdown
|
||||||
|
scoring_manager = bdf.get_scoring_manager()
|
||||||
|
|
||||||
# Добавляем обработчик завершения для корректного закрытия
|
# Добавляем обработчик завершения для корректного закрытия
|
||||||
@dp.shutdown()
|
@dp.shutdown()
|
||||||
async def on_shutdown():
|
async def on_shutdown():
|
||||||
logging.info("Bot shutdown initiated, cleaning up resources...")
|
logging.info("Bot shutdown initiated, cleaning up resources...")
|
||||||
try:
|
try:
|
||||||
|
# Закрываем ресурсы ScoringManager
|
||||||
|
if scoring_manager:
|
||||||
|
try:
|
||||||
|
await scoring_manager.close()
|
||||||
|
logging.info("ScoringManager закрыт")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Ошибка закрытия ScoringManager: {e}")
|
||||||
|
|
||||||
await bot.session.close()
|
await bot.session.close()
|
||||||
logging.info("Bot session closed successfully")
|
logging.info("Bot session closed successfully")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error closing bot session during shutdown: {e}")
|
logging.error(f"Error closing bot session during shutdown: {e}")
|
||||||
|
|
||||||
await bot.delete_webhook(drop_pending_updates=True)
|
await bot.delete_webhook(drop_pending_updates=True)
|
||||||
|
|
||||||
# Запускаем HTTP сервер для метрик параллельно с ботом
|
# Запускаем HTTP сервер для метрик параллельно с ботом
|
||||||
metrics_host = bdf.settings.get('Metrics', {}).get('host', '0.0.0.0')
|
metrics_host = bdf.settings.get("Metrics", {}).get("host", "0.0.0.0")
|
||||||
metrics_port = bdf.settings.get('Metrics', {}).get('port', 8080)
|
metrics_port = bdf.settings.get("Metrics", {}).get("port", 8080)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Запускаем метрики сервер
|
# Запускаем метрики сервер
|
||||||
await start_metrics_server(metrics_host, metrics_port)
|
await start_metrics_server(metrics_host, metrics_port)
|
||||||
|
|
||||||
logging.info(f"✅ Метрики сервер запущен на {metrics_host}:{metrics_port}")
|
logging.info(f"✅ Метрики сервер запущен на {metrics_host}:{metrics_port}")
|
||||||
logging.info("✅ Метрики будут обновляться в реальном времени через middleware")
|
logging.info("✅ Метрики будут обновляться в реальном времени через middleware")
|
||||||
|
|
||||||
# Запускаем бота с retry логикой
|
# Запускаем бота с retry логикой
|
||||||
await start_bot_with_retry(bot, dp)
|
await start_bot_with_retry(bot, dp)
|
||||||
|
|
||||||
@@ -94,12 +124,20 @@ async def start_bot(bdf):
|
|||||||
logging.error(f"❌ Ошибка запуска бота: {e}")
|
logging.error(f"❌ Ошибка запуска бота: {e}")
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
|
# Закрываем ресурсы ScoringManager перед завершением (на случай если shutdown не сработал)
|
||||||
|
if scoring_manager:
|
||||||
|
try:
|
||||||
|
await scoring_manager.close()
|
||||||
|
logging.info("ScoringManager закрыт в finally")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Ошибка закрытия ScoringManager в finally: {e}")
|
||||||
|
|
||||||
# Останавливаем метрики сервер при завершении
|
# Останавливаем метрики сервер при завершении
|
||||||
try:
|
try:
|
||||||
await stop_metrics_server()
|
await stop_metrics_server()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error stopping metrics server: {e}")
|
logging.error(f"Error stopping metrics server: {e}")
|
||||||
|
|
||||||
# Закрываем сессию бота
|
# Закрываем сессию бота
|
||||||
try:
|
try:
|
||||||
await bot.session.close()
|
await bot.session.close()
|
||||||
|
|||||||
@@ -1,82 +1,167 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from typing import Any, Dict, Union, List
|
from typing import Any, Dict, List, Optional, Union
|
||||||
|
|
||||||
from aiogram import BaseMiddleware
|
from aiogram import BaseMiddleware
|
||||||
from aiogram.types import Message
|
from aiogram.types import Message
|
||||||
|
|
||||||
|
|
||||||
|
class AlbumGetter:
|
||||||
|
"""Вспомогательный класс для получения полной медиагруппы из middleware"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, album_data: Dict[str, Any], media_group_id: str, event: asyncio.Event
|
||||||
|
):
|
||||||
|
self.album_data = album_data
|
||||||
|
self.media_group_id = media_group_id
|
||||||
|
self.event = event
|
||||||
|
|
||||||
|
async def get_album(self, timeout: float = 10.0) -> Optional[List[Message]]:
|
||||||
|
"""
|
||||||
|
Ждет полную медиагруппу и возвращает ее.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timeout: Максимальное время ожидания в секундах
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Список сообщений медиагруппы или None при таймауте
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self.event.wait(), timeout=timeout)
|
||||||
|
if self.media_group_id in self.album_data:
|
||||||
|
return self.album_data[self.media_group_id].get("collected_album")
|
||||||
|
return None
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class AlbumMiddleware(BaseMiddleware):
|
class AlbumMiddleware(BaseMiddleware):
|
||||||
"""
|
"""
|
||||||
Middleware для обработки медиа групп в Telegram.
|
Middleware для обработки медиа групп в Telegram.
|
||||||
Собирает все сообщения одной медиа группы и передает их как album в data.
|
Собирает все сообщения одной медиа группы и передает их как album в data.
|
||||||
|
Не блокирует handler - сразу вызывает его, а полную медиагруппу передает через Event.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, latency: Union[int, float] = 0.01):
|
def __init__(self, latency: Union[int, float] = 5.0):
|
||||||
"""
|
"""
|
||||||
Инициализация middleware.
|
Инициализация middleware.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
latency: Задержка в секундах для сбора всех сообщений медиа группы
|
latency: Задержка в секундах для сбора всех сообщений медиа группы
|
||||||
"""
|
"""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.latency = latency
|
self.latency = latency
|
||||||
self.album_data: Dict[str, Dict[str, List[Message]]] = {}
|
# Храним данные медиагруппы: messages, event для уведомления, task для сбора
|
||||||
|
self.album_data: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
def collect_album_messages(self, event: Message) -> int:
|
def collect_album_messages(self, event: Message) -> int:
|
||||||
"""
|
"""
|
||||||
Собирает сообщения одной медиа группы.
|
Собирает сообщения одной медиа группы.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
event: Сообщение для обработки
|
event: Сообщение для обработки
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Количество сообщений в текущей медиа группе
|
Количество сообщений в текущей медиа группе
|
||||||
"""
|
"""
|
||||||
if not event.media_group_id:
|
if not event.media_group_id:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
if event.media_group_id not in self.album_data:
|
if event.media_group_id not in self.album_data:
|
||||||
self.album_data[event.media_group_id] = {"messages": []}
|
self.album_data[event.media_group_id] = {"messages": []}
|
||||||
|
|
||||||
self.album_data[event.media_group_id]["messages"].append(event)
|
self.album_data[event.media_group_id]["messages"].append(event)
|
||||||
return len(self.album_data[event.media_group_id]["messages"])
|
return len(self.album_data[event.media_group_id]["messages"])
|
||||||
|
|
||||||
|
async def _collect_album_background(self, media_group_id: str) -> None:
|
||||||
|
"""
|
||||||
|
Фоновая задача для сбора всех сообщений медиагруппы.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
media_group_id: ID медиагруппы для сбора
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(self.latency)
|
||||||
|
|
||||||
|
if media_group_id not in self.album_data:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Получаем текущий список сообщений
|
||||||
|
album_messages = self.album_data[media_group_id]["messages"].copy()
|
||||||
|
album_messages.sort(key=lambda x: x.message_id)
|
||||||
|
|
||||||
|
# Сохраняем собранную медиагруппу и уведомляем через Event
|
||||||
|
self.album_data[media_group_id]["collected_album"] = album_messages
|
||||||
|
self.album_data[media_group_id]["event"].set()
|
||||||
|
|
||||||
|
# Очищаем данные после небольшой задержки (чтобы handler успел получить album)
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
if media_group_id in self.album_data:
|
||||||
|
task = self.album_data[media_group_id].get("task")
|
||||||
|
if task and not task.done():
|
||||||
|
task.cancel()
|
||||||
|
del self.album_data[media_group_id]
|
||||||
|
except Exception:
|
||||||
|
# В случае ошибки все равно уведомляем, чтобы handler не завис
|
||||||
|
if media_group_id in self.album_data:
|
||||||
|
self.album_data[media_group_id]["event"].set()
|
||||||
|
# Очищаем данные даже при ошибке
|
||||||
|
try:
|
||||||
|
task = self.album_data[media_group_id].get("task")
|
||||||
|
if task and not task.done():
|
||||||
|
task.cancel()
|
||||||
|
del self.album_data[media_group_id]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
async def __call__(self, handler, event: Message, data: Dict[str, Any]) -> Any:
|
async def __call__(self, handler, event: Message, data: Dict[str, Any]) -> Any:
|
||||||
"""
|
"""
|
||||||
Основная логика middleware.
|
Основная логика middleware.
|
||||||
|
|
||||||
|
Для медиагрупп: сразу вызывает handler, передавая Event для получения полной медиагруппы.
|
||||||
|
Для обычных сообщений: сразу вызывает handler.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
handler: Обработчик события
|
handler: Обработчик события
|
||||||
event: Событие (сообщение)
|
event: Событие (сообщение)
|
||||||
data: Данные для передачи в обработчик
|
data: Данные для передачи в обработчик
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Результат выполнения обработчика
|
Результат выполнения обработчика
|
||||||
"""
|
"""
|
||||||
# Если у события нет media_group_id, передаем его обработчику сразу
|
|
||||||
if not event.media_group_id:
|
if not event.media_group_id:
|
||||||
return await handler(event, data)
|
return await handler(event, data)
|
||||||
|
|
||||||
# Собираем сообщения одной медиа группы
|
media_group_id = event.media_group_id
|
||||||
total_before = self.collect_album_messages(event)
|
message_id = event.message_id
|
||||||
|
|
||||||
# Ждем указанный период для сбора всех сообщений
|
# Если это первое сообщение медиагруппы - создаем структуру данных
|
||||||
await asyncio.sleep(self.latency)
|
is_first_message = False
|
||||||
|
if media_group_id not in self.album_data:
|
||||||
# Проверяем количество сообщений после задержки
|
is_first_message = True
|
||||||
total_after = len(self.album_data[event.media_group_id]["messages"])
|
album_event = asyncio.Event()
|
||||||
|
self.album_data[media_group_id] = {
|
||||||
# Если за время задержки добавились новые сообщения, выходим
|
"messages": [],
|
||||||
if total_before != total_after:
|
"event": album_event,
|
||||||
|
"task": None,
|
||||||
|
"first_message_id": message_id,
|
||||||
|
}
|
||||||
|
# Запускаем фоновую задачу для сбора медиагруппы
|
||||||
|
task = asyncio.create_task(self._collect_album_background(media_group_id))
|
||||||
|
self.album_data[media_group_id]["task"] = task
|
||||||
|
|
||||||
|
# Добавляем сообщение в медиагруппу
|
||||||
|
self.album_data[media_group_id]["messages"].append(event)
|
||||||
|
|
||||||
|
# Обрабатываем только первое сообщение медиагруппы
|
||||||
|
if not is_first_message:
|
||||||
|
# Для остальных сообщений просто возвращаемся, не вызывая handler
|
||||||
return
|
return
|
||||||
|
|
||||||
# Сортируем сообщения по message_id и добавляем в data
|
# Передаем объект-геттер в data, чтобы handler мог получить полную медиагруппу
|
||||||
album_messages = self.album_data[event.media_group_id]["messages"]
|
album_getter = AlbumGetter(
|
||||||
album_messages.sort(key=lambda x: x.message_id)
|
self.album_data, media_group_id, self.album_data[media_group_id]["event"]
|
||||||
data["album"] = album_messages
|
)
|
||||||
|
data["album_getter"] = album_getter
|
||||||
# Удаляем медиа группу из отслеживания для освобождения памяти
|
|
||||||
del self.album_data[event.media_group_id]
|
# Сразу вызываем handler только для первого сообщения (не блокируем)
|
||||||
|
|
||||||
# Вызываем оригинальный обработчик события
|
|
||||||
return await handler(event, data)
|
return await handler(event, data)
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
from typing import Dict, Any
|
|
||||||
import html
|
import html
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
from aiogram import BaseMiddleware, types
|
from aiogram import BaseMiddleware, types
|
||||||
from aiogram.types import TelegramObject, Message, CallbackQuery
|
from aiogram.types import CallbackQuery, Message, TelegramObject
|
||||||
|
|
||||||
from helper_bot.utils.base_dependency_factory import get_global_instance
|
from helper_bot.utils.base_dependency_factory import get_global_instance
|
||||||
from logs.custom_logger import logger
|
from logs.custom_logger import logger
|
||||||
|
|
||||||
@@ -12,47 +13,61 @@ BotDB = bdf.get_db()
|
|||||||
|
|
||||||
|
|
||||||
class BlacklistMiddleware(BaseMiddleware):
|
class BlacklistMiddleware(BaseMiddleware):
|
||||||
async def __call__(self, handler, event: TelegramObject, data: Dict[str, Any]) -> Any:
|
async def __call__(
|
||||||
|
self, handler, event: TelegramObject, data: Dict[str, Any]
|
||||||
|
) -> Any:
|
||||||
# Проверяем тип события и получаем пользователя
|
# Проверяем тип события и получаем пользователя
|
||||||
user = None
|
user = None
|
||||||
if isinstance(event, Message):
|
if isinstance(event, Message):
|
||||||
user = event.from_user
|
user = event.from_user
|
||||||
elif isinstance(event, CallbackQuery):
|
elif isinstance(event, CallbackQuery):
|
||||||
user = event.from_user
|
user = event.from_user
|
||||||
|
|
||||||
# Если это не сообщение или callback, пропускаем проверку
|
# Если это не сообщение или callback, пропускаем проверку
|
||||||
if not user:
|
if not user:
|
||||||
return await handler(event, data)
|
return await handler(event, data)
|
||||||
|
|
||||||
logger.info(f'Вызов BlacklistMiddleware для пользователя {user.username}')
|
logger.info(f"Вызов BlacklistMiddleware для пользователя {user.username}")
|
||||||
|
|
||||||
# Используем асинхронную версию для предотвращения блокировки
|
# Используем асинхронную версию для предотвращения блокировки
|
||||||
if await BotDB.check_user_in_blacklist(user.id):
|
if await BotDB.check_user_in_blacklist(user.id):
|
||||||
logger.info(f'BlacklistMiddleware результат для пользователя: {user.username} заблокирован!')
|
logger.info(
|
||||||
|
f"BlacklistMiddleware результат для пользователя: {user.username} заблокирован!"
|
||||||
|
)
|
||||||
user_info = await BotDB.get_blacklist_users_by_id(user.id)
|
user_info = await BotDB.get_blacklist_users_by_id(user.id)
|
||||||
# Экранируем потенциально проблемные символы
|
# Экранируем потенциально проблемные символы
|
||||||
reason = html.escape(str(user_info[1])) if user_info and user_info[1] else "Не указана"
|
reason = (
|
||||||
|
html.escape(str(user_info[1]))
|
||||||
|
if user_info and user_info[1]
|
||||||
|
else "Не указана"
|
||||||
|
)
|
||||||
|
|
||||||
# Преобразуем timestamp в человекочитаемый формат
|
# Преобразуем timestamp в человекочитаемый формат
|
||||||
if user_info and user_info[2]:
|
if user_info and user_info[2]:
|
||||||
try:
|
try:
|
||||||
timestamp = int(user_info[2])
|
timestamp = int(user_info[2])
|
||||||
date_unban = datetime.fromtimestamp(timestamp).strftime("%d-%m-%Y %H:%M")
|
date_unban = datetime.fromtimestamp(timestamp).strftime(
|
||||||
|
"%d-%m-%Y %H:%M"
|
||||||
|
)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
date_unban = "Не указана"
|
date_unban = "Не указана"
|
||||||
else:
|
else:
|
||||||
date_unban = "Не указана"
|
date_unban = "Не указана"
|
||||||
|
|
||||||
# Отправляем сообщение в зависимости от типа события
|
# Отправляем сообщение в зависимости от типа события
|
||||||
if isinstance(event, Message):
|
if isinstance(event, Message):
|
||||||
await event.answer(
|
await event.answer(
|
||||||
f"<b>Ты заблокирован.</b>\n<b>Причина блокировки:</b> {reason}\n<b>Дата разбана:</b> {date_unban}")
|
f"<b>Ты заблокирован.</b>\n<b>Причина блокировки:</b> {reason}\n<b>Дата разбана:</b> {date_unban}"
|
||||||
|
)
|
||||||
elif isinstance(event, CallbackQuery):
|
elif isinstance(event, CallbackQuery):
|
||||||
await event.answer(
|
await event.answer(
|
||||||
f"<b>Ты заблокирован.</b>\n<b>Причина блокировки:</b> {reason}\n<b>Дата разбана:</b> {date_unban}",
|
f"<b>Ты заблокирован.</b>\n<b>Причина блокировки:</b> {reason}\n<b>Дата разбана:</b> {date_unban}",
|
||||||
show_alert=True)
|
show_alert=True,
|
||||||
|
)
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
logger.info(f'BlacklistMiddleware результат для пользователя: {user.username} доступ разрешен')
|
logger.info(
|
||||||
|
f"BlacklistMiddleware результат для пользователя: {user.username} доступ разрешен"
|
||||||
|
)
|
||||||
return await handler(event, data)
|
return await handler(event, data)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from aiogram import BaseMiddleware
|
from aiogram import BaseMiddleware
|
||||||
from aiogram.types import TelegramObject
|
from aiogram.types import TelegramObject
|
||||||
|
|
||||||
@@ -8,24 +9,28 @@ from logs.custom_logger import logger
|
|||||||
|
|
||||||
class DependenciesMiddleware(BaseMiddleware):
|
class DependenciesMiddleware(BaseMiddleware):
|
||||||
"""Универсальная middleware для внедрения зависимостей во все хендлеры"""
|
"""Универсальная middleware для внедрения зависимостей во все хендлеры"""
|
||||||
|
|
||||||
async def __call__(self, handler, event: TelegramObject, data: Dict[str, Any]) -> Any:
|
async def __call__(
|
||||||
|
self, handler, event: TelegramObject, data: Dict[str, Any]
|
||||||
|
) -> Any:
|
||||||
try:
|
try:
|
||||||
# Получаем глобальные зависимости
|
# Получаем глобальные зависимости
|
||||||
bdf = get_global_instance()
|
bdf = get_global_instance()
|
||||||
|
|
||||||
# Внедряем зависимости в data для MagicData
|
# Внедряем зависимости в data для MagicData
|
||||||
if 'bot_db' not in data:
|
if "bot_db" not in data:
|
||||||
data['bot_db'] = bdf.get_db()
|
data["bot_db"] = bdf.get_db()
|
||||||
if 'settings' not in data:
|
if "settings" not in data:
|
||||||
data['settings'] = bdf.settings
|
data["settings"] = bdf.settings
|
||||||
data['bot'] = data.get('bot')
|
data["bot"] = data.get("bot")
|
||||||
data['dp'] = data.get('dp')
|
data["dp"] = data.get("dp")
|
||||||
|
|
||||||
logger.debug(f"DependenciesMiddleware: внедрены зависимости для {type(event).__name__}")
|
logger.debug(
|
||||||
|
f"DependenciesMiddleware: внедрены зависимости для {type(event).__name__}"
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка в DependenciesMiddleware: {e}")
|
logger.error(f"Ошибка в DependenciesMiddleware: {e}")
|
||||||
# Не прерываем выполнение, продолжаем без зависимостей
|
# Не прерываем выполнение, продолжаем без зависимостей
|
||||||
|
|
||||||
return await handler(event, data)
|
return await handler(event, data)
|
||||||
|
|||||||
@@ -3,25 +3,29 @@ Enhanced Metrics middleware for aiogram 3.x.
|
|||||||
Automatically collects ALL available metrics for comprehensive monitoring.
|
Automatically collects ALL available metrics for comprehensive monitoring.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Any, Awaitable, Callable, Dict, Union, Optional
|
|
||||||
from aiogram import BaseMiddleware
|
|
||||||
from aiogram.types import TelegramObject, Message, CallbackQuery
|
|
||||||
from aiogram.enums import ChatType
|
|
||||||
import time
|
|
||||||
import logging
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Any, Awaitable, Callable, Dict, Optional, Union
|
||||||
|
|
||||||
|
from aiogram import BaseMiddleware
|
||||||
|
from aiogram.enums import ChatType
|
||||||
|
from aiogram.types import CallbackQuery, Message, TelegramObject
|
||||||
|
|
||||||
from ..utils.metrics import metrics
|
from ..utils.metrics import metrics
|
||||||
|
|
||||||
# Import button command mapping
|
# Import button command mapping
|
||||||
try:
|
try:
|
||||||
from ..handlers.private.constants import BUTTON_COMMAND_MAPPING
|
|
||||||
from ..handlers.callback.constants import CALLBACK_COMMAND_MAPPING
|
|
||||||
from ..handlers.admin.constants import ADMIN_BUTTON_COMMAND_MAPPING, ADMIN_COMMANDS
|
from ..handlers.admin.constants import ADMIN_BUTTON_COMMAND_MAPPING, ADMIN_COMMANDS
|
||||||
|
from ..handlers.callback.constants import CALLBACK_COMMAND_MAPPING
|
||||||
|
from ..handlers.private.constants import BUTTON_COMMAND_MAPPING
|
||||||
from ..handlers.voice.constants import (
|
from ..handlers.voice.constants import (
|
||||||
BUTTON_COMMAND_MAPPING as VOICE_BUTTON_COMMAND_MAPPING,
|
BUTTON_COMMAND_MAPPING as VOICE_BUTTON_COMMAND_MAPPING,
|
||||||
COMMAND_MAPPING as VOICE_COMMAND_MAPPING,
|
|
||||||
CALLBACK_COMMAND_MAPPING as VOICE_CALLBACK_COMMAND_MAPPING
|
|
||||||
)
|
)
|
||||||
|
from ..handlers.voice.constants import (
|
||||||
|
CALLBACK_COMMAND_MAPPING as VOICE_CALLBACK_COMMAND_MAPPING,
|
||||||
|
)
|
||||||
|
from ..handlers.voice.constants import COMMAND_MAPPING as VOICE_COMMAND_MAPPING
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# Fallback if constants not available
|
# Fallback if constants not available
|
||||||
BUTTON_COMMAND_MAPPING = {}
|
BUTTON_COMMAND_MAPPING = {}
|
||||||
@@ -35,40 +39,49 @@ except ImportError:
|
|||||||
|
|
||||||
class MetricsMiddleware(BaseMiddleware):
|
class MetricsMiddleware(BaseMiddleware):
|
||||||
"""Enhanced middleware for automatic collection of ALL available metrics."""
|
"""Enhanced middleware for automatic collection of ALL available metrics."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.logger = logging.getLogger(__name__)
|
self.logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Metrics update intervals
|
# Metrics update intervals
|
||||||
self.last_active_users_update = 0
|
self.last_active_users_update = 0
|
||||||
self.active_users_update_interval = 300 # 5 minutes
|
self.active_users_update_interval = 300 # 5 minutes
|
||||||
|
|
||||||
async def __call__(
|
async def __call__(
|
||||||
self,
|
self,
|
||||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||||
event: TelegramObject,
|
event: TelegramObject,
|
||||||
data: Dict[str, Any]
|
data: Dict[str, Any],
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Process event and collect comprehensive metrics."""
|
"""Process event and collect comprehensive metrics."""
|
||||||
|
|
||||||
# Update active users periodically
|
# Update active users periodically
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
if current_time - self.last_active_users_update > self.active_users_update_interval:
|
if (
|
||||||
|
current_time - self.last_active_users_update
|
||||||
|
> self.active_users_update_interval
|
||||||
|
):
|
||||||
await self._update_active_users_metric()
|
await self._update_active_users_metric()
|
||||||
self.last_active_users_update = current_time
|
self.last_active_users_update = current_time
|
||||||
|
|
||||||
# Extract command and event info
|
# Extract command and event info
|
||||||
command_info = None
|
command_info = None
|
||||||
event_metrics = {}
|
event_metrics = {}
|
||||||
|
|
||||||
# Process event based on type
|
# Process event based on type
|
||||||
if hasattr(event, 'message') and event.message:
|
if hasattr(event, "message") and event.message:
|
||||||
event_metrics = await self._record_comprehensive_message_metrics(event.message)
|
event_metrics = await self._record_comprehensive_message_metrics(
|
||||||
|
event.message
|
||||||
|
)
|
||||||
command_info = self._extract_command_info_with_fallback(event.message)
|
command_info = self._extract_command_info_with_fallback(event.message)
|
||||||
elif hasattr(event, 'callback_query') and event.callback_query:
|
elif hasattr(event, "callback_query") and event.callback_query:
|
||||||
event_metrics = await self._record_comprehensive_callback_metrics(event.callback_query)
|
event_metrics = await self._record_comprehensive_callback_metrics(
|
||||||
command_info = self._extract_callback_command_info_with_fallback(event.callback_query)
|
event.callback_query
|
||||||
|
)
|
||||||
|
command_info = self._extract_callback_command_info_with_fallback(
|
||||||
|
event.callback_query
|
||||||
|
)
|
||||||
elif isinstance(event, Message):
|
elif isinstance(event, Message):
|
||||||
event_metrics = await self._record_comprehensive_message_metrics(event)
|
event_metrics = await self._record_comprehensive_message_metrics(event)
|
||||||
command_info = self._extract_command_info_with_fallback(event)
|
command_info = self._extract_command_info_with_fallback(event)
|
||||||
@@ -77,107 +90,106 @@ class MetricsMiddleware(BaseMiddleware):
|
|||||||
command_info = self._extract_callback_command_info_with_fallback(event)
|
command_info = self._extract_callback_command_info_with_fallback(event)
|
||||||
else:
|
else:
|
||||||
event_metrics = await self._record_unknown_event_metrics(event)
|
event_metrics = await self._record_unknown_event_metrics(event)
|
||||||
|
|
||||||
if command_info:
|
if command_info:
|
||||||
self.logger.info(f"📊 Command info extracted: {command_info}")
|
self.logger.info(f"📊 Command info extracted: {command_info}")
|
||||||
else:
|
else:
|
||||||
self.logger.warning(f"📊 No command info extracted for event type: {type(event).__name__}")
|
self.logger.warning(
|
||||||
|
f"📊 No command info extracted for event type: {type(event).__name__}"
|
||||||
|
)
|
||||||
|
|
||||||
# Execute handler with comprehensive timing and metrics
|
# Execute handler with comprehensive timing and metrics
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
try:
|
try:
|
||||||
result = await handler(event, data)
|
result = await handler(event, data)
|
||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
|
|
||||||
# Record successful execution metrics
|
# Record successful execution metrics
|
||||||
handler_name = self._get_handler_name(handler)
|
handler_name = self._get_handler_name(handler)
|
||||||
|
|
||||||
metrics.record_method_duration(
|
metrics.record_method_duration(handler_name, duration, "handler", "success")
|
||||||
handler_name,
|
|
||||||
duration,
|
|
||||||
"handler",
|
|
||||||
"success"
|
|
||||||
)
|
|
||||||
|
|
||||||
if command_info:
|
if command_info:
|
||||||
metrics.record_command(
|
metrics.record_command(
|
||||||
command_info['command'],
|
command_info["command"],
|
||||||
command_info['handler_type'],
|
command_info["handler_type"],
|
||||||
command_info['user_type'],
|
command_info["user_type"],
|
||||||
"success"
|
"success",
|
||||||
)
|
)
|
||||||
|
|
||||||
await self._record_additional_success_metrics(event, event_metrics, handler_name)
|
await self._record_additional_success_metrics(
|
||||||
|
event, event_metrics, handler_name
|
||||||
|
)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
|
|
||||||
# Record error metrics
|
# Record error metrics
|
||||||
handler_name = self._get_handler_name(handler)
|
handler_name = self._get_handler_name(handler)
|
||||||
error_type = type(e).__name__
|
error_type = type(e).__name__
|
||||||
|
|
||||||
metrics.record_method_duration(
|
metrics.record_method_duration(handler_name, duration, "handler", "error")
|
||||||
handler_name,
|
|
||||||
duration,
|
metrics.record_error(error_type, "handler", handler_name)
|
||||||
"handler",
|
|
||||||
"error"
|
|
||||||
)
|
|
||||||
|
|
||||||
metrics.record_error(
|
|
||||||
error_type,
|
|
||||||
"handler",
|
|
||||||
handler_name
|
|
||||||
)
|
|
||||||
|
|
||||||
if command_info:
|
if command_info:
|
||||||
metrics.record_command(
|
metrics.record_command(
|
||||||
command_info['command'],
|
command_info["command"],
|
||||||
command_info['handler_type'],
|
command_info["handler_type"],
|
||||||
command_info['user_type'],
|
command_info["user_type"],
|
||||||
"error"
|
"error",
|
||||||
)
|
)
|
||||||
|
|
||||||
await self._record_additional_error_metrics(event, event_metrics, handler_name, error_type)
|
await self._record_additional_error_metrics(
|
||||||
|
event, event_metrics, handler_name, error_type
|
||||||
|
)
|
||||||
|
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
# Record middleware execution time
|
# Record middleware execution time
|
||||||
middleware_duration = time.time() - start_time
|
middleware_duration = time.time() - start_time
|
||||||
metrics.record_middleware("MetricsMiddleware", middleware_duration, "success")
|
metrics.record_middleware(
|
||||||
|
"MetricsMiddleware", middleware_duration, "success"
|
||||||
|
)
|
||||||
|
|
||||||
async def _update_active_users_metric(self):
|
async def _update_active_users_metric(self):
|
||||||
"""Periodically update active users metric from database."""
|
"""Periodically update active users metric from database."""
|
||||||
try:
|
try:
|
||||||
#TODO: Должна подключаться к базе данных, а не к глобальному экземпляру
|
# TODO: Должна подключаться к базе данных, а не к глобальному экземпляру
|
||||||
from ..utils.base_dependency_factory import get_global_instance
|
from ..utils.base_dependency_factory import get_global_instance
|
||||||
|
|
||||||
bdf = get_global_instance()
|
bdf = get_global_instance()
|
||||||
bot_db = bdf.get_db()
|
bot_db = bdf.get_db()
|
||||||
|
|
||||||
# Используем правильные методы AsyncBotDB для выполнения запросов
|
# Используем правильные методы AsyncBotDB для выполнения запросов
|
||||||
# Простой подсчет всех пользователей в базе
|
# Простой подсчет всех пользователей в базе
|
||||||
total_users_query = "SELECT COUNT(DISTINCT user_id) as total FROM our_users"
|
total_users_query = "SELECT COUNT(DISTINCT user_id) as total FROM our_users"
|
||||||
total_users_result = await bot_db.fetch_one(total_users_query)
|
total_users_result = await bot_db.fetch_one(total_users_query)
|
||||||
total_users = total_users_result['total'] if total_users_result else 1
|
total_users = total_users_result["total"] if total_users_result else 1
|
||||||
|
|
||||||
# Подсчет активных за день пользователей (date_changed - это Unix timestamp)
|
# Подсчет активных за день пользователей (date_changed - это Unix timestamp)
|
||||||
daily_users_query = "SELECT COUNT(DISTINCT user_id) as daily FROM our_users WHERE date_changed > (strftime('%s', 'now', '-1 day'))"
|
daily_users_query = "SELECT COUNT(DISTINCT user_id) as daily FROM our_users WHERE date_changed > (strftime('%s', 'now', '-1 day'))"
|
||||||
daily_users_result = await bot_db.fetch_one(daily_users_query)
|
daily_users_result = await bot_db.fetch_one(daily_users_query)
|
||||||
daily_users = daily_users_result['daily'] if daily_users_result else 1
|
daily_users = daily_users_result["daily"] if daily_users_result else 1
|
||||||
|
|
||||||
# Устанавливаем метрики с правильными лейблами
|
# Устанавливаем метрики с правильными лейблами
|
||||||
metrics.set_active_users(daily_users, "daily")
|
metrics.set_active_users(daily_users, "daily")
|
||||||
metrics.set_total_users(total_users)
|
metrics.set_total_users(total_users)
|
||||||
self.logger.info(f"📊 Active users metric updated: {daily_users} (daily), {total_users} (total)")
|
self.logger.info(
|
||||||
|
f"📊 Active users metric updated: {daily_users} (daily), {total_users} (total)"
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"❌ Failed to update users metric: {e}")
|
self.logger.error(f"❌ Failed to update users metric: {e}")
|
||||||
# Устанавливаем 1 как fallback
|
# Устанавливаем 1 как fallback
|
||||||
metrics.set_active_users(1, "daily")
|
metrics.set_active_users(1, "daily")
|
||||||
metrics.set_total_users(1)
|
metrics.set_total_users(1)
|
||||||
|
|
||||||
async def _record_comprehensive_message_metrics(self, message: Message) -> Dict[str, Any]:
|
async def _record_comprehensive_message_metrics(
|
||||||
|
self, message: Message
|
||||||
|
) -> Dict[str, Any]:
|
||||||
"""Record comprehensive message metrics."""
|
"""Record comprehensive message metrics."""
|
||||||
# Determine message type
|
# Determine message type
|
||||||
message_type = "text"
|
message_type = "text"
|
||||||
@@ -195,7 +207,7 @@ class MetricsMiddleware(BaseMiddleware):
|
|||||||
message_type = "sticker"
|
message_type = "sticker"
|
||||||
elif message.animation:
|
elif message.animation:
|
||||||
message_type = "animation"
|
message_type = "animation"
|
||||||
|
|
||||||
# Determine chat type
|
# Determine chat type
|
||||||
chat_type = "private"
|
chat_type = "private"
|
||||||
if message.chat.type == ChatType.GROUP:
|
if message.chat.type == ChatType.GROUP:
|
||||||
@@ -204,129 +216,139 @@ class MetricsMiddleware(BaseMiddleware):
|
|||||||
chat_type = "supergroup"
|
chat_type = "supergroup"
|
||||||
elif message.chat.type == ChatType.CHANNEL:
|
elif message.chat.type == ChatType.CHANNEL:
|
||||||
chat_type = "channel"
|
chat_type = "channel"
|
||||||
|
|
||||||
# Record message processing
|
# Record message processing
|
||||||
metrics.record_message(message_type, chat_type, "message_handler")
|
metrics.record_message(message_type, chat_type, "message_handler")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'message_type': message_type,
|
"message_type": message_type,
|
||||||
'chat_type': chat_type,
|
"chat_type": chat_type,
|
||||||
'user_id': message.from_user.id if message.from_user else None,
|
"user_id": message.from_user.id if message.from_user else None,
|
||||||
'is_bot': message.from_user.is_bot if message.from_user else False
|
"is_bot": message.from_user.is_bot if message.from_user else False,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _record_comprehensive_callback_metrics(self, callback: CallbackQuery) -> Dict[str, Any]:
|
async def _record_comprehensive_callback_metrics(
|
||||||
|
self, callback: CallbackQuery
|
||||||
|
) -> Dict[str, Any]:
|
||||||
"""Record comprehensive callback metrics."""
|
"""Record comprehensive callback metrics."""
|
||||||
# Record callback message
|
# Record callback message
|
||||||
metrics.record_message("callback_query", "callback", "callback_handler")
|
metrics.record_message("callback_query", "callback", "callback_handler")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'callback_data': callback.data,
|
"callback_data": callback.data,
|
||||||
'user_id': callback.from_user.id if callback.from_user else None,
|
"user_id": callback.from_user.id if callback.from_user else None,
|
||||||
'is_bot': callback.from_user.is_bot if callback.from_user else False
|
"is_bot": callback.from_user.is_bot if callback.from_user else False,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _record_unknown_event_metrics(self, event: TelegramObject) -> Dict[str, Any]:
|
async def _record_unknown_event_metrics(
|
||||||
|
self, event: TelegramObject
|
||||||
|
) -> Dict[str, Any]:
|
||||||
"""Record metrics for unknown event types."""
|
"""Record metrics for unknown event types."""
|
||||||
# Record unknown event
|
# Record unknown event
|
||||||
metrics.record_message("unknown", "unknown", "unknown_handler")
|
metrics.record_message("unknown", "unknown", "unknown_handler")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'event_type': type(event).__name__,
|
"event_type": type(event).__name__,
|
||||||
'event_data': str(event)[:100] if hasattr(event, '__str__') else "unknown"
|
"event_data": str(event)[:100] if hasattr(event, "__str__") else "unknown",
|
||||||
}
|
}
|
||||||
|
|
||||||
def _extract_command_info_with_fallback(self, message: Message) -> Optional[Dict[str, str]]:
|
def _extract_command_info_with_fallback(
|
||||||
|
self, message: Message
|
||||||
|
) -> Optional[Dict[str, str]]:
|
||||||
"""Extract command information with fallback for unknown commands."""
|
"""Extract command information with fallback for unknown commands."""
|
||||||
if not message.text:
|
if not message.text:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Check if it's a slash command
|
# Check if it's a slash command
|
||||||
if message.text.startswith('/'):
|
if message.text.startswith("/"):
|
||||||
command_name = message.text.split()[0][1:] # Remove '/' and get command name
|
command_name = message.text.split()[0][
|
||||||
|
1:
|
||||||
|
] # Remove '/' and get command name
|
||||||
|
|
||||||
# Check if it's an admin command
|
# Check if it's an admin command
|
||||||
if command_name in ADMIN_COMMANDS:
|
if command_name in ADMIN_COMMANDS:
|
||||||
return {
|
return {
|
||||||
'command': ADMIN_COMMANDS[command_name],
|
"command": ADMIN_COMMANDS[command_name],
|
||||||
'user_type': "admin" if message.from_user else "unknown",
|
"user_type": "admin" if message.from_user else "unknown",
|
||||||
'handler_type': "admin_handler"
|
"handler_type": "admin_handler",
|
||||||
}
|
}
|
||||||
# Check if it's a voice bot command
|
# Check if it's a voice bot command
|
||||||
elif command_name in VOICE_COMMAND_MAPPING:
|
elif command_name in VOICE_COMMAND_MAPPING:
|
||||||
return {
|
return {
|
||||||
'command': VOICE_COMMAND_MAPPING[command_name],
|
"command": VOICE_COMMAND_MAPPING[command_name],
|
||||||
'user_type': "user" if message.from_user else "unknown",
|
"user_type": "user" if message.from_user else "unknown",
|
||||||
'handler_type': "voice_command_handler"
|
"handler_type": "voice_command_handler",
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
# FALLBACK: Record unknown command
|
# FALLBACK: Record unknown command
|
||||||
return {
|
return {
|
||||||
'command': command_name,
|
"command": command_name,
|
||||||
'user_type': "user" if message.from_user else "unknown",
|
"user_type": "user" if message.from_user else "unknown",
|
||||||
'handler_type': "unknown_command_handler"
|
"handler_type": "unknown_command_handler",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check if it's an admin button click
|
# Check if it's an admin button click
|
||||||
if message.text in ADMIN_BUTTON_COMMAND_MAPPING:
|
if message.text in ADMIN_BUTTON_COMMAND_MAPPING:
|
||||||
return {
|
return {
|
||||||
'command': ADMIN_BUTTON_COMMAND_MAPPING[message.text],
|
"command": ADMIN_BUTTON_COMMAND_MAPPING[message.text],
|
||||||
'user_type': "admin" if message.from_user else "unknown",
|
"user_type": "admin" if message.from_user else "unknown",
|
||||||
'handler_type': "admin_button_handler"
|
"handler_type": "admin_button_handler",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check if it's a regular button click (text button)
|
# Check if it's a regular button click (text button)
|
||||||
if message.text in BUTTON_COMMAND_MAPPING:
|
if message.text in BUTTON_COMMAND_MAPPING:
|
||||||
return {
|
return {
|
||||||
'command': BUTTON_COMMAND_MAPPING[message.text],
|
"command": BUTTON_COMMAND_MAPPING[message.text],
|
||||||
'user_type': "user" if message.from_user else "unknown",
|
"user_type": "user" if message.from_user else "unknown",
|
||||||
'handler_type': "button_handler"
|
"handler_type": "button_handler",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check if it's a voice bot button click
|
# Check if it's a voice bot button click
|
||||||
if message.text in VOICE_BUTTON_COMMAND_MAPPING:
|
if message.text in VOICE_BUTTON_COMMAND_MAPPING:
|
||||||
return {
|
return {
|
||||||
'command': VOICE_BUTTON_COMMAND_MAPPING[message.text],
|
"command": VOICE_BUTTON_COMMAND_MAPPING[message.text],
|
||||||
'user_type': "user" if message.from_user else "unknown",
|
"user_type": "user" if message.from_user else "unknown",
|
||||||
'handler_type': "voice_button_handler"
|
"handler_type": "voice_button_handler",
|
||||||
}
|
}
|
||||||
|
|
||||||
# FALLBACK: Record ANY text message as a command for metrics
|
# FALLBACK: Record ANY text message as a command for metrics
|
||||||
if message.text and len(message.text.strip()) > 0:
|
if message.text and len(message.text.strip()) > 0:
|
||||||
return {
|
return {
|
||||||
'command': f"text",
|
"command": f"text",
|
||||||
'user_type': "user" if message.from_user else "unknown",
|
"user_type": "user" if message.from_user else "unknown",
|
||||||
'handler_type': "text_message_handler"
|
"handler_type": "text_message_handler",
|
||||||
}
|
}
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _extract_callback_command_info_with_fallback(self, callback: CallbackQuery) -> Optional[Dict[str, str]]:
|
def _extract_callback_command_info_with_fallback(
|
||||||
|
self, callback: CallbackQuery
|
||||||
|
) -> Optional[Dict[str, str]]:
|
||||||
"""Extract callback command information with fallback."""
|
"""Extract callback command information with fallback."""
|
||||||
if not callback.data:
|
if not callback.data:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Extract command from callback data
|
# Extract command from callback data
|
||||||
parts = callback.data.split(':', 1)
|
parts = callback.data.split(":", 1)
|
||||||
if parts and parts[0] in CALLBACK_COMMAND_MAPPING:
|
if parts and parts[0] in CALLBACK_COMMAND_MAPPING:
|
||||||
return {
|
return {
|
||||||
'command': CALLBACK_COMMAND_MAPPING[parts[0]],
|
"command": CALLBACK_COMMAND_MAPPING[parts[0]],
|
||||||
'user_type': "user" if callback.from_user else "unknown",
|
"user_type": "user" if callback.from_user else "unknown",
|
||||||
'handler_type': "callback_handler"
|
"handler_type": "callback_handler",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check if it's a voice bot callback
|
# Check if it's a voice bot callback
|
||||||
if parts and parts[0] in VOICE_CALLBACK_COMMAND_MAPPING:
|
if parts and parts[0] in VOICE_CALLBACK_COMMAND_MAPPING:
|
||||||
return {
|
return {
|
||||||
'command': VOICE_CALLBACK_COMMAND_MAPPING[parts[0]],
|
"command": VOICE_CALLBACK_COMMAND_MAPPING[parts[0]],
|
||||||
'user_type': "user" if callback.from_user else "unknown",
|
"user_type": "user" if callback.from_user else "unknown",
|
||||||
'handler_type': "voice_callback_handler"
|
"handler_type": "voice_callback_handler",
|
||||||
}
|
}
|
||||||
|
|
||||||
# FALLBACK: Record unknown callback
|
# FALLBACK: Record unknown callback
|
||||||
if parts:
|
if parts:
|
||||||
callback_data = parts[0]
|
callback_data = parts[0]
|
||||||
|
|
||||||
# Группируем похожие callback'и по паттернам
|
# Группируем похожие callback'и по паттернам
|
||||||
if callback_data.startswith("ban_") and callback_data[4:].isdigit():
|
if callback_data.startswith("ban_") and callback_data[4:].isdigit():
|
||||||
# callback_ban_123456 -> callback_ban
|
# callback_ban_123456 -> callback_ban
|
||||||
@@ -337,60 +359,69 @@ class MetricsMiddleware(BaseMiddleware):
|
|||||||
else:
|
else:
|
||||||
# Для остальных неизвестных callback'ов оставляем как есть
|
# Для остальных неизвестных callback'ов оставляем как есть
|
||||||
command = f"callback_{callback_data[:20]}"
|
command = f"callback_{callback_data[:20]}"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'command': command,
|
"command": command,
|
||||||
'user_type': "user" if callback.from_user else "unknown",
|
"user_type": "user" if callback.from_user else "unknown",
|
||||||
'handler_type': "unknown_callback_handler"
|
"handler_type": "unknown_callback_handler",
|
||||||
}
|
}
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _record_additional_success_metrics(self, event: TelegramObject, event_metrics: Dict[str, Any], handler_name: str):
|
async def _record_additional_success_metrics(
|
||||||
|
self, event: TelegramObject, event_metrics: Dict[str, Any], handler_name: str
|
||||||
|
):
|
||||||
"""Record additional success metrics."""
|
"""Record additional success metrics."""
|
||||||
try:
|
try:
|
||||||
# Record rate limiting metrics (if applicable)
|
# Record rate limiting metrics (if applicable)
|
||||||
if hasattr(event, 'from_user') and event.from_user:
|
if hasattr(event, "from_user") and event.from_user:
|
||||||
# You can add rate limiting logic here
|
# You can add rate limiting logic here
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Record user activity metrics
|
# Record user activity metrics
|
||||||
if event_metrics.get('user_id'):
|
if event_metrics.get("user_id"):
|
||||||
# This could trigger additional user activity tracking
|
# This could trigger additional user activity tracking
|
||||||
pass
|
pass
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"❌ Error recording additional success metrics: {e}")
|
self.logger.error(f"❌ Error recording additional success metrics: {e}")
|
||||||
|
|
||||||
async def _record_additional_error_metrics(self, event: TelegramObject, event_metrics: Dict[str, Any], handler_name: str, error_type: str):
|
async def _record_additional_error_metrics(
|
||||||
|
self,
|
||||||
|
event: TelegramObject,
|
||||||
|
event_metrics: Dict[str, Any],
|
||||||
|
handler_name: str,
|
||||||
|
error_type: str,
|
||||||
|
):
|
||||||
"""Record additional error metrics."""
|
"""Record additional error metrics."""
|
||||||
try:
|
try:
|
||||||
# Record specific error context
|
# Record specific error context
|
||||||
if event_metrics.get('user_id'):
|
if event_metrics.get("user_id"):
|
||||||
# You can add user-specific error tracking here
|
# You can add user-specific error tracking here
|
||||||
pass
|
pass
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"❌ Error recording additional error metrics: {e}")
|
self.logger.error(f"❌ Error recording additional error metrics: {e}")
|
||||||
|
|
||||||
def _get_handler_name(self, handler: Callable) -> str:
|
def _get_handler_name(self, handler: Callable) -> str:
|
||||||
"""Extract handler name efficiently."""
|
"""Extract handler name efficiently."""
|
||||||
# Check various ways to get handler name
|
# Check various ways to get handler name
|
||||||
if hasattr(handler, '__name__') and handler.__name__ != '<lambda>':
|
if hasattr(handler, "__name__") and handler.__name__ != "<lambda>":
|
||||||
return handler.__name__
|
return handler.__name__
|
||||||
elif hasattr(handler, '__qualname__') and handler.__qualname__ != '<lambda>':
|
elif hasattr(handler, "__qualname__") and handler.__qualname__ != "<lambda>":
|
||||||
return handler.__qualname__
|
return handler.__qualname__
|
||||||
elif hasattr(handler, 'callback') and hasattr(handler.callback, '__name__'):
|
elif hasattr(handler, "callback") and hasattr(handler.callback, "__name__"):
|
||||||
return handler.callback.__name__
|
return handler.callback.__name__
|
||||||
elif hasattr(handler, 'view') and hasattr(handler.view, '__name__'):
|
elif hasattr(handler, "view") and hasattr(handler.view, "__name__"):
|
||||||
return handler.view.__name__
|
return handler.view.__name__
|
||||||
else:
|
else:
|
||||||
# Пытаемся получить имя из строкового представления
|
# Пытаемся получить имя из строкового представления
|
||||||
handler_str = str(handler)
|
handler_str = str(handler)
|
||||||
if 'function' in handler_str:
|
if "function" in handler_str:
|
||||||
# Извлекаем имя функции из строки
|
# Извлекаем имя функции из строки
|
||||||
import re
|
import re
|
||||||
match = re.search(r'function\s+(\w+)', handler_str)
|
|
||||||
|
match = re.search(r"function\s+(\w+)", handler_str)
|
||||||
if match:
|
if match:
|
||||||
return match.group(1)
|
return match.group(1)
|
||||||
return "unknown"
|
return "unknown"
|
||||||
@@ -398,83 +429,77 @@ class MetricsMiddleware(BaseMiddleware):
|
|||||||
|
|
||||||
class DatabaseMetricsMiddleware(BaseMiddleware):
|
class DatabaseMetricsMiddleware(BaseMiddleware):
|
||||||
"""Enhanced middleware for database operation metrics."""
|
"""Enhanced middleware for database operation metrics."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.logger = logging.getLogger(__name__)
|
self.logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
async def __call__(
|
async def __call__(
|
||||||
self,
|
self,
|
||||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||||
event: TelegramObject,
|
event: TelegramObject,
|
||||||
data: Dict[str, Any]
|
data: Dict[str, Any],
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Process event and collect database metrics."""
|
"""Process event and collect database metrics."""
|
||||||
|
|
||||||
# Check if this handler involves database operations
|
# Check if this handler involves database operations
|
||||||
handler_name = handler.__name__ if hasattr(handler, '__name__') else "unknown"
|
handler_name = handler.__name__ if hasattr(handler, "__name__") else "unknown"
|
||||||
|
|
||||||
# Record middleware start
|
# Record middleware start
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await handler(event, data)
|
result = await handler(event, data)
|
||||||
|
|
||||||
# Record successful database operation
|
# Record successful database operation
|
||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
metrics.record_middleware("DatabaseMetricsMiddleware", duration, "success")
|
metrics.record_middleware("DatabaseMetricsMiddleware", duration, "success")
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Record failed database operation
|
# Record failed database operation
|
||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
metrics.record_middleware("DatabaseMetricsMiddleware", duration, "error")
|
metrics.record_middleware("DatabaseMetricsMiddleware", duration, "error")
|
||||||
metrics.record_error(
|
metrics.record_error(type(e).__name__, "database_middleware", handler_name)
|
||||||
type(e).__name__,
|
|
||||||
"database_middleware",
|
|
||||||
handler_name
|
|
||||||
)
|
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
class ErrorMetricsMiddleware(BaseMiddleware):
|
class ErrorMetricsMiddleware(BaseMiddleware):
|
||||||
"""Enhanced middleware for error tracking and metrics."""
|
"""Enhanced middleware for error tracking and metrics."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.logger = logging.getLogger(__name__)
|
self.logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
async def __call__(
|
async def __call__(
|
||||||
self,
|
self,
|
||||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||||
event: TelegramObject,
|
event: TelegramObject,
|
||||||
data: Dict[str, Any]
|
data: Dict[str, Any],
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Process event and collect error metrics."""
|
"""Process event and collect error metrics."""
|
||||||
|
|
||||||
# Record middleware start
|
# Record middleware start
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await handler(event, data)
|
result = await handler(event, data)
|
||||||
|
|
||||||
# Record successful error handling
|
# Record successful error handling
|
||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
metrics.record_middleware("ErrorMetricsMiddleware", duration, "success")
|
metrics.record_middleware("ErrorMetricsMiddleware", duration, "success")
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Record error metrics
|
# Record error metrics
|
||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
handler_name = handler.__name__ if hasattr(handler, '__name__') else "unknown"
|
handler_name = (
|
||||||
|
handler.__name__ if hasattr(handler, "__name__") else "unknown"
|
||||||
metrics.record_middleware("ErrorMetricsMiddleware", duration, "error")
|
|
||||||
metrics.record_error(
|
|
||||||
type(e).__name__,
|
|
||||||
"error_middleware",
|
|
||||||
handler_name
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
metrics.record_middleware("ErrorMetricsMiddleware", duration, "error")
|
||||||
|
metrics.record_error(type(e).__name__, "error_middleware", handler_name)
|
||||||
|
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -1,41 +1,43 @@
|
|||||||
"""
|
"""
|
||||||
Middleware для автоматического применения rate limiting ко всем входящим сообщениям
|
Middleware для автоматического применения rate limiting ко всем входящим сообщениям
|
||||||
"""
|
"""
|
||||||
from typing import Callable, Dict, Any, Awaitable, Union
|
|
||||||
|
from typing import Any, Awaitable, Callable, Dict, Union
|
||||||
|
|
||||||
from aiogram import BaseMiddleware
|
from aiogram import BaseMiddleware
|
||||||
from aiogram.types import Message, CallbackQuery, InlineQuery, ChatMemberUpdated, Update
|
from aiogram.exceptions import TelegramAPIError, TelegramRetryAfter
|
||||||
from aiogram.exceptions import TelegramRetryAfter, TelegramAPIError
|
from aiogram.types import CallbackQuery, ChatMemberUpdated, InlineQuery, Message, Update
|
||||||
from logs.custom_logger import logger
|
|
||||||
|
|
||||||
from helper_bot.utils.rate_limiter import telegram_rate_limiter
|
from helper_bot.utils.rate_limiter import telegram_rate_limiter
|
||||||
|
from logs.custom_logger import logger
|
||||||
|
|
||||||
|
|
||||||
class RateLimitMiddleware(BaseMiddleware):
|
class RateLimitMiddleware(BaseMiddleware):
|
||||||
"""Middleware для автоматического rate limiting входящих сообщений"""
|
"""Middleware для автоматического rate limiting входящих сообщений"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.rate_limiter = telegram_rate_limiter
|
self.rate_limiter = telegram_rate_limiter
|
||||||
|
|
||||||
async def __call__(
|
async def __call__(
|
||||||
self,
|
self,
|
||||||
handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]],
|
handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]],
|
||||||
event: Union[Update, Message, CallbackQuery, InlineQuery, ChatMemberUpdated],
|
event: Union[Update, Message, CallbackQuery, InlineQuery, ChatMemberUpdated],
|
||||||
data: Dict[str, Any]
|
data: Dict[str, Any],
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Обрабатывает событие с rate limiting"""
|
"""Обрабатывает событие с rate limiting"""
|
||||||
|
|
||||||
# Извлекаем сообщение из Update
|
# Извлекаем сообщение из Update
|
||||||
message = None
|
message = None
|
||||||
if isinstance(event, Update):
|
if isinstance(event, Update):
|
||||||
message = event.message
|
message = event.message
|
||||||
elif isinstance(event, Message):
|
elif isinstance(event, Message):
|
||||||
message = event
|
message = event
|
||||||
|
|
||||||
# Применяем rate limiting только к сообщениям
|
# Применяем rate limiting только к сообщениям
|
||||||
if message is not None:
|
if message is not None:
|
||||||
chat_id = message.chat.id
|
chat_id = message.chat.id
|
||||||
|
|
||||||
# Обертываем handler в rate limiting
|
# Обертываем handler в rate limiting
|
||||||
async def rate_limited_handler():
|
async def rate_limited_handler():
|
||||||
try:
|
try:
|
||||||
@@ -45,13 +47,11 @@ class RateLimitMiddleware(BaseMiddleware):
|
|||||||
# Middleware не должен перехватывать эти ошибки,
|
# Middleware не должен перехватывать эти ошибки,
|
||||||
# пусть их обрабатывает rate_limiter в функциях отправки
|
# пусть их обрабатывает rate_limiter в функциях отправки
|
||||||
raise
|
raise
|
||||||
|
|
||||||
# Применяем rate limiting к handler
|
# Применяем rate limiting к handler
|
||||||
return await self.rate_limiter.send_with_rate_limit(
|
return await self.rate_limiter.send_with_rate_limit(
|
||||||
rate_limited_handler,
|
rate_limited_handler, chat_id
|
||||||
chat_id
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Для других типов событий просто вызываем handler
|
# Для других типов событий просто вызываем handler
|
||||||
return await handler(event, data)
|
return await handler(event, data)
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ class BulkTextMiddleware(BaseMiddleware):
|
|||||||
self.latency = latency
|
self.latency = latency
|
||||||
self.texts = defaultdict(list)
|
self.texts = defaultdict(list)
|
||||||
|
|
||||||
|
|
||||||
async def __call__(self, handler, event: Message, data: Dict[str, Any]) -> Any:
|
async def __call__(self, handler, event: Message, data: Dict[str, Any]) -> Any:
|
||||||
"""
|
"""
|
||||||
Main middleware logic.
|
Main middleware logic.
|
||||||
@@ -37,10 +36,9 @@ class BulkTextMiddleware(BaseMiddleware):
|
|||||||
# # Sort the album messages by message_id and add to data
|
# # Sort the album messages by message_id and add to data
|
||||||
msg_texts = self.texts[key]
|
msg_texts = self.texts[key]
|
||||||
msg_texts.sort(key=lambda x: x.message_id)
|
msg_texts.sort(key=lambda x: x.message_id)
|
||||||
data["texts"] = ''.join([msg.text for msg in msg_texts])
|
data["texts"] = "".join([msg.text for msg in msg_texts])
|
||||||
#
|
#
|
||||||
# Remove the media group from tracking to free up memory
|
# Remove the media group from tracking to free up memory
|
||||||
del self.texts[key]
|
del self.texts[key]
|
||||||
# # Call the original event handler
|
# # Call the original event handler
|
||||||
return await handler(event, data)
|
return await handler(event, data)
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
HTTP server for metrics endpoint integration with centralized Prometheus monitoring.
|
HTTP server for metrics endpoint integration with centralized Prometheus monitoring.
|
||||||
Provides /metrics endpoint and health check for the bot.
|
Provides /metrics endpoint and health check for the bot.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from aiohttp import web
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
from .utils.metrics import metrics
|
from .utils.metrics import metrics
|
||||||
|
|
||||||
# Импортируем логгер из проекта
|
# Импортируем логгер из проекта
|
||||||
@@ -15,53 +16,48 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
# Fallback для случаев, когда custom_logger недоступен
|
# Fallback для случаев, когда custom_logger недоступен
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class MetricsServer:
|
class MetricsServer:
|
||||||
"""HTTP server for Prometheus metrics and health checks."""
|
"""HTTP server for Prometheus metrics and health checks."""
|
||||||
|
|
||||||
def __init__(self, host: str = '0.0.0.0', port: int = 8080):
|
def __init__(self, host: str = "0.0.0.0", port: int = 8080):
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
self.app = web.Application()
|
self.app = web.Application()
|
||||||
self.runner: Optional[web.AppRunner] = None
|
self.runner: Optional[web.AppRunner] = None
|
||||||
self.site: Optional[web.TCPSite] = None
|
self.site: Optional[web.TCPSite] = None
|
||||||
|
|
||||||
# Настраиваем роуты
|
# Настраиваем роуты
|
||||||
self.app.router.add_get('/metrics', self.metrics_handler)
|
self.app.router.add_get("/metrics", self.metrics_handler)
|
||||||
self.app.router.add_get('/health', self.health_handler)
|
self.app.router.add_get("/health", self.health_handler)
|
||||||
|
|
||||||
async def metrics_handler(self, request: web.Request) -> web.Response:
|
async def metrics_handler(self, request: web.Request) -> web.Response:
|
||||||
"""Handle /metrics endpoint for Prometheus scraping."""
|
"""Handle /metrics endpoint for Prometheus scraping."""
|
||||||
try:
|
try:
|
||||||
logger.debug("Generating metrics...")
|
logger.debug("Generating metrics...")
|
||||||
|
|
||||||
# Проверяем, что metrics доступен
|
# Проверяем, что metrics доступен
|
||||||
if not metrics:
|
if not metrics:
|
||||||
logger.error("Metrics object is not available")
|
logger.error("Metrics object is not available")
|
||||||
return web.Response(
|
return web.Response(text="Metrics not available", status=500)
|
||||||
text="Metrics not available",
|
|
||||||
status=500
|
|
||||||
)
|
|
||||||
|
|
||||||
# Генерируем метрики в формате Prometheus
|
# Генерируем метрики в формате Prometheus
|
||||||
metrics_data = metrics.get_metrics()
|
metrics_data = metrics.get_metrics()
|
||||||
logger.debug(f"Generated metrics: {len(metrics_data)} bytes")
|
logger.debug(f"Generated metrics: {len(metrics_data)} bytes")
|
||||||
|
|
||||||
return web.Response(
|
return web.Response(
|
||||||
body=metrics_data,
|
body=metrics_data, content_type="text/plain; version=0.0.4"
|
||||||
content_type='text/plain; version=0.0.4'
|
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error generating metrics: {e}")
|
logger.error(f"Error generating metrics: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||||
return web.Response(
|
return web.Response(text=f"Error generating metrics: {e}", status=500)
|
||||||
text=f"Error generating metrics: {e}",
|
|
||||||
status=500
|
|
||||||
)
|
|
||||||
|
|
||||||
async def health_handler(self, request: web.Request) -> web.Response:
|
async def health_handler(self, request: web.Request) -> web.Response:
|
||||||
"""Handle /health endpoint for health checks."""
|
"""Handle /health endpoint for health checks."""
|
||||||
try:
|
try:
|
||||||
@@ -69,77 +65,72 @@ class MetricsServer:
|
|||||||
if not metrics:
|
if not metrics:
|
||||||
return web.Response(
|
return web.Response(
|
||||||
text="ERROR: Metrics not available",
|
text="ERROR: Metrics not available",
|
||||||
content_type='text/plain',
|
content_type="text/plain",
|
||||||
status=503
|
status=503,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Проверяем, что можем получить метрики
|
# Проверяем, что можем получить метрики
|
||||||
try:
|
try:
|
||||||
metrics_data = metrics.get_metrics()
|
metrics_data = metrics.get_metrics()
|
||||||
if not metrics_data:
|
if not metrics_data:
|
||||||
return web.Response(
|
return web.Response(
|
||||||
text="ERROR: Empty metrics",
|
text="ERROR: Empty metrics",
|
||||||
content_type='text/plain',
|
content_type="text/plain",
|
||||||
status=503
|
status=503,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return web.Response(
|
return web.Response(
|
||||||
text=f"ERROR: Metrics generation failed: {e}",
|
text=f"ERROR: Metrics generation failed: {e}",
|
||||||
content_type='text/plain',
|
content_type="text/plain",
|
||||||
status=503
|
status=503,
|
||||||
)
|
)
|
||||||
|
|
||||||
return web.Response(
|
return web.Response(text="OK", content_type="text/plain", status=200)
|
||||||
text="OK",
|
|
||||||
content_type='text/plain',
|
|
||||||
status=200
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Health check failed: {e}")
|
logger.error(f"Health check failed: {e}")
|
||||||
return web.Response(
|
return web.Response(
|
||||||
text=f"ERROR: Health check failed: {e}",
|
text=f"ERROR: Health check failed: {e}",
|
||||||
content_type='text/plain',
|
content_type="text/plain",
|
||||||
status=500
|
status=500,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start the HTTP server."""
|
"""Start the HTTP server."""
|
||||||
try:
|
try:
|
||||||
self.runner = web.AppRunner(self.app)
|
self.runner = web.AppRunner(self.app)
|
||||||
await self.runner.setup()
|
await self.runner.setup()
|
||||||
|
|
||||||
self.site = web.TCPSite(self.runner, self.host, self.port)
|
self.site = web.TCPSite(self.runner, self.host, self.port)
|
||||||
await self.site.start()
|
await self.site.start()
|
||||||
|
|
||||||
logger.info(f"Metrics server started on {self.host}:{self.port}")
|
logger.info(f"Metrics server started on {self.host}:{self.port}")
|
||||||
logger.info("Available endpoints:")
|
logger.info("Available endpoints:")
|
||||||
logger.info(f" - /metrics - Prometheus metrics")
|
logger.info(f" - /metrics - Prometheus metrics")
|
||||||
logger.info(f" - /health - Health check")
|
logger.info(f" - /health - Health check")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to start metrics server: {e}")
|
logger.error(f"Failed to start metrics server: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
"""Stop the HTTP server."""
|
"""Stop the HTTP server."""
|
||||||
try:
|
try:
|
||||||
if self.site:
|
if self.site:
|
||||||
await self.site.stop()
|
await self.site.stop()
|
||||||
logger.info("Metrics server site stopped")
|
logger.info("Metrics server site stopped")
|
||||||
|
|
||||||
if self.runner:
|
if self.runner:
|
||||||
await self.runner.cleanup()
|
await self.runner.cleanup()
|
||||||
logger.info("Metrics server runner cleaned up")
|
logger.info("Metrics server runner cleaned up")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error stopping metrics server: {e}")
|
logger.error(f"Error stopping metrics server: {e}")
|
||||||
|
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
"""Async context manager entry."""
|
"""Async context manager entry."""
|
||||||
await self.start()
|
await self.start()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||||
"""Async context manager exit."""
|
"""Async context manager exit."""
|
||||||
await self.stop()
|
await self.stop()
|
||||||
@@ -149,7 +140,9 @@ class MetricsServer:
|
|||||||
metrics_server: Optional[MetricsServer] = None
|
metrics_server: Optional[MetricsServer] = None
|
||||||
|
|
||||||
|
|
||||||
async def start_metrics_server(host: str = '0.0.0.0', port: int = 8080) -> MetricsServer:
|
async def start_metrics_server(
|
||||||
|
host: str = "0.0.0.0", port: int = 8080
|
||||||
|
) -> MetricsServer:
|
||||||
"""Start metrics server and return instance."""
|
"""Start metrics server and return instance."""
|
||||||
global metrics_server
|
global metrics_server
|
||||||
metrics_server = MetricsServer(host, port)
|
metrics_server = MetricsServer(host, port)
|
||||||
|
|||||||
5
helper_bot/services/__init__.py
Normal file
5
helper_bot/services/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
Сервисы приложения.
|
||||||
|
|
||||||
|
Содержит бизнес-логику, не связанную напрямую с handlers.
|
||||||
|
"""
|
||||||
39
helper_bot/services/scoring/__init__.py
Normal file
39
helper_bot/services/scoring/__init__.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""
|
||||||
|
Сервисы для ML-скоринга постов.
|
||||||
|
|
||||||
|
Включает:
|
||||||
|
- RagApiClient - HTTP клиент для внешнего RAG API сервиса
|
||||||
|
- DeepSeekService - интеграция с DeepSeek API
|
||||||
|
- ScoringManager - объединение всех сервисов скоринга
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .base import CombinedScore, ScoringResult, ScoringServiceProtocol
|
||||||
|
from .deepseek_service import DeepSeekService
|
||||||
|
from .exceptions import (
|
||||||
|
DeepSeekAPIError,
|
||||||
|
InsufficientExamplesError,
|
||||||
|
ModelNotLoadedError,
|
||||||
|
ScoringError,
|
||||||
|
TextTooShortError,
|
||||||
|
VectorStoreError,
|
||||||
|
)
|
||||||
|
from .rag_client import RagApiClient
|
||||||
|
from .scoring_manager import ScoringManager
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Базовые классы
|
||||||
|
"ScoringResult",
|
||||||
|
"ScoringServiceProtocol",
|
||||||
|
"CombinedScore",
|
||||||
|
# Исключения
|
||||||
|
"ScoringError",
|
||||||
|
"ModelNotLoadedError",
|
||||||
|
"VectorStoreError",
|
||||||
|
"DeepSeekAPIError",
|
||||||
|
"InsufficientExamplesError",
|
||||||
|
"TextTooShortError",
|
||||||
|
# Сервисы
|
||||||
|
"RagApiClient",
|
||||||
|
"DeepSeekService",
|
||||||
|
"ScoringManager",
|
||||||
|
]
|
||||||
159
helper_bot/services/scoring/base.py
Normal file
159
helper_bot/services/scoring/base.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
"""
|
||||||
|
Базовые классы и протоколы для сервисов скоринга.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Dict, Optional, Protocol
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScoringResult:
|
||||||
|
"""
|
||||||
|
Результат оценки поста от одного сервиса.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
score: Оценка от 0.0 до 1.0 (вероятность публикации)
|
||||||
|
source: Источник оценки ("deepseek", "rag", etc.)
|
||||||
|
model: Название используемой модели
|
||||||
|
confidence: Уверенность в оценке (опционально)
|
||||||
|
timestamp: Время получения оценки
|
||||||
|
metadata: Дополнительные данные
|
||||||
|
"""
|
||||||
|
|
||||||
|
score: float
|
||||||
|
source: str
|
||||||
|
model: str
|
||||||
|
confidence: Optional[float] = None
|
||||||
|
timestamp: int = field(default_factory=lambda: int(datetime.now().timestamp()))
|
||||||
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
"""Валидация score в диапазоне [0.0, 1.0]."""
|
||||||
|
if not 0.0 <= self.score <= 1.0:
|
||||||
|
raise ValueError(
|
||||||
|
f"Score должен быть в диапазоне [0.0, 1.0], получено: {self.score}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Преобразует результат в словарь для сохранения в JSON."""
|
||||||
|
result = {
|
||||||
|
"score": round(self.score, 4),
|
||||||
|
"model": self.model,
|
||||||
|
"ts": self.timestamp,
|
||||||
|
}
|
||||||
|
if self.confidence is not None:
|
||||||
|
result["confidence"] = round(self.confidence, 4)
|
||||||
|
if self.metadata:
|
||||||
|
result["metadata"] = self.metadata
|
||||||
|
return result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, source: str, data: Dict[str, Any]) -> "ScoringResult":
|
||||||
|
"""Создает ScoringResult из словаря."""
|
||||||
|
return cls(
|
||||||
|
score=data["score"],
|
||||||
|
source=source,
|
||||||
|
model=data.get("model", "unknown"),
|
||||||
|
confidence=data.get("confidence"),
|
||||||
|
timestamp=data.get("ts", int(datetime.now().timestamp())),
|
||||||
|
metadata=data.get("metadata", {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CombinedScore:
|
||||||
|
"""
|
||||||
|
Объединенный результат от всех сервисов скоринга.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
deepseek: Результат от DeepSeek API (None если отключен/ошибка)
|
||||||
|
rag: Результат от RAG сервиса (None если отключен/ошибка)
|
||||||
|
errors: Словарь с ошибками по источникам
|
||||||
|
"""
|
||||||
|
|
||||||
|
deepseek: Optional[ScoringResult] = None
|
||||||
|
rag: Optional[ScoringResult] = None
|
||||||
|
errors: Dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def deepseek_score(self) -> Optional[float]:
|
||||||
|
"""Возвращает только числовой скор от DeepSeek."""
|
||||||
|
return self.deepseek.score if self.deepseek else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rag_score(self) -> Optional[float]:
|
||||||
|
"""Возвращает только числовой скор от RAG."""
|
||||||
|
return self.rag.score if self.rag else None
|
||||||
|
|
||||||
|
def to_json_dict(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Преобразует в словарь для сохранения в ml_scores колонку.
|
||||||
|
|
||||||
|
Формат:
|
||||||
|
{
|
||||||
|
"deepseek": {"score": 0.75, "model": "...", "ts": ...},
|
||||||
|
"rag": {"score": 0.90, "model": "...", "ts": ...}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
if self.deepseek:
|
||||||
|
result["deepseek"] = self.deepseek.to_dict()
|
||||||
|
if self.rag:
|
||||||
|
result["rag"] = self.rag.to_dict()
|
||||||
|
return result
|
||||||
|
|
||||||
|
def has_any_score(self) -> bool:
|
||||||
|
"""Проверяет, есть ли хотя бы один успешный скор."""
|
||||||
|
return self.deepseek is not None or self.rag is not None
|
||||||
|
|
||||||
|
|
||||||
|
class ScoringServiceProtocol(Protocol):
|
||||||
|
"""
|
||||||
|
Протокол для сервисов скоринга.
|
||||||
|
|
||||||
|
Любой сервис скоринга должен реализовывать эти методы.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source_name(self) -> str:
|
||||||
|
"""Возвращает имя источника ("deepseek", "rag", etc.)."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_enabled(self) -> bool:
|
||||||
|
"""Проверяет, включен ли сервис."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def calculate_score(self, text: str) -> ScoringResult:
|
||||||
|
"""
|
||||||
|
Рассчитывает скор для текста поста.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Текст поста для оценки
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ScoringResult с оценкой
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ScoringError: При ошибке расчета
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def add_positive_example(self, text: str) -> None:
|
||||||
|
"""
|
||||||
|
Добавляет текст как положительный пример (опубликованный пост).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Текст опубликованного поста
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def add_negative_example(self, text: str) -> None:
|
||||||
|
"""
|
||||||
|
Добавляет текст как отрицательный пример (отклоненный пост).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Текст отклоненного поста
|
||||||
|
"""
|
||||||
|
...
|
||||||
365
helper_bot/services/scoring/deepseek_service.py
Normal file
365
helper_bot/services/scoring/deepseek_service.py
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
"""
|
||||||
|
DeepSeek API сервис для скоринга постов.
|
||||||
|
|
||||||
|
Использует DeepSeek API для семантической оценки релевантности поста.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
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
|
||||||
|
|
||||||
|
from .base import ScoringResult
|
||||||
|
from .exceptions import DeepSeekAPIError, ScoringError, TextTooShortError
|
||||||
|
|
||||||
|
|
||||||
|
class DeepSeekService:
|
||||||
|
"""
|
||||||
|
Сервис для оценки постов через DeepSeek API.
|
||||||
|
|
||||||
|
Отправляет текст поста в DeepSeek с промптом для оценки
|
||||||
|
и получает числовой скор релевантности.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
api_key: API ключ DeepSeek
|
||||||
|
api_url: URL API эндпоинта
|
||||||
|
model: Название модели
|
||||||
|
timeout: Таймаут запроса в секундах
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Промпт для оценки поста
|
||||||
|
SCORING_PROMPT = """Роль: Ты — строгий и внимательный модератор сообщества в социальной сети, ориентированного на знакомства между людьми. Твоя задача — оценить, можно ли опубликовать пост, основываясь на четких правилах.
|
||||||
|
|
||||||
|
Контекст группы: Это группа для поиска и знакомства с людьми. Пользователи могут искать кого угодно: случайно увиденных на улице, в транспорте, в кафе, старых знакомых, новых друзей или пару. Это главная и единственная цель группы.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
ПРАВИЛА ЗАПРЕТА (пост НЕ ДОЛЖЕН быть опубликован, если содержит это):
|
||||||
|
|
||||||
|
1. Запрещенные законом тематики: Любые призывы, обсуждение или поиск чего-либо незаконного (наркотики, оружие, мошенничество, насилие и т.д.).
|
||||||
|
2. Поиск и утеря животных, найденные предметы: Запрещены посты про потерявшихся/найденных кошек, собак, хомяков, а также про потерянные/найденные телефоны, ключи, сумки и т.п.
|
||||||
|
3. Конкуренция (Дайвинчик): Любое упоминание группы/проекта/чата "Дайвинчик" или любых других групп-конкурентов. Запрещены призывы переходить в другие сообщества.
|
||||||
|
4. Сбор больших компаний и групп: Запрещены посты с целью собрать большую тусовку, компанию, группу для похода, вечеринки, игры и т.д. (например, "собираем команду для футбола", "кто хочет на квартиру?").
|
||||||
|
5. Организация чатов и других сообществ: Запрещено создание или реклама сторонних чатов, каналов, групп в телеграме, дискорде и т.п.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
ПРАВИЛА РАЗРЕШЕНИЯ (пост МОЖЕТ быть опубликован, если):
|
||||||
|
|
||||||
|
· Цель — найти конкретного человека или познакомиться с кем-то новым.
|
||||||
|
· Формат: Описание человека, обстоятельств встречи, примет, места и времени. Или прямой призыв к знакомству.
|
||||||
|
· Примеры ДОПУСТИМЫХ постов (ориентируйся на них):
|
||||||
|
· "мальчики нефоры/патлатые, гоу знакомиться😻 анон"
|
||||||
|
· "ищу девочку, ехала на 21 автобусе примерно в 15:20. села на детской поликлинике и вышла в заречье вся в черной одежде и с черным баулом"
|
||||||
|
· "ищу мальчика ехали на 35 автобусе часов в 7 вечера я была с девочками,у нас с тобой еще куртки одинаковые ,я рядом с тобой сидела,напиши в комментарии если у тебя нету девочки. анон админу любви."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
ИНСТРУКЦИЯ ПО ОЦЕНКЕ:
|
||||||
|
|
||||||
|
Проанализируй полученный пост и присвой ему итоговый Вес (Score) от 0.0 до 1.0, где:
|
||||||
|
|
||||||
|
· 1.0 — Пост полностью соответствует правилам. Цель — найти/познакомиться с человеком. Ничего из списка запретов не нарушено. Можно публиковать.
|
||||||
|
· 0.0 — Пост категорически нарушает правила. Содержит явные признаки одного или нескольких пунктов из списка запрета. Публиковать НЕЛЬЗЯ.
|
||||||
|
· 0.2 - 0.8 — Пост находится в "серой зоне". Присваивай промежуточный вес, оценивая степень риска и соответствия цели группы.
|
||||||
|
· Ближе к 0.2: Сильно сомнительный пост, есть явные признаки запрещенной темы (например, упоминание "собраться компанией", косвенная реклама другого места).
|
||||||
|
· 0.5: Нейтральный или неочевидный пост. Нужно проверить, нет ли скрытого смысла, нарушающего правила.
|
||||||
|
· Ближе к 0.8: В целом допустимый пост, но с небольшими странностями или двусмысленностями, не нарушающими правила напрямую.
|
||||||
|
---
|
||||||
|
{text}
|
||||||
|
---
|
||||||
|
|
||||||
|
Ответь ТОЛЬКО числом от 0.0 до 1.0, без дополнительных объяснений.
|
||||||
|
Пример ответа: 0.75"""
|
||||||
|
|
||||||
|
DEFAULT_API_URL = "https://api.deepseek.com/v1/chat/completions"
|
||||||
|
DEFAULT_MODEL = "deepseek-chat"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
api_key: Optional[str] = None,
|
||||||
|
api_url: Optional[str] = None,
|
||||||
|
model: Optional[str] = None,
|
||||||
|
timeout: int = 30,
|
||||||
|
enabled: bool = True,
|
||||||
|
min_text_length: int = 3,
|
||||||
|
max_retries: int = 3,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Инициализация DeepSeek сервиса.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_key: API ключ DeepSeek
|
||||||
|
api_url: URL API эндпоинта
|
||||||
|
model: Название модели
|
||||||
|
timeout: Таймаут запроса в секундах
|
||||||
|
enabled: Включен ли сервис
|
||||||
|
min_text_length: Минимальная длина текста для обработки
|
||||||
|
max_retries: Максимальное количество повторных попыток
|
||||||
|
"""
|
||||||
|
self.api_key = api_key
|
||||||
|
self.api_url = api_url or self.DEFAULT_API_URL
|
||||||
|
self.model = model or self.DEFAULT_MODEL
|
||||||
|
self.timeout = timeout
|
||||||
|
self._enabled = enabled and bool(api_key)
|
||||||
|
self.min_text_length = min_text_length
|
||||||
|
self.max_retries = max_retries
|
||||||
|
|
||||||
|
# HTTP клиент (создается лениво)
|
||||||
|
self._client: Optional[httpx.AsyncClient] = None
|
||||||
|
|
||||||
|
if not api_key and enabled:
|
||||||
|
logger.warning("DeepSeekService: API ключ не указан, сервис отключен")
|
||||||
|
self._enabled = False
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"DeepSeekService инициализирован "
|
||||||
|
f"(model={self.model}, enabled={self._enabled})"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source_name(self) -> str:
|
||||||
|
"""Имя источника для результатов."""
|
||||||
|
return "deepseek"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_enabled(self) -> bool:
|
||||||
|
"""Проверяет, включен ли сервис."""
|
||||||
|
return self._enabled
|
||||||
|
|
||||||
|
async def _get_client(self) -> httpx.AsyncClient:
|
||||||
|
"""Получает или создает HTTP клиент."""
|
||||||
|
if self._client is None:
|
||||||
|
self._client = httpx.AsyncClient(
|
||||||
|
timeout=httpx.Timeout(self.timeout),
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Закрывает HTTP клиент."""
|
||||||
|
if self._client:
|
||||||
|
await self._client.aclose()
|
||||||
|
self._client = None
|
||||||
|
|
||||||
|
def _clean_text(self, text: str) -> str:
|
||||||
|
"""Очищает текст от лишних символов."""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Удаляем лишние пробелы и переносы строк
|
||||||
|
clean = " ".join(text.split())
|
||||||
|
|
||||||
|
# Удаляем служебные символы
|
||||||
|
if clean == "^":
|
||||||
|
return ""
|
||||||
|
|
||||||
|
return clean.strip()
|
||||||
|
|
||||||
|
def _parse_score_response(self, response_text: str) -> float:
|
||||||
|
"""
|
||||||
|
Парсит ответ от DeepSeek и извлекает скор.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
response_text: Текст ответа от API
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Числовой скор от 0.0 до 1.0
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
DeepSeekAPIError: Если не удалось распарсить ответ
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Пытаемся найти число в ответе
|
||||||
|
text = response_text.strip()
|
||||||
|
|
||||||
|
# Убираем возможные обрамления
|
||||||
|
text = text.strip("\"'`")
|
||||||
|
|
||||||
|
# Пробуем распарсить как число
|
||||||
|
score = float(text)
|
||||||
|
|
||||||
|
# Ограничиваем диапазон
|
||||||
|
score = max(0.0, min(1.0, score))
|
||||||
|
|
||||||
|
return score
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
# Пробуем найти число в тексте
|
||||||
|
import re
|
||||||
|
|
||||||
|
matches = re.findall(r"0\.\d+|1\.0|0|1", text)
|
||||||
|
if matches:
|
||||||
|
score = float(matches[0])
|
||||||
|
return max(0.0, min(1.0, score))
|
||||||
|
|
||||||
|
logger.error(
|
||||||
|
f"DeepSeekService: Не удалось распарсить ответ: {response_text}"
|
||||||
|
)
|
||||||
|
raise DeepSeekAPIError(
|
||||||
|
f"Не удалось распарсить скор из ответа: {response_text}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@track_time("calculate_score", "deepseek_service")
|
||||||
|
@track_errors("deepseek_service", "calculate_score")
|
||||||
|
async def calculate_score(self, text: str) -> ScoringResult:
|
||||||
|
"""
|
||||||
|
Рассчитывает скор для текста поста через DeepSeek API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Текст поста для оценки
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ScoringResult с оценкой
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ScoringError: При ошибке расчета
|
||||||
|
"""
|
||||||
|
if not self._enabled:
|
||||||
|
raise ScoringError("DeepSeek сервис отключен")
|
||||||
|
|
||||||
|
# Очищаем текст
|
||||||
|
clean_text = self._clean_text(text)
|
||||||
|
|
||||||
|
if len(clean_text) < self.min_text_length:
|
||||||
|
raise TextTooShortError(
|
||||||
|
f"Текст слишком короткий (минимум {self.min_text_length} символов)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Формируем промпт
|
||||||
|
prompt = self.SCORING_PROMPT.format(text=clean_text)
|
||||||
|
|
||||||
|
# Выполняем запрос с повторными попытками
|
||||||
|
last_error = None
|
||||||
|
for attempt in range(self.max_retries):
|
||||||
|
try:
|
||||||
|
score = await self._make_api_request(prompt)
|
||||||
|
|
||||||
|
return ScoringResult(
|
||||||
|
score=score,
|
||||||
|
source=self.source_name,
|
||||||
|
model=self.model,
|
||||||
|
metadata={
|
||||||
|
"text_length": len(clean_text),
|
||||||
|
"attempt": attempt + 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
except DeepSeekAPIError as e:
|
||||||
|
last_error = e
|
||||||
|
logger.warning(
|
||||||
|
f"DeepSeekService: Попытка {attempt + 1}/{self.max_retries} "
|
||||||
|
f"не удалась: {e}"
|
||||||
|
)
|
||||||
|
if attempt < self.max_retries - 1:
|
||||||
|
# Экспоненциальная задержка
|
||||||
|
await asyncio.sleep(2**attempt)
|
||||||
|
|
||||||
|
raise ScoringError(
|
||||||
|
f"Все попытки запроса к DeepSeek API не удались: {last_error}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _make_api_request(self, prompt: str) -> float:
|
||||||
|
"""
|
||||||
|
Выполняет запрос к DeepSeek API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt: Промпт для отправки
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Числовой скор от 0.0 до 1.0
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
DeepSeekAPIError: При ошибке API
|
||||||
|
"""
|
||||||
|
client = await self._get_client()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": self.model,
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": prompt,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"temperature": 0.1, # Низкая температура для детерминированности
|
||||||
|
"max_tokens": 10, # Ожидаем только число
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await client.post(self.api_url, json=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Извлекаем ответ
|
||||||
|
if "choices" not in data or not data["choices"]:
|
||||||
|
raise DeepSeekAPIError("Пустой ответ от API")
|
||||||
|
|
||||||
|
response_text = data["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
|
# Парсим скор
|
||||||
|
score = self._parse_score_response(response_text)
|
||||||
|
|
||||||
|
logger.debug(f"DeepSeekService: Получен скор {score} для текста")
|
||||||
|
return score
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
error_msg = f"HTTP ошибка {e.response.status_code}"
|
||||||
|
try:
|
||||||
|
error_data = e.response.json()
|
||||||
|
if "error" in error_data:
|
||||||
|
error_msg = error_data["error"].get("message", error_msg)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise DeepSeekAPIError(error_msg)
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
raise DeepSeekAPIError(f"Таймаут запроса ({self.timeout}s)")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise DeepSeekAPIError(f"Ошибка запроса: {e}")
|
||||||
|
|
||||||
|
async def add_positive_example(self, text: str) -> None:
|
||||||
|
"""
|
||||||
|
Добавляет текст как положительный пример.
|
||||||
|
|
||||||
|
Для DeepSeek не требуется хранить примеры - оценка выполняется
|
||||||
|
на основе промпта. Метод существует для совместимости с протоколом.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Текст опубликованного поста
|
||||||
|
"""
|
||||||
|
# DeepSeek не использует примеры для обучения
|
||||||
|
# Промпт уже содержит критерии оценки
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def add_negative_example(self, text: str) -> None:
|
||||||
|
"""
|
||||||
|
Добавляет текст как отрицательный пример.
|
||||||
|
|
||||||
|
Для DeepSeek не требуется хранить примеры - оценка выполняется
|
||||||
|
на основе промпта. Метод существует для совместимости с протоколом.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Текст отклоненного поста
|
||||||
|
"""
|
||||||
|
# DeepSeek не использует примеры для обучения
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_stats(self) -> dict:
|
||||||
|
"""Возвращает статистику сервиса."""
|
||||||
|
return {
|
||||||
|
"enabled": self._enabled,
|
||||||
|
"model": self.model,
|
||||||
|
"api_url": self.api_url,
|
||||||
|
"timeout": self.timeout,
|
||||||
|
"max_retries": self.max_retries,
|
||||||
|
}
|
||||||
39
helper_bot/services/scoring/exceptions.py
Normal file
39
helper_bot/services/scoring/exceptions.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""
|
||||||
|
Исключения для сервисов скоринга.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ScoringError(Exception):
|
||||||
|
"""Базовое исключение для ошибок скоринга."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ModelNotLoadedError(ScoringError):
|
||||||
|
"""Модель не загружена или недоступна."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class VectorStoreError(ScoringError):
|
||||||
|
"""Ошибка при работе с хранилищем векторов."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DeepSeekAPIError(ScoringError):
|
||||||
|
"""Ошибка при обращении к DeepSeek API."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InsufficientExamplesError(ScoringError):
|
||||||
|
"""Недостаточно примеров для расчета скора."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TextTooShortError(ScoringError):
|
||||||
|
"""Текст слишком короткий для векторизации."""
|
||||||
|
|
||||||
|
pass
|
||||||
545
helper_bot/services/scoring/rag_client.py
Normal file
545
helper_bot/services/scoring/rag_client.py
Normal file
@@ -0,0 +1,545 @@
|
|||||||
|
"""
|
||||||
|
HTTP клиент для взаимодействия с внешним RAG сервисом.
|
||||||
|
|
||||||
|
Использует REST API для получения скоров и отправки примеров.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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 сервисом.
|
||||||
|
|
||||||
|
Использует REST API для:
|
||||||
|
- Получения скоров постов (POST /api/v1/score)
|
||||||
|
- Отправки положительных примеров (POST /api/v1/examples/positive)
|
||||||
|
- Отправки отрицательных примеров (POST /api/v1/examples/negative)
|
||||||
|
- Получения статистики (GET /api/v1/stats)
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
api_url: Базовый URL API сервиса
|
||||||
|
api_key: API ключ для аутентификации
|
||||||
|
timeout: Таймаут запросов в секундах
|
||||||
|
test_mode: Включен ли тестовый режим (добавляет заголовок X-Test-Mode: true)
|
||||||
|
enabled: Включен ли клиент
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
api_url: str,
|
||||||
|
api_key: str,
|
||||||
|
timeout: int = 30,
|
||||||
|
test_mode: bool = False,
|
||||||
|
enabled: bool = True,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Инициализация клиента.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_url: Базовый URL API (например, http://хх.ххх.ххх.хх/api/v1)
|
||||||
|
api_key: API ключ для аутентификации
|
||||||
|
timeout: Таймаут запросов в секундах
|
||||||
|
test_mode: Включен ли тестовый режим (добавляет заголовок X-Test-Mode: true к запросам examples)
|
||||||
|
enabled: Включен ли клиент
|
||||||
|
"""
|
||||||
|
# Убираем trailing slash если есть
|
||||||
|
self.api_url = api_url.rstrip("/")
|
||||||
|
self.api_key = api_key
|
||||||
|
self.timeout = timeout
|
||||||
|
self.test_mode = test_mode
|
||||||
|
self._enabled = enabled
|
||||||
|
|
||||||
|
# Создаем HTTP клиент
|
||||||
|
self._client = httpx.AsyncClient(
|
||||||
|
timeout=httpx.Timeout(timeout),
|
||||||
|
headers={
|
||||||
|
"X-API-Key": api_key,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"RagApiClient инициализирован (url={self.api_url}, enabled={enabled})"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source_name(self) -> str:
|
||||||
|
"""Имя источника для результатов."""
|
||||||
|
return "rag"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_enabled(self) -> bool:
|
||||||
|
"""Проверяет, включен ли клиент."""
|
||||||
|
return self._enabled
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Закрывает HTTP клиент."""
|
||||||
|
await self._client.aclose()
|
||||||
|
|
||||||
|
@track_time("calculate_score", "rag_client")
|
||||||
|
@track_errors("rag_client", "calculate_score")
|
||||||
|
async def calculate_score(self, text: str) -> ScoringResult:
|
||||||
|
"""
|
||||||
|
Рассчитывает скор для текста поста через API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Текст поста для оценки
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ScoringResult с оценкой
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ScoringError: При ошибке расчета
|
||||||
|
InsufficientExamplesError: Если недостаточно примеров
|
||||||
|
TextTooShortError: Если текст слишком короткий
|
||||||
|
"""
|
||||||
|
if not self._enabled:
|
||||||
|
raise ScoringError("RAG API клиент отключен")
|
||||||
|
|
||||||
|
if not text or not text.strip():
|
||||||
|
raise TextTooShortError("Текст пустой")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await self._client.post(
|
||||||
|
f"{self.api_url}/score", json={"text": text.strip()}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Обрабатываем различные статусы
|
||||||
|
if response.status_code == 400:
|
||||||
|
try:
|
||||||
|
error_data = response.json()
|
||||||
|
error_msg = error_data.get("detail", "Неизвестная ошибка")
|
||||||
|
except Exception:
|
||||||
|
error_msg = response.text or "Неизвестная ошибка"
|
||||||
|
|
||||||
|
logger.warning(f"RagApiClient: Ошибка валидации запроса: {error_msg}")
|
||||||
|
|
||||||
|
if (
|
||||||
|
"недостаточно" in error_msg.lower()
|
||||||
|
or "insufficient" in error_msg.lower()
|
||||||
|
):
|
||||||
|
raise InsufficientExamplesError(error_msg)
|
||||||
|
if "коротк" in error_msg.lower() or "short" in error_msg.lower():
|
||||||
|
raise TextTooShortError(error_msg)
|
||||||
|
raise ScoringError(f"Ошибка валидации: {error_msg}")
|
||||||
|
|
||||||
|
if response.status_code == 401:
|
||||||
|
logger.error("RagApiClient: Ошибка аутентификации: неверный API ключ")
|
||||||
|
raise ScoringError("Ошибка аутентификации: неверный API ключ")
|
||||||
|
|
||||||
|
if response.status_code == 404:
|
||||||
|
logger.error("RagApiClient: RAG API endpoint не найден")
|
||||||
|
raise ScoringError("RAG API endpoint не найден")
|
||||||
|
|
||||||
|
if response.status_code >= 500:
|
||||||
|
logger.error(
|
||||||
|
f"RagApiClient: Ошибка сервера RAG API: {response.status_code}"
|
||||||
|
)
|
||||||
|
raise ScoringError(f"Ошибка сервера RAG API: {response.status_code}")
|
||||||
|
|
||||||
|
# Проверяем успешный статус
|
||||||
|
if response.status_code != 200:
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Парсим ответ
|
||||||
|
score = float(data.get("rag_score", 0.0))
|
||||||
|
confidence = (
|
||||||
|
float(data.get("rag_confidence", 0.0))
|
||||||
|
if data.get("rag_confidence") is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
rag_score_pos_only_raw = data.get("rag_score_pos_only")
|
||||||
|
rag_score_pos_only = (
|
||||||
|
float(rag_score_pos_only_raw)
|
||||||
|
if rag_score_pos_only_raw is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Форматируем confidence для логирования
|
||||||
|
confidence_str = f"{confidence:.4f}" if confidence is not None else "None"
|
||||||
|
rag_score_pos_only_str = (
|
||||||
|
f"{rag_score_pos_only:.4f}"
|
||||||
|
if rag_score_pos_only is not None
|
||||||
|
else "None"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"RagApiClient: Скор успешно получен из API - "
|
||||||
|
f"rag_score={score:.4f} (type: {type(score).__name__}), "
|
||||||
|
f"rag_confidence={confidence_str}, "
|
||||||
|
f"rag_score_pos_only={rag_score_pos_only_str}, "
|
||||||
|
f"raw_response_rag_score={data.get('rag_score')}, "
|
||||||
|
f"raw_response_rag_score_pos_only={rag_score_pos_only_raw}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return ScoringResult(
|
||||||
|
score=score,
|
||||||
|
source=self.source_name,
|
||||||
|
model=data.get("meta", {}).get("model", "rag-service"),
|
||||||
|
confidence=confidence,
|
||||||
|
metadata={
|
||||||
|
"rag_score_pos_only": (
|
||||||
|
float(data.get("rag_score_pos_only", 0.0))
|
||||||
|
if data.get("rag_score_pos_only") is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"positive_examples": data.get("meta", {}).get("positive_examples"),
|
||||||
|
"negative_examples": data.get("meta", {}).get("negative_examples"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
logger.error(f"RagApiClient: Таймаут запроса к RAG API (>{self.timeout}с)")
|
||||||
|
raise ScoringError(f"Таймаут запроса к RAG API (>{self.timeout}с)")
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.error(f"RagApiClient: Ошибка подключения к RAG API: {e}")
|
||||||
|
raise ScoringError(f"Ошибка подключения к RAG API: {e}")
|
||||||
|
except (KeyError, ValueError, TypeError) as e:
|
||||||
|
logger.error(
|
||||||
|
f"RagApiClient: Ошибка парсинга ответа: {e}, response: {response.text if 'response' in locals() else 'N/A'}"
|
||||||
|
)
|
||||||
|
raise ScoringError(f"Ошибка парсинга ответа от RAG API: {e}")
|
||||||
|
except InsufficientExamplesError:
|
||||||
|
raise
|
||||||
|
except TextTooShortError:
|
||||||
|
raise
|
||||||
|
except ScoringError:
|
||||||
|
# Уже залогированные ошибки (401, 404, 500, таймауты и т.д.) - просто пробрасываем
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
# Только действительно неожиданные ошибки логируем здесь
|
||||||
|
logger.error(
|
||||||
|
f"RagApiClient: Неожиданная ошибка при расчете скора: {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
raise ScoringError(f"Неожиданная ошибка: {e}")
|
||||||
|
|
||||||
|
@track_time("add_positive_example", "rag_client")
|
||||||
|
async def add_positive_example(self, text: str) -> None:
|
||||||
|
"""
|
||||||
|
Добавляет текст как положительный пример (опубликованный пост).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Текст опубликованного поста
|
||||||
|
"""
|
||||||
|
if not self._enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not text or not text.strip():
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Формируем заголовки (добавляем X-Test-Mode если включен тестовый режим)
|
||||||
|
headers = {}
|
||||||
|
if self.test_mode:
|
||||||
|
headers["X-Test-Mode"] = "true"
|
||||||
|
|
||||||
|
response = await self._client.post(
|
||||||
|
f"{self.api_url}/examples/positive",
|
||||||
|
json={"text": text.strip()},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200 or response.status_code == 201:
|
||||||
|
logger.info("RagApiClient: Положительный пример успешно добавлен")
|
||||||
|
elif response.status_code == 400:
|
||||||
|
logger.warning(
|
||||||
|
f"RagApiClient: Ошибка валидации при добавлении положительного примера: {response.text}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"RagApiClient: Неожиданный статус при добавлении положительного примера: {response.status_code}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
logger.warning(
|
||||||
|
f"RagApiClient: Таймаут при добавлении положительного примера"
|
||||||
|
)
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.warning(
|
||||||
|
f"RagApiClient: Ошибка подключения при добавлении положительного примера: {e}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"RagApiClient: Ошибка добавления положительного примера: {e}")
|
||||||
|
|
||||||
|
@track_time("add_negative_example", "rag_client")
|
||||||
|
async def add_negative_example(self, text: str) -> None:
|
||||||
|
"""
|
||||||
|
Добавляет текст как отрицательный пример (отклоненный пост).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Текст отклоненного поста
|
||||||
|
"""
|
||||||
|
if not self._enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not text or not text.strip():
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Формируем заголовки (добавляем X-Test-Mode если включен тестовый режим)
|
||||||
|
headers = {}
|
||||||
|
if self.test_mode:
|
||||||
|
headers["X-Test-Mode"] = "true"
|
||||||
|
|
||||||
|
response = await self._client.post(
|
||||||
|
f"{self.api_url}/examples/negative",
|
||||||
|
json={"text": text.strip()},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200 or response.status_code == 201:
|
||||||
|
logger.info("RagApiClient: Отрицательный пример успешно добавлен")
|
||||||
|
elif response.status_code == 400:
|
||||||
|
logger.warning(
|
||||||
|
f"RagApiClient: Ошибка валидации при добавлении отрицательного примера: {response.text}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"RagApiClient: Неожиданный статус при добавлении отрицательного примера: {response.status_code}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
logger.warning(
|
||||||
|
f"RagApiClient: Таймаут при добавлении отрицательного примера"
|
||||||
|
)
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.warning(
|
||||||
|
f"RagApiClient: Ошибка подключения при добавлении отрицательного примера: {e}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"RagApiClient: Ошибка добавления отрицательного примера: {e}")
|
||||||
|
|
||||||
|
async def get_stats(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Получает статистику от RAG API через endpoint /stats.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Словарь со статистикой или пустой словарь при ошибке
|
||||||
|
"""
|
||||||
|
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:
|
||||||
|
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: Неожиданный статус при получении статистики: "
|
||||||
|
f"status={response.status_code}, body={response.text[:200]}"
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
logger.warning(
|
||||||
|
f"RagApiClient: Таймаут при получении статистики (timeout={self.timeout}s)"
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.warning(
|
||||||
|
f"RagApiClient: Ошибка подключения при получении статистики: {e}"
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"RagApiClient: Ошибка получения статистики: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def get_stats_sync(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Синхронная версия get_stats для использования в get_stats() ScoringManager.
|
||||||
|
|
||||||
|
Внимание: Это заглушка, реальная статистика будет получена асинхронно.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"enabled": self._enabled,
|
||||||
|
"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
|
||||||
266
helper_bot/services/scoring/scoring_manager.py
Normal file
266
helper_bot/services/scoring/scoring_manager.py
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
"""
|
||||||
|
Менеджер для объединения всех сервисов скоринга.
|
||||||
|
|
||||||
|
Координирует работу RagApiClient и DeepSeekService,
|
||||||
|
выполняет параллельные запросы и агрегирует результаты.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from helper_bot.utils.metrics import track_errors, track_time
|
||||||
|
from logs.custom_logger import logger
|
||||||
|
|
||||||
|
from .base import CombinedScore, ScoringResult
|
||||||
|
from .deepseek_service import DeepSeekService
|
||||||
|
from .exceptions import InsufficientExamplesError, ScoringError, TextTooShortError
|
||||||
|
from .rag_client import RagApiClient
|
||||||
|
|
||||||
|
|
||||||
|
class ScoringManager:
|
||||||
|
"""
|
||||||
|
Менеджер для управления всеми сервисами скоринга.
|
||||||
|
|
||||||
|
Объединяет RagApiClient и DeepSeekService, выполняет параллельные
|
||||||
|
запросы и агрегирует результаты в единый CombinedScore.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
rag_client: HTTP клиент для RAG API
|
||||||
|
deepseek_service: Сервис DeepSeek API
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
rag_client: Optional[RagApiClient] = None,
|
||||||
|
deepseek_service: Optional[DeepSeekService] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Инициализация менеджера.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rag_client: HTTP клиент для RAG API (создается автоматически если не передан)
|
||||||
|
deepseek_service: Сервис DeepSeek (создается автоматически если не передан)
|
||||||
|
"""
|
||||||
|
self.rag_client = rag_client
|
||||||
|
self.deepseek_service = deepseek_service
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"ScoringManager инициализирован "
|
||||||
|
f"(rag={rag_client is not None and rag_client.is_enabled}, "
|
||||||
|
f"deepseek={deepseek_service is not None and deepseek_service.is_enabled})"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_any_enabled(self) -> bool:
|
||||||
|
"""Проверяет, включен ли хотя бы один сервис."""
|
||||||
|
rag_enabled = self.rag_client is not None and self.rag_client.is_enabled
|
||||||
|
deepseek_enabled = (
|
||||||
|
self.deepseek_service is not None and self.deepseek_service.is_enabled
|
||||||
|
)
|
||||||
|
return rag_enabled or deepseek_enabled
|
||||||
|
|
||||||
|
@track_time("score_post", "scoring_manager")
|
||||||
|
@track_errors("scoring_manager", "score_post")
|
||||||
|
async def score_post(self, text: str) -> CombinedScore:
|
||||||
|
"""
|
||||||
|
Рассчитывает скоры для текста поста от всех сервисов.
|
||||||
|
|
||||||
|
Выполняет запросы параллельно для минимизации задержки.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Текст поста для оценки
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CombinedScore с результатами от всех сервисов
|
||||||
|
"""
|
||||||
|
result = CombinedScore()
|
||||||
|
|
||||||
|
if not text or not text.strip():
|
||||||
|
logger.debug("ScoringManager: Пустой текст, пропускаем скоринг")
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Собираем задачи для параллельного выполнения
|
||||||
|
tasks = []
|
||||||
|
task_names = []
|
||||||
|
|
||||||
|
# RAG API клиент
|
||||||
|
if self.rag_client and self.rag_client.is_enabled:
|
||||||
|
tasks.append(self._get_rag_score(text))
|
||||||
|
task_names.append("rag")
|
||||||
|
|
||||||
|
# DeepSeek сервис
|
||||||
|
if self.deepseek_service and self.deepseek_service.is_enabled:
|
||||||
|
tasks.append(self._get_deepseek_score(text))
|
||||||
|
task_names.append("deepseek")
|
||||||
|
|
||||||
|
if not tasks:
|
||||||
|
logger.debug("ScoringManager: Нет активных сервисов для скоринга")
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Выполняем параллельно
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
# Обрабатываем результаты
|
||||||
|
for name, res in zip(task_names, results):
|
||||||
|
if isinstance(res, Exception):
|
||||||
|
error_msg = str(res)
|
||||||
|
result.errors[name] = error_msg
|
||||||
|
# Ошибки уже залогированы в сервисах, здесь только предупреждение
|
||||||
|
logger.warning(f"ScoringManager: Ошибка от {name}: {error_msg}")
|
||||||
|
elif res is not None:
|
||||||
|
if name == "rag":
|
||||||
|
result.rag = res
|
||||||
|
elif name == "deepseek":
|
||||||
|
result.deepseek = res
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"ScoringManager: Скоринг завершен "
|
||||||
|
f"(rag={result.rag_score}, deepseek={result.deepseek_score})"
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _get_rag_score(self, text: str) -> Optional[ScoringResult]:
|
||||||
|
"""Получает скор от RAG API."""
|
||||||
|
try:
|
||||||
|
return await self.rag_client.calculate_score(text)
|
||||||
|
except InsufficientExamplesError:
|
||||||
|
# Недостаточно примеров - это не ошибка, просто нет данных
|
||||||
|
logger.info("ScoringManager: RAG - недостаточно примеров")
|
||||||
|
return None
|
||||||
|
except TextTooShortError:
|
||||||
|
# Текст слишком короткий - пропускаем
|
||||||
|
logger.debug("ScoringManager: RAG - текст слишком короткий")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
# Ошибки уже залогированы в RagApiClient, здесь только пробрасываем
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def _get_deepseek_score(self, text: str) -> Optional[ScoringResult]:
|
||||||
|
"""Получает скор от DeepSeek сервиса."""
|
||||||
|
try:
|
||||||
|
return await self.deepseek_service.calculate_score(text)
|
||||||
|
except TextTooShortError:
|
||||||
|
# Текст слишком короткий - пропускаем
|
||||||
|
logger.debug("ScoringManager: DeepSeek - текст слишком короткий")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
# Ошибки уже залогированы в DeepSeekService, здесь только пробрасываем
|
||||||
|
raise
|
||||||
|
|
||||||
|
@track_time("on_post_published", "scoring_manager")
|
||||||
|
async def on_post_published(self, text: str) -> None:
|
||||||
|
"""
|
||||||
|
Вызывается при публикации поста.
|
||||||
|
|
||||||
|
Добавляет текст как положительный пример для обучения RAG.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Текст опубликованного поста
|
||||||
|
"""
|
||||||
|
if not text or not text.strip():
|
||||||
|
return
|
||||||
|
|
||||||
|
tasks = []
|
||||||
|
|
||||||
|
if self.rag_client and self.rag_client.is_enabled:
|
||||||
|
tasks.append(self.rag_client.add_positive_example(text))
|
||||||
|
|
||||||
|
if self.deepseek_service and self.deepseek_service.is_enabled:
|
||||||
|
tasks.append(self.deepseek_service.add_positive_example(text))
|
||||||
|
|
||||||
|
if tasks:
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
logger.info("ScoringManager: Добавлен положительный пример")
|
||||||
|
|
||||||
|
@track_time("on_post_declined", "scoring_manager")
|
||||||
|
async def on_post_declined(self, text: str) -> None:
|
||||||
|
"""
|
||||||
|
Вызывается при отклонении поста.
|
||||||
|
|
||||||
|
Добавляет текст как отрицательный пример для обучения RAG.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Текст отклоненного поста
|
||||||
|
"""
|
||||||
|
if not text or not text.strip():
|
||||||
|
return
|
||||||
|
|
||||||
|
tasks = []
|
||||||
|
|
||||||
|
if self.rag_client and self.rag_client.is_enabled:
|
||||||
|
tasks.append(self.rag_client.add_negative_example(text))
|
||||||
|
|
||||||
|
if self.deepseek_service and self.deepseek_service.is_enabled:
|
||||||
|
tasks.append(self.deepseek_service.add_negative_example(text))
|
||||||
|
|
||||||
|
if tasks:
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
logger.info("ScoringManager: Добавлен отрицательный пример")
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Закрывает ресурсы всех сервисов."""
|
||||||
|
if self.deepseek_service:
|
||||||
|
await self.deepseek_service.close()
|
||||||
|
|
||||||
|
if self.rag_client:
|
||||||
|
await self.rag_client.close()
|
||||||
|
|
||||||
|
async def get_stats(self) -> dict:
|
||||||
|
"""Возвращает статистику всех сервисов."""
|
||||||
|
stats = {
|
||||||
|
"any_enabled": self.is_any_enabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.rag_client:
|
||||||
|
# Получаем статистику асинхронно от API
|
||||||
|
rag_stats = await self.rag_client.get_stats()
|
||||||
|
stats["rag"] = rag_stats if rag_stats else self.rag_client.get_stats_sync()
|
||||||
|
|
||||||
|
if self.deepseek_service:
|
||||||
|
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)
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
@@ -8,28 +8,25 @@ from apscheduler.triggers.cron import CronTrigger
|
|||||||
from helper_bot.utils.base_dependency_factory import get_global_instance
|
from helper_bot.utils.base_dependency_factory import get_global_instance
|
||||||
from logs.custom_logger import logger
|
from logs.custom_logger import logger
|
||||||
|
|
||||||
from .metrics import (
|
from .metrics import db_query_time, track_errors, track_time
|
||||||
track_time,
|
|
||||||
track_errors,
|
|
||||||
db_query_time
|
|
||||||
)
|
|
||||||
|
|
||||||
class AutoUnbanScheduler:
|
class AutoUnbanScheduler:
|
||||||
"""
|
"""
|
||||||
Класс для автоматического разбана пользователей по истечении срока блокировки.
|
Класс для автоматического разбана пользователей по истечении срока блокировки.
|
||||||
Запускается ежедневно в 5:00 по московскому времени.
|
Запускается ежедневно в 5:00 по московскому времени.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.bdf = get_global_instance()
|
self.bdf = get_global_instance()
|
||||||
self.bot_db = self.bdf.get_db()
|
self.bot_db = self.bdf.get_db()
|
||||||
self.scheduler = AsyncIOScheduler()
|
self.scheduler = AsyncIOScheduler()
|
||||||
self.bot = None # Будет установлен позже
|
self.bot = None # Будет установлен позже
|
||||||
|
|
||||||
def set_bot(self, bot):
|
def set_bot(self, bot):
|
||||||
"""Устанавливает экземпляр бота для отправки уведомлений"""
|
"""Устанавливает экземпляр бота для отправки уведомлений"""
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
|
|
||||||
@track_time("auto_unban_users", "auto_unban_scheduler")
|
@track_time("auto_unban_users", "auto_unban_scheduler")
|
||||||
@track_errors("auto_unban_scheduler", "auto_unban_users")
|
@track_errors("auto_unban_scheduler", "auto_unban_users")
|
||||||
@db_query_time("auto_unban_users", "users", "mixed")
|
@db_query_time("auto_unban_users", "users", "mixed")
|
||||||
@@ -41,26 +38,32 @@ class AutoUnbanScheduler:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
logger.info("Запуск автоматического разбана пользователей")
|
logger.info("Запуск автоматического разбана пользователей")
|
||||||
|
|
||||||
# Получаем текущий UNIX timestamp
|
# Получаем текущий UNIX timestamp
|
||||||
current_timestamp = int(datetime.now().timestamp())
|
current_timestamp = int(datetime.now().timestamp())
|
||||||
|
|
||||||
logger.info(f"Поиск пользователей для разблокировки на timestamp: {current_timestamp}")
|
logger.info(
|
||||||
|
f"Поиск пользователей для разблокировки на timestamp: {current_timestamp}"
|
||||||
|
)
|
||||||
|
|
||||||
# Получаем список пользователей для разблокировки
|
# Получаем список пользователей для разблокировки
|
||||||
users_to_unban = await self.bot_db.get_users_for_unblock_today(current_timestamp)
|
users_to_unban = await self.bot_db.get_users_for_unblock_today(
|
||||||
|
current_timestamp
|
||||||
|
)
|
||||||
|
|
||||||
if not users_to_unban:
|
if not users_to_unban:
|
||||||
logger.info("Нет пользователей для разблокировки сегодня")
|
logger.info("Нет пользователей для разблокировки сегодня")
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(f"Найдено {len(users_to_unban)} пользователей для разблокировки")
|
logger.info(
|
||||||
|
f"Найдено {len(users_to_unban)} пользователей для разблокировки"
|
||||||
|
)
|
||||||
|
|
||||||
# Список для отслеживания результатов
|
# Список для отслеживания результатов
|
||||||
success_count = 0
|
success_count = 0
|
||||||
failed_count = 0
|
failed_count = 0
|
||||||
failed_users = []
|
failed_users = []
|
||||||
|
|
||||||
# Разблокируем каждого пользователя
|
# Разблокируем каждого пользователя
|
||||||
for user_id in users_to_unban:
|
for user_id in users_to_unban:
|
||||||
try:
|
try:
|
||||||
@@ -75,92 +78,99 @@ class AutoUnbanScheduler:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
failed_count += 1
|
failed_count += 1
|
||||||
failed_users.append(f"{user_id}")
|
failed_users.append(f"{user_id}")
|
||||||
logger.error(f"Исключение при разблокировке пользователя {user_id}: {e}")
|
logger.error(
|
||||||
|
f"Исключение при разблокировке пользователя {user_id}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
# Формируем отчет
|
# Формируем отчет
|
||||||
report = self._generate_report(success_count, failed_count, failed_users, users_to_unban)
|
report = self._generate_report(
|
||||||
|
success_count, failed_count, failed_users, users_to_unban
|
||||||
|
)
|
||||||
|
|
||||||
# Отправляем отчет в лог-канал
|
# Отправляем отчет в лог-канал
|
||||||
await self._send_report(report)
|
await self._send_report(report)
|
||||||
|
|
||||||
logger.info(f"Автоматический разбан завершен. Успешно: {success_count}, Ошибок: {failed_count}")
|
logger.info(
|
||||||
|
f"Автоматический разбан завершен. Успешно: {success_count}, Ошибок: {failed_count}"
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Критическая ошибка в автоматическом разбане: {e}"
|
error_msg = f"Критическая ошибка в автоматическом разбане: {e}"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
await self._send_error_report(error_msg)
|
await self._send_error_report(error_msg)
|
||||||
|
|
||||||
def _generate_report(self, success_count: int, failed_count: int,
|
def _generate_report(
|
||||||
failed_users: list, all_users: dict) -> str:
|
self, success_count: int, failed_count: int, failed_users: list, all_users: dict
|
||||||
|
) -> str:
|
||||||
"""Генерирует отчет о результатах автоматического разбана"""
|
"""Генерирует отчет о результатах автоматического разбана"""
|
||||||
report = f"🤖 <b>Отчет об автоматическом разбане</b>\n\n"
|
report = f"🤖 <b>Отчет об автоматическом разбане</b>\n\n"
|
||||||
report += f"📅 Дата: {datetime.now().strftime('%d.%m.%Y %H:%M')}\n"
|
report += f"📅 Дата: {datetime.now().strftime('%d.%m.%Y %H:%M')}\n"
|
||||||
report += f"✅ Успешно разблокировано: {success_count}\n"
|
report += f"✅ Успешно разблокировано: {success_count}\n"
|
||||||
report += f"❌ Ошибок: {failed_count}\n\n"
|
report += f"❌ Ошибок: {failed_count}\n\n"
|
||||||
|
|
||||||
if success_count > 0:
|
if success_count > 0:
|
||||||
report += "✅ <b>Разблокированные пользователи:</b>\n"
|
report += "✅ <b>Разблокированные пользователи:</b>\n"
|
||||||
for user_id in all_users:
|
for user_id in all_users:
|
||||||
if str(user_id) not in failed_users:
|
if str(user_id) not in failed_users:
|
||||||
report += f"• ID: {user_id}\n"
|
report += f"• ID: {user_id}\n"
|
||||||
report += "\n"
|
report += "\n"
|
||||||
|
|
||||||
if failed_users:
|
if failed_users:
|
||||||
report += "❌ <b>Ошибки при разблокировке:</b>\n"
|
report += "❌ <b>Ошибки при разблокировке:</b>\n"
|
||||||
for user in failed_users:
|
for user in failed_users:
|
||||||
report += f"• {user}\n"
|
report += f"• {user}\n"
|
||||||
|
|
||||||
return report
|
return report
|
||||||
|
|
||||||
@track_time("send_report", "auto_unban_scheduler")
|
@track_time("send_report", "auto_unban_scheduler")
|
||||||
@track_errors("auto_unban_scheduler", "send_report")
|
@track_errors("auto_unban_scheduler", "send_report")
|
||||||
async def _send_report(self, report: str):
|
async def _send_report(self, report: str):
|
||||||
"""Отправляет отчет в лог-канал"""
|
"""Отправляет отчет в лог-канал"""
|
||||||
try:
|
try:
|
||||||
if self.bot:
|
if self.bot:
|
||||||
group_for_logs = self.bdf.settings['Telegram']['group_for_logs']
|
group_for_logs = self.bdf.settings["Telegram"]["group_for_logs"]
|
||||||
await self.bot.send_message(
|
await self.bot.send_message(
|
||||||
chat_id=group_for_logs,
|
chat_id=group_for_logs, text=report, parse_mode="HTML"
|
||||||
text=report,
|
|
||||||
parse_mode='HTML'
|
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при отправке отчета: {e}")
|
logger.error(f"Ошибка при отправке отчета: {e}")
|
||||||
|
|
||||||
@track_time("send_error_report", "auto_unban_scheduler")
|
@track_time("send_error_report", "auto_unban_scheduler")
|
||||||
@track_errors("auto_unban_scheduler", "send_error_report")
|
@track_errors("auto_unban_scheduler", "send_error_report")
|
||||||
async def _send_error_report(self, error_msg: str):
|
async def _send_error_report(self, error_msg: str):
|
||||||
"""Отправляет отчет об ошибке в важный лог-канал"""
|
"""Отправляет отчет об ошибке в важный лог-канал"""
|
||||||
try:
|
try:
|
||||||
if self.bot:
|
if self.bot:
|
||||||
important_logs = self.bdf.settings['Telegram']['important_logs']
|
important_logs = self.bdf.settings["Telegram"]["important_logs"]
|
||||||
await self.bot.send_message(
|
await self.bot.send_message(
|
||||||
chat_id=important_logs,
|
chat_id=important_logs,
|
||||||
text=f"🚨 <b>Ошибка автоматического разбана</b>\n\n{error_msg}",
|
text=f"🚨 <b>Ошибка автоматического разбана</b>\n\n{error_msg}",
|
||||||
parse_mode='HTML'
|
parse_mode="HTML",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при отправке отчета об ошибке: {e}")
|
logger.error(f"Ошибка при отправке отчета об ошибке: {e}")
|
||||||
|
|
||||||
def start_scheduler(self):
|
def start_scheduler(self):
|
||||||
"""Запускает планировщик задач"""
|
"""Запускает планировщик задач"""
|
||||||
try:
|
try:
|
||||||
# Добавляем задачу на ежедневное выполнение в 5:00 по Москве
|
# Добавляем задачу на ежедневное выполнение в 5:00 по Москве
|
||||||
self.scheduler.add_job(
|
self.scheduler.add_job(
|
||||||
self.auto_unban_users,
|
self.auto_unban_users,
|
||||||
CronTrigger(hour=5, minute=0, timezone='Europe/Moscow'),
|
CronTrigger(hour=5, minute=0, timezone="Europe/Moscow"),
|
||||||
id='auto_unban_users',
|
id="auto_unban_users",
|
||||||
name='Автоматический разбан пользователей',
|
name="Автоматический разбан пользователей",
|
||||||
replace_existing=True
|
replace_existing=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Запускаем планировщик
|
# Запускаем планировщик
|
||||||
self.scheduler.start()
|
self.scheduler.start()
|
||||||
logger.info("Планировщик автоматического разбана запущен. Задача запланирована на 5:00 по Москве")
|
logger.info(
|
||||||
|
"Планировщик автоматического разбана запущен. Задача запланирована на 5:00 по Москве"
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при запуске планировщика: {e}")
|
logger.error(f"Ошибка при запуске планировщика: {e}")
|
||||||
|
|
||||||
def stop_scheduler(self):
|
def stop_scheduler(self):
|
||||||
"""Останавливает планировщик задач"""
|
"""Останавливает планировщик задач"""
|
||||||
try:
|
try:
|
||||||
@@ -169,7 +179,7 @@ class AutoUnbanScheduler:
|
|||||||
logger.info("Планировщик автоматического разбана остановлен")
|
logger.info("Планировщик автоматического разбана остановлен")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при остановке планировщика: {e}")
|
logger.error(f"Ошибка при остановке планировщика: {e}")
|
||||||
|
|
||||||
async def run_manual_unban(self):
|
async def run_manual_unban(self):
|
||||||
"""Запускает разбан вручную (для тестирования)"""
|
"""Запускает разбан вручную (для тестирования)"""
|
||||||
logger.info("Запуск ручного разбана пользователей")
|
logger.info("Запуск ручного разбана пользователей")
|
||||||
|
|||||||
@@ -1,56 +1,115 @@
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from database.async_db import AsyncBotDB
|
from database.async_db import AsyncBotDB
|
||||||
|
from helper_bot.utils.s3_storage import S3StorageService
|
||||||
|
from logs.custom_logger import logger
|
||||||
|
|
||||||
|
|
||||||
class BaseDependencyFactory:
|
class BaseDependencyFactory:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
project_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
project_dir = os.path.dirname(
|
||||||
env_path = os.path.join(project_dir, '.env')
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
)
|
||||||
|
env_path = os.path.join(project_dir, ".env")
|
||||||
if os.path.exists(env_path):
|
if os.path.exists(env_path):
|
||||||
load_dotenv(env_path)
|
load_dotenv(env_path)
|
||||||
|
|
||||||
self.settings = {}
|
self.settings = {}
|
||||||
|
self._project_dir = project_dir
|
||||||
|
|
||||||
database_path = os.getenv('DATABASE_PATH', 'database/tg-bot-database.db')
|
database_path = os.getenv("DATABASE_PATH", "database/tg-bot-database.db")
|
||||||
if not os.path.isabs(database_path):
|
if not os.path.isabs(database_path):
|
||||||
database_path = os.path.join(project_dir, database_path)
|
database_path = os.path.join(project_dir, database_path)
|
||||||
|
|
||||||
self.database = AsyncBotDB(database_path)
|
self.database = AsyncBotDB(database_path)
|
||||||
|
|
||||||
self._load_settings_from_env()
|
self._load_settings_from_env()
|
||||||
|
self._init_s3_storage()
|
||||||
|
|
||||||
|
# ScoringManager инициализируется лениво
|
||||||
|
self._scoring_manager = None
|
||||||
|
|
||||||
def _load_settings_from_env(self):
|
def _load_settings_from_env(self):
|
||||||
"""Загружает настройки из переменных окружения."""
|
"""Загружает настройки из переменных окружения."""
|
||||||
self.settings['Telegram'] = {
|
self.settings["Telegram"] = {
|
||||||
'bot_token': os.getenv('BOT_TOKEN', ''),
|
"bot_token": os.getenv("BOT_TOKEN", ""),
|
||||||
'listen_bot_token': os.getenv('LISTEN_BOT_TOKEN', ''),
|
"listen_bot_token": os.getenv("LISTEN_BOT_TOKEN", ""),
|
||||||
'test_bot_token': os.getenv('TEST_BOT_TOKEN', ''),
|
"test_bot_token": os.getenv("TEST_BOT_TOKEN", ""),
|
||||||
'preview_link': self._parse_bool(os.getenv('PREVIEW_LINK', 'false')),
|
"preview_link": self._parse_bool(os.getenv("PREVIEW_LINK", "false")),
|
||||||
'main_public': os.getenv('MAIN_PUBLIC', ''),
|
"main_public": os.getenv("MAIN_PUBLIC", ""),
|
||||||
'group_for_posts': self._parse_int(os.getenv('GROUP_FOR_POSTS', '0')),
|
"group_for_posts": self._parse_int(os.getenv("GROUP_FOR_POSTS", "0")),
|
||||||
'group_for_message': self._parse_int(os.getenv('GROUP_FOR_MESSAGE', '0')),
|
"group_for_message": self._parse_int(os.getenv("GROUP_FOR_MESSAGE", "0")),
|
||||||
'group_for_logs': self._parse_int(os.getenv('GROUP_FOR_LOGS', '0')),
|
"group_for_logs": self._parse_int(os.getenv("GROUP_FOR_LOGS", "0")),
|
||||||
'important_logs': self._parse_int(os.getenv('IMPORTANT_LOGS', '0')),
|
"important_logs": self._parse_int(os.getenv("IMPORTANT_LOGS", "0")),
|
||||||
'archive': self._parse_int(os.getenv('ARCHIVE', '0')),
|
"archive": self._parse_int(os.getenv("ARCHIVE", "0")),
|
||||||
'test_group': self._parse_int(os.getenv('TEST_GROUP', '0'))
|
"test_group": self._parse_int(os.getenv("TEST_GROUP", "0")),
|
||||||
}
|
}
|
||||||
|
|
||||||
self.settings['Settings'] = {
|
self.settings["Settings"] = {
|
||||||
'logs': self._parse_bool(os.getenv('LOGS', 'false')),
|
"logs": self._parse_bool(os.getenv("LOGS", "false")),
|
||||||
'test': self._parse_bool(os.getenv('TEST', 'false'))
|
"test": self._parse_bool(os.getenv("TEST", "false")),
|
||||||
}
|
}
|
||||||
|
|
||||||
self.settings['Metrics'] = {
|
self.settings["Metrics"] = {
|
||||||
'host': os.getenv('METRICS_HOST', '0.0.0.0'),
|
"host": os.getenv("METRICS_HOST", "0.0.0.0"),
|
||||||
'port': self._parse_int(os.getenv('METRICS_PORT', '8080'))
|
"port": self._parse_int(os.getenv("METRICS_PORT", "8080")),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.settings["S3"] = {
|
||||||
|
"enabled": self._parse_bool(os.getenv("S3_ENABLED", "false")),
|
||||||
|
"endpoint_url": os.getenv("S3_ENDPOINT_URL", ""),
|
||||||
|
"access_key": os.getenv("S3_ACCESS_KEY", ""),
|
||||||
|
"secret_key": os.getenv("S3_SECRET_KEY", ""),
|
||||||
|
"bucket_name": os.getenv("S3_BUCKET_NAME", ""),
|
||||||
|
"region": os.getenv("S3_REGION", "us-east-1"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Настройки ML-скоринга
|
||||||
|
self.settings["Scoring"] = {
|
||||||
|
# RAG API
|
||||||
|
"rag_enabled": self._parse_bool(os.getenv("RAG_ENABLED", "false")),
|
||||||
|
"rag_api_url": os.getenv("RAG_API_URL", ""),
|
||||||
|
"rag_api_key": os.getenv("RAG_API_KEY", ""),
|
||||||
|
"rag_api_timeout": self._parse_int(os.getenv("RAG_API_TIMEOUT", "30")),
|
||||||
|
"rag_test_mode": self._parse_bool(os.getenv("RAG_TEST_MODE", "false")),
|
||||||
|
# DeepSeek
|
||||||
|
"deepseek_enabled": self._parse_bool(
|
||||||
|
os.getenv("DEEPSEEK_ENABLED", "false")
|
||||||
|
),
|
||||||
|
"deepseek_api_key": os.getenv("DEEPSEEK_API_KEY", ""),
|
||||||
|
"deepseek_api_url": os.getenv(
|
||||||
|
"DEEPSEEK_API_URL", "https://api.deepseek.com/v1/chat/completions"
|
||||||
|
),
|
||||||
|
"deepseek_model": os.getenv("DEEPSEEK_MODEL", "deepseek-chat"),
|
||||||
|
"deepseek_timeout": self._parse_int(os.getenv("DEEPSEEK_TIMEOUT", "30")),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _init_s3_storage(self):
|
||||||
|
"""Инициализирует S3StorageService если S3 включен."""
|
||||||
|
self.s3_storage = None
|
||||||
|
if self.settings["S3"]["enabled"]:
|
||||||
|
s3_config = self.settings["S3"]
|
||||||
|
if (
|
||||||
|
s3_config["endpoint_url"]
|
||||||
|
and s3_config["access_key"]
|
||||||
|
and s3_config["secret_key"]
|
||||||
|
and s3_config["bucket_name"]
|
||||||
|
):
|
||||||
|
self.s3_storage = S3StorageService(
|
||||||
|
endpoint_url=s3_config["endpoint_url"],
|
||||||
|
access_key=s3_config["access_key"],
|
||||||
|
secret_key=s3_config["secret_key"],
|
||||||
|
bucket_name=s3_config["bucket_name"],
|
||||||
|
region=s3_config["region"],
|
||||||
|
)
|
||||||
|
|
||||||
def _parse_bool(self, value: str) -> bool:
|
def _parse_bool(self, value: str) -> bool:
|
||||||
"""Парсит строковое значение в boolean."""
|
"""Парсит строковое значение в boolean."""
|
||||||
return value.lower() in ('true', '1', 'yes', 'on')
|
return value.lower() in ("true", "1", "yes", "on")
|
||||||
|
|
||||||
def _parse_int(self, value: str) -> int:
|
def _parse_int(self, value: str) -> int:
|
||||||
"""Парсит строковое значение в integer."""
|
"""Парсит строковое значение в integer."""
|
||||||
@@ -59,6 +118,13 @@ class BaseDependencyFactory:
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
def _parse_float(self, value: str) -> float:
|
||||||
|
"""Парсит строковое значение в float."""
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
def get_settings(self):
|
def get_settings(self):
|
||||||
return self.settings
|
return self.settings
|
||||||
|
|
||||||
@@ -66,9 +132,94 @@ class BaseDependencyFactory:
|
|||||||
"""Возвращает подключение к базе данных."""
|
"""Возвращает подключение к базе данных."""
|
||||||
return self.database
|
return self.database
|
||||||
|
|
||||||
|
def get_s3_storage(self) -> Optional[S3StorageService]:
|
||||||
|
"""Возвращает S3StorageService если S3 включен, иначе None."""
|
||||||
|
return self.s3_storage
|
||||||
|
|
||||||
|
def _init_scoring_manager(self):
|
||||||
|
"""
|
||||||
|
Инициализирует ScoringManager с RAG API клиентом и DeepSeek сервисом.
|
||||||
|
|
||||||
|
Вызывается лениво при первом обращении к get_scoring_manager().
|
||||||
|
"""
|
||||||
|
from helper_bot.services.scoring import (
|
||||||
|
DeepSeekService,
|
||||||
|
RagApiClient,
|
||||||
|
ScoringManager,
|
||||||
|
)
|
||||||
|
|
||||||
|
scoring_config = self.settings["Scoring"]
|
||||||
|
|
||||||
|
# Инициализация RAG API клиента
|
||||||
|
rag_client = None
|
||||||
|
if scoring_config["rag_enabled"]:
|
||||||
|
api_url = scoring_config["rag_api_url"]
|
||||||
|
api_key = scoring_config["rag_api_key"]
|
||||||
|
|
||||||
|
if not api_url or not api_key:
|
||||||
|
logger.warning("RAG включен, но не указаны RAG_API_URL или RAG_API_KEY")
|
||||||
|
else:
|
||||||
|
rag_client = RagApiClient(
|
||||||
|
api_url=api_url,
|
||||||
|
api_key=api_key,
|
||||||
|
timeout=scoring_config["rag_api_timeout"],
|
||||||
|
test_mode=scoring_config["rag_test_mode"],
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"RagApiClient инициализирован: {api_url} (test_mode={scoring_config['rag_test_mode']})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Инициализация DeepSeek сервиса
|
||||||
|
deepseek_service = None
|
||||||
|
if scoring_config["deepseek_enabled"] and scoring_config["deepseek_api_key"]:
|
||||||
|
deepseek_service = DeepSeekService(
|
||||||
|
api_key=scoring_config["deepseek_api_key"],
|
||||||
|
api_url=scoring_config["deepseek_api_url"],
|
||||||
|
model=scoring_config["deepseek_model"],
|
||||||
|
timeout=scoring_config["deepseek_timeout"],
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"DeepSeekService инициализирован: {scoring_config['deepseek_model']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Создаем менеджер
|
||||||
|
self._scoring_manager = ScoringManager(
|
||||||
|
rag_client=rag_client,
|
||||||
|
deepseek_service=deepseek_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
return self._scoring_manager
|
||||||
|
|
||||||
|
def get_scoring_manager(self):
|
||||||
|
"""
|
||||||
|
Возвращает ScoringManager для ML-скоринга постов.
|
||||||
|
|
||||||
|
Инициализируется лениво при первом вызове.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ScoringManager или None если скоринг полностью отключен
|
||||||
|
"""
|
||||||
|
if self._scoring_manager is None:
|
||||||
|
scoring_config = self.settings.get("Scoring", {})
|
||||||
|
|
||||||
|
# Проверяем, включен ли хотя бы один сервис
|
||||||
|
rag_enabled = scoring_config.get("rag_enabled", False)
|
||||||
|
deepseek_enabled = scoring_config.get("deepseek_enabled", False)
|
||||||
|
|
||||||
|
if not rag_enabled and not deepseek_enabled:
|
||||||
|
logger.info("Scoring полностью отключен (RAG и DeepSeek disabled)")
|
||||||
|
return None
|
||||||
|
|
||||||
|
self._init_scoring_manager()
|
||||||
|
|
||||||
|
return self._scoring_manager
|
||||||
|
|
||||||
|
|
||||||
_global_instance = None
|
_global_instance = None
|
||||||
|
|
||||||
|
|
||||||
def get_global_instance():
|
def get_global_instance():
|
||||||
"""Возвращает глобальный экземпляр BaseDependencyFactory."""
|
"""Возвращает глобальный экземпляр BaseDependencyFactory."""
|
||||||
global _global_instance
|
global _global_instance
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,63 +1,58 @@
|
|||||||
import html
|
import html
|
||||||
|
|
||||||
# Local imports - metrics
|
# Local imports - metrics
|
||||||
from .metrics import (
|
from .metrics import metrics, track_errors, track_time
|
||||||
metrics,
|
|
||||||
track_time,
|
|
||||||
track_errors
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
constants = {
|
constants = {
|
||||||
'HELLO_MESSAGE': "Привет, username!👋🏼&Меня зовут Виби, я бот канала 'Влюбленный Бийск'❤🤖"
|
"HELLO_MESSAGE": "Привет, username!👋🏼&Меня зовут Виби, я бот канала 'Влюбленный Бийск'❤🤖"
|
||||||
"&Я был создан для того, чтобы помочь тебе выложить пост в наш канал и если это необходимо, связаться с админами ✍✉"
|
"&Я был создан для того, чтобы помочь тебе выложить пост в наш канал и если это необходимо, связаться с админами ✍✉"
|
||||||
"&Так же я могу выдать тебе набор стикеров, где я буду главным героем🦸♂"
|
"&Так же я могу выдать тебе набор стикеров, где я буду главным героем🦸♂"
|
||||||
"&Наш бот голосового общения переехал ко мне! Доступен по кнопке 🎤Голосовой бот &Там можно послушать о чем говорит наш город🎧"
|
"&Наш бот голосового общения переехал ко мне! Доступен по кнопке 🎤Голосовой бот &Там можно послушать о чем говорит наш город🎧"
|
||||||
"&Предлагай свой пост мне и я обязательно его опубликую😉"
|
"&Предлагай свой пост мне и я обязательно его опубликую😉"
|
||||||
"&Для продолжения взаимодействия воспользуйся меню внизу твоего дисплея⬇"
|
"&Для продолжения взаимодействия воспользуйся меню внизу твоего дисплея⬇"
|
||||||
"&&Если что-то пошло не так: введи в чат команду /start или /restart, это перезапустит сценарий сначала."
|
"&&Если что-то пошло не так: введи в чат команду /start или /restart, это перезапустит сценарий сначала."
|
||||||
"Почитать инструкцию к боту можно по команде /help. Если есть вопросы, то пиши в личку: @Kerrad1"
|
"Почитать инструкцию к боту можно по команде /help. Если есть вопросы, то пиши в личку: @Kerrad1"
|
||||||
"&Не жми кнопку несколько раз если я не ответил с первого раза. Возможно ведутся тех.работы и я отвечу позже"
|
"&Не жми кнопку несколько раз если я не ответил с первого раза. Возможно ведутся тех.работы и я отвечу позже"
|
||||||
"&&Группа в ВК: https://vk.com/love_bsk"
|
"&&Группа в ВК: https://vk.com/love_bsk"
|
||||||
"&Канал в ТГ: https://t.me/love_bsk",
|
"&Канал в ТГ: https://t.me/love_bsk",
|
||||||
'SUGGEST_NEWS': "username, окей, жду от тебя текст поста🙌🏼"
|
"SUGGEST_NEWS": "username, окей, жду от тебя текст поста🙌🏼"
|
||||||
"&Обрати внимание, что я умный и смогу из твоего текста понять команды указанные ниже😉"
|
"&Обрати внимание, что я умный и смогу из твоего текста понять команды указанные ниже😉"
|
||||||
"&Если хочешь чтобы пост был опубликован анонимно, напиши в любом месте своего поста слово 'анон'."
|
"&Если хочешь чтобы пост был опубликован анонимно, напиши в любом месте своего поста слово 'анон'."
|
||||||
"&Если хочешь опубликовать пост не анонимно, то напиши 'не анон', 'неанон' или не пиши ничего."
|
"&Если хочешь опубликовать пост не анонимно, то напиши 'не анон', 'неанон' или не пиши ничего."
|
||||||
"&&❗️❗️Я обучен только на команды, указанные мной выше👆"
|
"&&❗️❗️Я обучен только на команды, указанные мной выше👆"
|
||||||
"&❗️❗️Проверь, чтобы указание авторства было выполнено так как я попросил, иначе пост будет выложен не корректно"
|
"&❗️❗️Проверь, чтобы указание авторства было выполнено так как я попросил, иначе пост будет выложен не корректно"
|
||||||
"&Пост будет опубликован только в группе ТГ📩",
|
"&Пост будет опубликован только в группе ТГ📩",
|
||||||
"CONNECT_WITH_ADMIN": "username, напиши свое обращение или предложение✍️"
|
"CONNECT_WITH_ADMIN": "username, напиши свое обращение или предложение✍️"
|
||||||
"&Мы рассмотрим и ответим тебе в ближайшее время☺️❤️",
|
"&Мы рассмотрим и ответим тебе в ближайшее время☺️❤️",
|
||||||
"DEL_MESSAGE": "username, напиши свое обращение или предложение✍"
|
"DEL_MESSAGE": "username, напиши свое обращение или предложение✍"
|
||||||
"&Мы рассмотрим и ответим тебе в ближайшее время☺❤",
|
"&Мы рассмотрим и ответим тебе в ближайшее время☺❤",
|
||||||
"BYE_MESSAGE": "Если позднее захочешь предложить еще один пост или обратиться к админам с вопросом, то просто пришли в чат команду 👉 /restart"
|
"BYE_MESSAGE": "Если позднее захочешь предложить еще один пост или обратиться к админам с вопросом, то просто пришли в чат команду 👉 /restart"
|
||||||
"&&И тебе пока!👋🏼❤️",
|
"&&И тебе пока!👋🏼❤️",
|
||||||
"USER_ERROR": "Увы, я не понимаю тебя😐💔 Выбери один из пунктов в нижнем меню, а затем пиши.",
|
"USER_ERROR": "Увы, я не понимаю тебя😐💔 Выбери один из пунктов в нижнем меню, а затем пиши.",
|
||||||
"QUESTION": "Сообщение успешно отправлено❤️ Ответим, как только сможем😉",
|
"QUESTION": "Сообщение успешно отправлено❤️ Ответим, как только сможем😉",
|
||||||
"SUCCESS_SEND_MESSAGE": "Пост успешно отправлен❤️ Ожидай одобрения😊",
|
"SUCCESS_SEND_MESSAGE": "Пост успешно отправлен❤️ Ожидай одобрения😊",
|
||||||
# Voice handler messages
|
# Voice handler messages
|
||||||
"MESSAGE_FOR_STANDUP": "Отлично, ты вошел в режим стендапа 📣"
|
"MESSAGE_FOR_STANDUP": "Отлично, ты вошел в режим стендапа 📣"
|
||||||
"&Это свободное пространство, в котором может высказаться каждый житель нашего города, и он будет услышан🙌🏼"
|
"&Это свободное пространство, в котором может высказаться каждый житель нашего города, и он будет услышан🙌🏼"
|
||||||
"&Для того чтобы высказаться, нажми кнопку: 'Высказаться' и запиши голосовое сообщение, оно выпадет анонимно кому-то другому🗣"
|
"&Для того чтобы высказаться, нажми кнопку: 'Высказаться' и запиши голосовое сообщение, оно выпадет анонимно кому-то другому🗣"
|
||||||
"&Для того чтобы послушать о чем говорит наш город, нажми кнопку: 'Послушать'👂"
|
"&Для того чтобы послушать о чем говорит наш город, нажми кнопку: 'Послушать'👂"
|
||||||
"&Ты можешь анонимно пообщаться, поделиться чем-то важным, обратиться напрямую к жителям🤝 Также можешь выступить перед аудиторией (спеть песню, рассказать стихотворение, шутку)🎤"
|
"&Ты можешь анонимно пообщаться, поделиться чем-то важным, обратиться напрямую к жителям🤝 Также можешь выступить перед аудиторией (спеть песню, рассказать стихотворение, шутку)🎤"
|
||||||
"&❗️Но пожалуйста не оскорбляй никого, и будь вежлив.",
|
"&❗️Но пожалуйста не оскорбляй никого, и будь вежлив.",
|
||||||
'WELCOME_MESSAGE': "<b>Привет.</b>",
|
"WELCOME_MESSAGE": "<b>Привет.</b>",
|
||||||
'DESCRIPTION_MESSAGE': "<i>Здесь можно послушать голосовые сообщения от совершенно незнакомых людей из Бийска</i>",
|
"DESCRIPTION_MESSAGE": "<i>Здесь можно послушать голосовые сообщения от совершенно незнакомых людей из Бийска</i>",
|
||||||
'ANALOGY_MESSAGE': "Это почти как написать письмо, положить его в бутылку и швырнуть в океан. Никогда не узнаешь, послушал его кто-то или нет и ответить тоже не получится..",
|
"ANALOGY_MESSAGE": "Это почти как написать письмо, положить его в бутылку и швырнуть в океан. Никогда не узнаешь, послушал его кто-то или нет и ответить тоже не получится..",
|
||||||
'RULES_MESSAGE': "Записывать можно всё что угодно — никаких правил нет. Главное — твой голос, <i>хотя бы на 5-10 секунд</i>",
|
"RULES_MESSAGE": "Записывать можно всё что угодно — никаких правил нет. Главное — твой голос, <i>хотя бы на 5-10 секунд</i>",
|
||||||
'ANONYMITY_MESSAGE': "Здесь всё анонимно: тот, кому я отправлю твое сообщение, не узнает ни твое имя, ни твой аккаунт (так что можно не стесняться говорить то, что не стал(а) бы выкладывать в собственные соцсети)",
|
"ANONYMITY_MESSAGE": "Здесь всё анонимно: тот, кому я отправлю твое сообщение, не узнает ни твое имя, ни твой аккаунт (так что можно не стесняться говорить то, что не стал(а) бы выкладывать в собственные соцсети)",
|
||||||
'SUGGESTION_MESSAGE': "Если не знаешь, что сказать, можешь просто прочитать любое текстовое сообщение из недавно полученных или отправленных (или спеть, рассказать стихотворенье)",
|
"SUGGESTION_MESSAGE": "Если не знаешь, что сказать, можешь просто прочитать любое текстовое сообщение из недавно полученных или отправленных (или спеть, рассказать стихотворенье)",
|
||||||
'EMOJI_INFO_MESSAGE': "Любые войсы будут помечены эмоджи. <b>Твой эмоджи - </b>{emoji}Таким эмоджи будут помечены твои сообщения для других Но другие люди не узнают кто за каким эмоджи скрывается:)",
|
"EMOJI_INFO_MESSAGE": "Любые войсы будут помечены эмоджи. <b>Твой эмоджи - </b>{emoji}Таким эмоджи будут помечены твои сообщения для других Но другие люди не узнают кто за каким эмоджи скрывается:)",
|
||||||
'HELP_INFO_MESSAGE': "Так же можешь ознакомиться с инструкцией к боту по команде /help",
|
"HELP_INFO_MESSAGE": "Так же можешь ознакомиться с инструкцией к боту по команде /help",
|
||||||
'FINAL_MESSAGE': "<b>Ну всё, достаточно инструкций. записывайся! Микрофон твой - </b> 🎤",
|
"FINAL_MESSAGE": "<b>Ну всё, достаточно инструкций. записывайся! Микрофон твой - </b> 🎤",
|
||||||
'HELP_MESSAGE': "Когда-нибудь здесь будет инструкция к боту. А пока по вопросам пиши в личку: @Kerrad1 или в Связаться с админами",
|
"HELP_MESSAGE": "Когда-нибудь здесь будет инструкция к боту. А пока по вопросам пиши в личку: @Kerrad1 или в Связаться с админами",
|
||||||
'VOICE_SAVED_MESSAGE': "Окей, сохранил!👌",
|
"VOICE_SAVED_MESSAGE": "Окей, сохранил!👌",
|
||||||
'LISTENINGS_CLEARED_MESSAGE': "Прослушивания очищены. Можешь начать слушать заново🤗",
|
"LISTENINGS_CLEARED_MESSAGE": "Прослушивания очищены. Можешь начать слушать заново🤗",
|
||||||
'NO_AUDIO_MESSAGE': "Прости, ты прослушал все аудио😔. Возвращайся позже, возможно наша база пополнится",
|
"NO_AUDIO_MESSAGE": "Прости, ты прослушал все аудио😔. Возвращайся позже, возможно наша база пополнится",
|
||||||
'UNKNOWN_CONTENT_MESSAGE': "Я тебя не понимаю🤷♀️ запиши голосовое",
|
"UNKNOWN_CONTENT_MESSAGE": "Я тебя не понимаю🤷♀️ запиши голосовое",
|
||||||
'RECORD_VOICE_MESSAGE': "Хорошо, теперь пришли мне свое голосовое сообщение"
|
"RECORD_VOICE_MESSAGE": "Хорошо, теперь пришли мне свое голосовое сообщение",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -69,5 +64,5 @@ def get_message(username: str, type_message: str):
|
|||||||
raise TypeError("username is None")
|
raise TypeError("username is None")
|
||||||
message = constants[type_message]
|
message = constants[type_message]
|
||||||
# Экранируем потенциально проблемные символы для HTML
|
# Экранируем потенциально проблемные символы для HTML
|
||||||
message = message.replace('username', html.escape(username)).replace('&', '\n')
|
message = message.replace("username", html.escape(username)).replace("&", "\n")
|
||||||
return message
|
return message
|
||||||
|
|||||||
@@ -3,439 +3,472 @@ Metrics module for Telegram bot monitoring with Prometheus.
|
|||||||
Provides predefined metrics for bot commands, errors, performance, and user activity.
|
Provides predefined metrics for bot commands, errors, performance, and user activity.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Dict, Any, Optional
|
|
||||||
from prometheus_client import Counter, Histogram, Gauge, generate_latest, CONTENT_TYPE_LATEST
|
|
||||||
from prometheus_client.core import CollectorRegistry
|
|
||||||
import time
|
|
||||||
import os
|
|
||||||
from functools import wraps
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
|
import time
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from functools import wraps
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from prometheus_client import (
|
||||||
|
CONTENT_TYPE_LATEST,
|
||||||
|
Counter,
|
||||||
|
Gauge,
|
||||||
|
Histogram,
|
||||||
|
generate_latest,
|
||||||
|
)
|
||||||
|
from prometheus_client.core import CollectorRegistry
|
||||||
|
|
||||||
# Метрики rate limiter теперь создаются в основном классе
|
# Метрики rate limiter теперь создаются в основном классе
|
||||||
|
|
||||||
|
|
||||||
class BotMetrics:
|
class BotMetrics:
|
||||||
"""Central class for managing all bot metrics."""
|
"""Central class for managing all bot metrics."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.registry = CollectorRegistry()
|
self.registry = CollectorRegistry()
|
||||||
|
|
||||||
# Создаем метрики rate limiter в том же registry
|
# Создаем метрики rate limiter в том же registry
|
||||||
self._create_rate_limit_metrics()
|
self._create_rate_limit_metrics()
|
||||||
|
|
||||||
# Bot commands counter
|
# Bot commands counter
|
||||||
self.bot_commands_total = Counter(
|
self.bot_commands_total = Counter(
|
||||||
'bot_commands_total',
|
"bot_commands_total",
|
||||||
'Total number of bot commands processed',
|
"Total number of bot commands processed",
|
||||||
['command', 'status', 'handler_type', 'user_type'],
|
["command", "status", "handler_type", "user_type"],
|
||||||
registry=self.registry
|
registry=self.registry,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Method execution time histogram
|
# Method execution time histogram
|
||||||
self.method_duration_seconds = Histogram(
|
self.method_duration_seconds = Histogram(
|
||||||
'method_duration_seconds',
|
"method_duration_seconds",
|
||||||
'Time spent executing methods',
|
"Time spent executing methods",
|
||||||
['method_name', 'handler_type', 'status'],
|
["method_name", "handler_type", "status"],
|
||||||
# Оптимизированные buckets для Telegram API (обычно < 1 сек)
|
# Оптимизированные buckets для Telegram API (обычно < 1 сек)
|
||||||
buckets=[0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0],
|
buckets=[0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0],
|
||||||
registry=self.registry
|
registry=self.registry,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Errors counter
|
# Errors counter
|
||||||
self.errors_total = Counter(
|
self.errors_total = Counter(
|
||||||
'errors_total',
|
"errors_total",
|
||||||
'Total number of errors',
|
"Total number of errors",
|
||||||
['error_type', 'handler_type', 'method_name'],
|
["error_type", "handler_type", "method_name"],
|
||||||
registry=self.registry
|
registry=self.registry,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Active users gauge
|
# Active users gauge
|
||||||
self.active_users = Gauge(
|
self.active_users = Gauge(
|
||||||
'active_users',
|
"active_users",
|
||||||
'Number of currently active users',
|
"Number of currently active users",
|
||||||
['user_type'],
|
["user_type"],
|
||||||
registry=self.registry
|
registry=self.registry,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Total users gauge (отдельная метрика)
|
# Total users gauge (отдельная метрика)
|
||||||
self.total_users = Gauge(
|
self.total_users = Gauge(
|
||||||
'total_users',
|
"total_users", "Total number of users in database", registry=self.registry
|
||||||
'Total number of users in database',
|
|
||||||
registry=self.registry
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Database query metrics
|
# Database query metrics
|
||||||
self.db_query_duration_seconds = Histogram(
|
self.db_query_duration_seconds = Histogram(
|
||||||
'db_query_duration_seconds',
|
"db_query_duration_seconds",
|
||||||
'Time spent executing database queries',
|
"Time spent executing database queries",
|
||||||
['query_type', 'table_name', 'operation'],
|
["query_type", "table_name", "operation"],
|
||||||
# Оптимизированные buckets для SQLite/PostgreSQL
|
# Оптимизированные buckets для SQLite/PostgreSQL
|
||||||
buckets=[0.001, 0.005, 0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5],
|
buckets=[0.001, 0.005, 0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5],
|
||||||
registry=self.registry
|
registry=self.registry,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Database queries counter
|
# Database queries counter
|
||||||
self.db_queries_total = Counter(
|
self.db_queries_total = Counter(
|
||||||
'db_queries_total',
|
"db_queries_total",
|
||||||
'Total number of database queries executed',
|
"Total number of database queries executed",
|
||||||
['query_type', 'table_name', 'operation'],
|
["query_type", "table_name", "operation"],
|
||||||
registry=self.registry
|
registry=self.registry,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Database errors counter
|
# Database errors counter
|
||||||
self.db_errors_total = Counter(
|
self.db_errors_total = Counter(
|
||||||
'db_errors_total',
|
"db_errors_total",
|
||||||
'Total number of database errors',
|
"Total number of database errors",
|
||||||
['error_type', 'query_type', 'table_name', 'operation'],
|
["error_type", "query_type", "table_name", "operation"],
|
||||||
registry=self.registry
|
registry=self.registry,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Message processing metrics
|
# Message processing metrics
|
||||||
self.messages_processed_total = Counter(
|
self.messages_processed_total = Counter(
|
||||||
'messages_processed_total',
|
"messages_processed_total",
|
||||||
'Total number of messages processed',
|
"Total number of messages processed",
|
||||||
['message_type', 'chat_type', 'handler_type'],
|
["message_type", "chat_type", "handler_type"],
|
||||||
registry=self.registry
|
registry=self.registry,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Middleware execution metrics
|
# Middleware execution metrics
|
||||||
self.middleware_duration_seconds = Histogram(
|
self.middleware_duration_seconds = Histogram(
|
||||||
'middleware_duration_seconds',
|
"middleware_duration_seconds",
|
||||||
'Time spent in middleware execution',
|
"Time spent in middleware execution",
|
||||||
['middleware_name', 'status'],
|
["middleware_name", "status"],
|
||||||
# Middleware должен быть быстрым
|
# Middleware должен быть быстрым
|
||||||
buckets=[0.001, 0.005, 0.01, 0.05, 0.1, 0.25],
|
buckets=[0.001, 0.005, 0.01, 0.05, 0.1, 0.25],
|
||||||
registry=self.registry
|
registry=self.registry,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Rate limiting metrics
|
# Rate limiting metrics
|
||||||
self.rate_limit_hits_total = Counter(
|
self.rate_limit_hits_total = Counter(
|
||||||
'rate_limit_hits_total',
|
"rate_limit_hits_total",
|
||||||
'Total number of rate limit hits',
|
"Total number of rate limit hits",
|
||||||
['limit_type', 'user_id', 'action'],
|
["limit_type", "user_id", "action"],
|
||||||
registry=self.registry
|
registry=self.registry,
|
||||||
)
|
)
|
||||||
# User activity metrics
|
# User activity metrics
|
||||||
self.user_activity_total = Counter(
|
self.user_activity_total = Counter(
|
||||||
'user_activity_total',
|
"user_activity_total",
|
||||||
'Total user activity events',
|
"Total user activity events",
|
||||||
['activity_type', 'user_type', 'chat_type'],
|
["activity_type", "user_type", "chat_type"],
|
||||||
registry=self.registry
|
registry=self.registry,
|
||||||
)
|
)
|
||||||
|
|
||||||
# File download metrics
|
# File download metrics
|
||||||
self.file_downloads_total = Counter(
|
self.file_downloads_total = Counter(
|
||||||
'file_downloads_total',
|
"file_downloads_total",
|
||||||
'Total number of file downloads',
|
"Total number of file downloads",
|
||||||
['content_type', 'status'],
|
["content_type", "status"],
|
||||||
registry=self.registry
|
registry=self.registry,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.file_download_duration_seconds = Histogram(
|
self.file_download_duration_seconds = Histogram(
|
||||||
'file_download_duration_seconds',
|
"file_download_duration_seconds",
|
||||||
'Time spent downloading files',
|
"Time spent downloading files",
|
||||||
['content_type'],
|
["content_type"],
|
||||||
buckets=[0.1, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0],
|
buckets=[0.1, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0],
|
||||||
registry=self.registry
|
registry=self.registry,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.file_download_size_bytes = Histogram(
|
self.file_download_size_bytes = Histogram(
|
||||||
'file_download_size_bytes',
|
"file_download_size_bytes",
|
||||||
'Size of downloaded files in bytes',
|
"Size of downloaded files in bytes",
|
||||||
['content_type'],
|
["content_type"],
|
||||||
buckets=[1024, 10240, 102400, 1048576, 10485760, 104857600, 1073741824],
|
buckets=[1024, 10240, 102400, 1048576, 10485760, 104857600, 1073741824],
|
||||||
registry=self.registry
|
registry=self.registry,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Media processing metrics
|
# Media processing metrics
|
||||||
self.media_processing_total = Counter(
|
self.media_processing_total = Counter(
|
||||||
'media_processing_total',
|
"media_processing_total",
|
||||||
'Total number of media processing operations',
|
"Total number of media processing operations",
|
||||||
['content_type', 'status'],
|
["content_type", "status"],
|
||||||
registry=self.registry
|
registry=self.registry,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.media_processing_duration_seconds = Histogram(
|
self.media_processing_duration_seconds = Histogram(
|
||||||
'media_processing_duration_seconds',
|
"media_processing_duration_seconds",
|
||||||
'Time spent processing media',
|
"Time spent processing media",
|
||||||
['content_type'],
|
["content_type"],
|
||||||
buckets=[0.1, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0],
|
buckets=[0.1, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0],
|
||||||
registry=self.registry
|
registry=self.registry,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _create_rate_limit_metrics(self):
|
def _create_rate_limit_metrics(self):
|
||||||
"""Создает метрики rate limiter в основном registry"""
|
"""Создает метрики rate limiter в основном registry"""
|
||||||
try:
|
try:
|
||||||
# Создаем метрики rate limiter в том же registry
|
# Создаем метрики rate limiter в том же registry
|
||||||
self.rate_limit_requests_total = Counter(
|
self.rate_limit_requests_total = Counter(
|
||||||
'rate_limit_requests_total',
|
"rate_limit_requests_total",
|
||||||
'Total number of rate limited requests',
|
"Total number of rate limited requests",
|
||||||
['chat_id', 'status', 'error_type'],
|
["chat_id", "status", "error_type"],
|
||||||
registry=self.registry
|
registry=self.registry,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.rate_limit_errors_total = Counter(
|
self.rate_limit_errors_total = Counter(
|
||||||
'rate_limit_errors_total',
|
"rate_limit_errors_total",
|
||||||
'Total number of rate limit errors',
|
"Total number of rate limit errors",
|
||||||
['error_type', 'chat_id'],
|
["error_type", "chat_id"],
|
||||||
registry=self.registry
|
registry=self.registry,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.rate_limit_wait_duration_seconds = Histogram(
|
self.rate_limit_wait_duration_seconds = Histogram(
|
||||||
'rate_limit_wait_duration_seconds',
|
"rate_limit_wait_duration_seconds",
|
||||||
'Time spent waiting due to rate limiting',
|
"Time spent waiting due to rate limiting",
|
||||||
['chat_id'],
|
["chat_id"],
|
||||||
buckets=[0.1, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0],
|
buckets=[0.1, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0],
|
||||||
registry=self.registry
|
registry=self.registry,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.rate_limit_active_chats = Gauge(
|
self.rate_limit_active_chats = Gauge(
|
||||||
'rate_limit_active_chats',
|
"rate_limit_active_chats",
|
||||||
'Number of active chats with rate limiting',
|
"Number of active chats with rate limiting",
|
||||||
registry=self.registry
|
registry=self.registry,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.rate_limit_success_rate = Gauge(
|
self.rate_limit_success_rate = Gauge(
|
||||||
'rate_limit_success_rate',
|
"rate_limit_success_rate",
|
||||||
'Success rate of rate limited requests',
|
"Success rate of rate limited requests",
|
||||||
['chat_id'],
|
["chat_id"],
|
||||||
registry=self.registry
|
registry=self.registry,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.rate_limit_requests_per_minute = Gauge(
|
self.rate_limit_requests_per_minute = Gauge(
|
||||||
'rate_limit_requests_per_minute',
|
"rate_limit_requests_per_minute",
|
||||||
'Requests per minute',
|
"Requests per minute",
|
||||||
['chat_id'],
|
["chat_id"],
|
||||||
registry=self.registry
|
registry=self.registry,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.rate_limit_total_requests = Gauge(
|
self.rate_limit_total_requests = Gauge(
|
||||||
'rate_limit_total_requests',
|
"rate_limit_total_requests",
|
||||||
'Total number of requests',
|
"Total number of requests",
|
||||||
['chat_id'],
|
["chat_id"],
|
||||||
registry=self.registry
|
registry=self.registry,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.rate_limit_total_errors = Gauge(
|
self.rate_limit_total_errors = Gauge(
|
||||||
'rate_limit_total_errors',
|
"rate_limit_total_errors",
|
||||||
'Total number of errors',
|
"Total number of errors",
|
||||||
['chat_id', 'error_type'],
|
["chat_id", "error_type"],
|
||||||
registry=self.registry
|
registry=self.registry,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.rate_limit_avg_wait_time_seconds = Gauge(
|
self.rate_limit_avg_wait_time_seconds = Gauge(
|
||||||
'rate_limit_avg_wait_time_seconds',
|
"rate_limit_avg_wait_time_seconds",
|
||||||
'Average wait time in seconds',
|
"Average wait time in seconds",
|
||||||
['chat_id'],
|
["chat_id"],
|
||||||
registry=self.registry
|
registry=self.registry,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Логируем ошибку, но не прерываем инициализацию
|
# Логируем ошибку, но не прерываем инициализацию
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logging.warning(f"Failed to create rate limit metrics: {e}")
|
logging.warning(f"Failed to create rate limit metrics: {e}")
|
||||||
|
|
||||||
def record_command(self, command_type: str, handler_type: str = "unknown", user_type: str = "unknown", status: str = "success"):
|
def record_command(
|
||||||
|
self,
|
||||||
|
command_type: str,
|
||||||
|
handler_type: str = "unknown",
|
||||||
|
user_type: str = "unknown",
|
||||||
|
status: str = "success",
|
||||||
|
):
|
||||||
"""Record a bot command execution."""
|
"""Record a bot command execution."""
|
||||||
self.bot_commands_total.labels(
|
self.bot_commands_total.labels(
|
||||||
command=command_type,
|
command=command_type,
|
||||||
status=status,
|
status=status,
|
||||||
handler_type=handler_type,
|
handler_type=handler_type,
|
||||||
user_type=user_type
|
user_type=user_type,
|
||||||
).inc()
|
).inc()
|
||||||
|
|
||||||
def record_error(self, error_type: str, handler_type: str = "unknown", method_name: str = "unknown"):
|
def record_error(
|
||||||
|
self,
|
||||||
|
error_type: str,
|
||||||
|
handler_type: str = "unknown",
|
||||||
|
method_name: str = "unknown",
|
||||||
|
):
|
||||||
"""Record an error occurrence."""
|
"""Record an error occurrence."""
|
||||||
self.errors_total.labels(
|
self.errors_total.labels(
|
||||||
error_type=error_type,
|
error_type=error_type, handler_type=handler_type, method_name=method_name
|
||||||
handler_type=handler_type,
|
|
||||||
method_name=method_name
|
|
||||||
).inc()
|
).inc()
|
||||||
|
|
||||||
def record_method_duration(self, method_name: str, duration: float, handler_type: str = "unknown", status: str = "success"):
|
def record_method_duration(
|
||||||
|
self,
|
||||||
|
method_name: str,
|
||||||
|
duration: float,
|
||||||
|
handler_type: str = "unknown",
|
||||||
|
status: str = "success",
|
||||||
|
):
|
||||||
"""Record method execution duration."""
|
"""Record method execution duration."""
|
||||||
self.method_duration_seconds.labels(
|
self.method_duration_seconds.labels(
|
||||||
method_name=method_name,
|
method_name=method_name, handler_type=handler_type, status=status
|
||||||
handler_type=handler_type,
|
|
||||||
status=status
|
|
||||||
).observe(duration)
|
).observe(duration)
|
||||||
|
|
||||||
def set_active_users(self, count: int, user_type: str = "daily"):
|
def set_active_users(self, count: int, user_type: str = "daily"):
|
||||||
"""Set the number of active users for a specific type."""
|
"""Set the number of active users for a specific type."""
|
||||||
self.active_users.labels(user_type=user_type).set(count)
|
self.active_users.labels(user_type=user_type).set(count)
|
||||||
|
|
||||||
def set_total_users(self, count: int):
|
def set_total_users(self, count: int):
|
||||||
"""Set the total number of users in database."""
|
"""Set the total number of users in database."""
|
||||||
self.total_users.set(count)
|
self.total_users.set(count)
|
||||||
|
|
||||||
def record_db_query(self, query_type: str, duration: float, table_name: str = "unknown", operation: str = "unknown"):
|
def record_db_query(
|
||||||
|
self,
|
||||||
|
query_type: str,
|
||||||
|
duration: float,
|
||||||
|
table_name: str = "unknown",
|
||||||
|
operation: str = "unknown",
|
||||||
|
):
|
||||||
"""Record database query duration."""
|
"""Record database query duration."""
|
||||||
self.db_query_duration_seconds.labels(
|
self.db_query_duration_seconds.labels(
|
||||||
query_type=query_type,
|
query_type=query_type, table_name=table_name, operation=operation
|
||||||
table_name=table_name,
|
|
||||||
operation=operation
|
|
||||||
).observe(duration)
|
).observe(duration)
|
||||||
self.db_queries_total.labels(
|
self.db_queries_total.labels(
|
||||||
query_type=query_type,
|
query_type=query_type, table_name=table_name, operation=operation
|
||||||
table_name=table_name,
|
|
||||||
operation=operation
|
|
||||||
).inc()
|
).inc()
|
||||||
|
|
||||||
def record_message(self, message_type: str, chat_type: str = "unknown", handler_type: str = "unknown"):
|
def record_message(
|
||||||
|
self,
|
||||||
|
message_type: str,
|
||||||
|
chat_type: str = "unknown",
|
||||||
|
handler_type: str = "unknown",
|
||||||
|
):
|
||||||
"""Record a processed message."""
|
"""Record a processed message."""
|
||||||
self.messages_processed_total.labels(
|
self.messages_processed_total.labels(
|
||||||
message_type=message_type,
|
message_type=message_type, chat_type=chat_type, handler_type=handler_type
|
||||||
chat_type=chat_type,
|
|
||||||
handler_type=handler_type
|
|
||||||
).inc()
|
).inc()
|
||||||
|
|
||||||
def record_middleware(self, middleware_name: str, duration: float, status: str = "success"):
|
def record_middleware(
|
||||||
|
self, middleware_name: str, duration: float, status: str = "success"
|
||||||
|
):
|
||||||
"""Record middleware execution duration."""
|
"""Record middleware execution duration."""
|
||||||
self.middleware_duration_seconds.labels(
|
self.middleware_duration_seconds.labels(
|
||||||
middleware_name=middleware_name,
|
middleware_name=middleware_name, status=status
|
||||||
status=status
|
|
||||||
).observe(duration)
|
).observe(duration)
|
||||||
|
|
||||||
def record_file_download(self, content_type: str, file_size: int, duration: float):
|
def record_file_download(self, content_type: str, file_size: int, duration: float):
|
||||||
"""Record file download metrics."""
|
"""Record file download metrics."""
|
||||||
self.file_downloads_total.labels(
|
self.file_downloads_total.labels(
|
||||||
content_type=content_type,
|
content_type=content_type, status="success"
|
||||||
status="success"
|
|
||||||
).inc()
|
).inc()
|
||||||
|
|
||||||
self.file_download_duration_seconds.labels(
|
self.file_download_duration_seconds.labels(content_type=content_type).observe(
|
||||||
content_type=content_type
|
duration
|
||||||
).observe(duration)
|
)
|
||||||
|
|
||||||
self.file_download_size_bytes.labels(
|
self.file_download_size_bytes.labels(content_type=content_type).observe(
|
||||||
content_type=content_type
|
file_size
|
||||||
).observe(file_size)
|
)
|
||||||
|
|
||||||
def record_file_download_error(self, content_type: str, error_message: str):
|
def record_file_download_error(self, content_type: str, error_message: str):
|
||||||
"""Record file download error metrics."""
|
"""Record file download error metrics."""
|
||||||
self.file_downloads_total.labels(
|
self.file_downloads_total.labels(
|
||||||
content_type=content_type,
|
content_type=content_type, status="error"
|
||||||
status="error"
|
|
||||||
).inc()
|
).inc()
|
||||||
|
|
||||||
self.errors_total.labels(
|
self.errors_total.labels(
|
||||||
error_type="file_download_error",
|
error_type="file_download_error",
|
||||||
handler_type="media_processing",
|
handler_type="media_processing",
|
||||||
method_name="download_file"
|
method_name="download_file",
|
||||||
).inc()
|
).inc()
|
||||||
|
|
||||||
def record_media_processing(self, content_type: str, duration: float, success: bool):
|
def record_media_processing(
|
||||||
|
self, content_type: str, duration: float, success: bool
|
||||||
|
):
|
||||||
"""Record media processing metrics."""
|
"""Record media processing metrics."""
|
||||||
status = "success" if success else "error"
|
status = "success" if success else "error"
|
||||||
|
|
||||||
self.media_processing_total.labels(
|
self.media_processing_total.labels(
|
||||||
content_type=content_type,
|
content_type=content_type, status=status
|
||||||
status=status
|
|
||||||
).inc()
|
).inc()
|
||||||
|
|
||||||
self.media_processing_duration_seconds.labels(
|
self.media_processing_duration_seconds.labels(
|
||||||
content_type=content_type
|
content_type=content_type
|
||||||
).observe(duration)
|
).observe(duration)
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
self.errors_total.labels(
|
self.errors_total.labels(
|
||||||
error_type="media_processing_error",
|
error_type="media_processing_error",
|
||||||
handler_type="media_processing",
|
handler_type="media_processing",
|
||||||
method_name="add_in_db_media"
|
method_name="add_in_db_media",
|
||||||
).inc()
|
).inc()
|
||||||
|
|
||||||
def record_db_error(self, error_type: str, query_type: str = "unknown", table_name: str = "unknown", operation: str = "unknown"):
|
def record_db_error(
|
||||||
|
self,
|
||||||
|
error_type: str,
|
||||||
|
query_type: str = "unknown",
|
||||||
|
table_name: str = "unknown",
|
||||||
|
operation: str = "unknown",
|
||||||
|
):
|
||||||
"""Record database error occurrence."""
|
"""Record database error occurrence."""
|
||||||
self.db_errors_total.labels(
|
self.db_errors_total.labels(
|
||||||
error_type=error_type,
|
error_type=error_type,
|
||||||
query_type=query_type,
|
query_type=query_type,
|
||||||
table_name=table_name,
|
table_name=table_name,
|
||||||
operation=operation
|
operation=operation,
|
||||||
).inc()
|
).inc()
|
||||||
|
|
||||||
def record_rate_limit_request(self, chat_id: int, success: bool, wait_time: float = 0.0, error_type: str = None):
|
def record_rate_limit_request(
|
||||||
|
self,
|
||||||
|
chat_id: int,
|
||||||
|
success: bool,
|
||||||
|
wait_time: float = 0.0,
|
||||||
|
error_type: str = None,
|
||||||
|
):
|
||||||
"""Record rate limit request metrics."""
|
"""Record rate limit request metrics."""
|
||||||
try:
|
try:
|
||||||
# Определяем статус
|
# Определяем статус
|
||||||
status = "success" if success else "error"
|
status = "success" if success else "error"
|
||||||
|
|
||||||
# Записываем счетчик запросов
|
# Записываем счетчик запросов
|
||||||
self.rate_limit_requests_total.labels(
|
self.rate_limit_requests_total.labels(
|
||||||
chat_id=str(chat_id),
|
chat_id=str(chat_id), status=status, error_type=error_type or "none"
|
||||||
status=status,
|
|
||||||
error_type=error_type or "none"
|
|
||||||
).inc()
|
).inc()
|
||||||
|
|
||||||
# Записываем время ожидания
|
# Записываем время ожидания
|
||||||
if wait_time > 0:
|
if wait_time > 0:
|
||||||
self.rate_limit_wait_duration_seconds.labels(
|
self.rate_limit_wait_duration_seconds.labels(
|
||||||
chat_id=str(chat_id)
|
chat_id=str(chat_id)
|
||||||
).observe(wait_time)
|
).observe(wait_time)
|
||||||
|
|
||||||
# Записываем ошибки
|
# Записываем ошибки
|
||||||
if not success and error_type:
|
if not success and error_type:
|
||||||
self.rate_limit_errors_total.labels(
|
self.rate_limit_errors_total.labels(
|
||||||
error_type=error_type,
|
error_type=error_type, chat_id=str(chat_id)
|
||||||
chat_id=str(chat_id)
|
|
||||||
).inc()
|
).inc()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logging.warning(f"Failed to record rate limit request: {e}")
|
logging.warning(f"Failed to record rate limit request: {e}")
|
||||||
|
|
||||||
def update_rate_limit_gauges(self):
|
def update_rate_limit_gauges(self):
|
||||||
"""Update rate limit gauge metrics."""
|
"""Update rate limit gauge metrics."""
|
||||||
try:
|
try:
|
||||||
from .rate_limit_monitor import rate_limit_monitor
|
from .rate_limit_monitor import rate_limit_monitor
|
||||||
|
|
||||||
# Обновляем количество активных чатов
|
# Обновляем количество активных чатов
|
||||||
self.rate_limit_active_chats.set(len(rate_limit_monitor.stats))
|
self.rate_limit_active_chats.set(len(rate_limit_monitor.stats))
|
||||||
|
|
||||||
# Обновляем метрики для каждого чата
|
# Обновляем метрики для каждого чата
|
||||||
for chat_id, chat_stats in rate_limit_monitor.stats.items():
|
for chat_id, chat_stats in rate_limit_monitor.stats.items():
|
||||||
chat_id_str = str(chat_id)
|
chat_id_str = str(chat_id)
|
||||||
|
|
||||||
# Процент успеха
|
# Процент успеха
|
||||||
self.rate_limit_success_rate.labels(
|
self.rate_limit_success_rate.labels(chat_id=chat_id_str).set(
|
||||||
chat_id=chat_id_str
|
chat_stats.success_rate
|
||||||
).set(chat_stats.success_rate)
|
)
|
||||||
|
|
||||||
# Запросов в минуту
|
# Запросов в минуту
|
||||||
self.rate_limit_requests_per_minute.labels(
|
self.rate_limit_requests_per_minute.labels(chat_id=chat_id_str).set(
|
||||||
chat_id=chat_id_str
|
chat_stats.requests_per_minute
|
||||||
).set(chat_stats.requests_per_minute)
|
)
|
||||||
|
|
||||||
# Общее количество запросов
|
# Общее количество запросов
|
||||||
self.rate_limit_total_requests.labels(
|
self.rate_limit_total_requests.labels(chat_id=chat_id_str).set(
|
||||||
chat_id=chat_id_str
|
chat_stats.total_requests
|
||||||
).set(chat_stats.total_requests)
|
)
|
||||||
|
|
||||||
# Среднее время ожидания
|
# Среднее время ожидания
|
||||||
self.rate_limit_avg_wait_time_seconds.labels(
|
self.rate_limit_avg_wait_time_seconds.labels(chat_id=chat_id_str).set(
|
||||||
chat_id=chat_id_str
|
chat_stats.average_wait_time
|
||||||
).set(chat_stats.average_wait_time)
|
)
|
||||||
|
|
||||||
# Количество ошибок по типам
|
# Количество ошибок по типам
|
||||||
if chat_stats.retry_after_errors > 0:
|
if chat_stats.retry_after_errors > 0:
|
||||||
self.rate_limit_total_errors.labels(
|
self.rate_limit_total_errors.labels(
|
||||||
chat_id=chat_id_str,
|
chat_id=chat_id_str, error_type="RetryAfter"
|
||||||
error_type="RetryAfter"
|
|
||||||
).set(chat_stats.retry_after_errors)
|
).set(chat_stats.retry_after_errors)
|
||||||
|
|
||||||
if chat_stats.other_errors > 0:
|
if chat_stats.other_errors > 0:
|
||||||
self.rate_limit_total_errors.labels(
|
self.rate_limit_total_errors.labels(
|
||||||
chat_id=chat_id_str,
|
chat_id=chat_id_str, error_type="Other"
|
||||||
error_type="Other"
|
|
||||||
).set(chat_stats.other_errors)
|
).set(chat_stats.other_errors)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logging.warning(f"Failed to update rate limit gauges: {e}")
|
logging.warning(f"Failed to update rate limit gauges: {e}")
|
||||||
|
|
||||||
def get_metrics(self) -> bytes:
|
def get_metrics(self) -> bytes:
|
||||||
"""Generate metrics in Prometheus format."""
|
"""Generate metrics in Prometheus format."""
|
||||||
# Обновляем gauge метрики rate limiter перед генерацией
|
# Обновляем gauge метрики rate limiter перед генерацией
|
||||||
self.update_rate_limit_gauges()
|
self.update_rate_limit_gauges()
|
||||||
|
|
||||||
return generate_latest(self.registry)
|
return generate_latest(self.registry)
|
||||||
|
|
||||||
|
|
||||||
@@ -446,6 +479,7 @@ metrics = BotMetrics()
|
|||||||
# Decorators for easy metric collection
|
# Decorators for easy metric collection
|
||||||
def track_time(method_name: str = None, handler_type: str = "unknown"):
|
def track_time(method_name: str = None, handler_type: str = "unknown"):
|
||||||
"""Decorator to track execution time of functions."""
|
"""Decorator to track execution time of functions."""
|
||||||
|
|
||||||
def decorator(func):
|
def decorator(func):
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
async def async_wrapper(*args, **kwargs):
|
async def async_wrapper(*args, **kwargs):
|
||||||
@@ -454,27 +488,19 @@ def track_time(method_name: str = None, handler_type: str = "unknown"):
|
|||||||
result = await func(*args, **kwargs)
|
result = await func(*args, **kwargs)
|
||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
metrics.record_method_duration(
|
metrics.record_method_duration(
|
||||||
method_name or func.__name__,
|
method_name or func.__name__, duration, handler_type, "success"
|
||||||
duration,
|
|
||||||
handler_type,
|
|
||||||
"success"
|
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
metrics.record_method_duration(
|
metrics.record_method_duration(
|
||||||
method_name or func.__name__,
|
method_name or func.__name__, duration, handler_type, "error"
|
||||||
duration,
|
|
||||||
handler_type,
|
|
||||||
"error"
|
|
||||||
)
|
)
|
||||||
metrics.record_error(
|
metrics.record_error(
|
||||||
type(e).__name__,
|
type(e).__name__, handler_type, method_name or func.__name__
|
||||||
handler_type,
|
|
||||||
method_name or func.__name__
|
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
def sync_wrapper(*args, **kwargs):
|
def sync_wrapper(*args, **kwargs):
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
@@ -482,35 +508,29 @@ def track_time(method_name: str = None, handler_type: str = "unknown"):
|
|||||||
result = func(*args, **kwargs)
|
result = func(*args, **kwargs)
|
||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
metrics.record_method_duration(
|
metrics.record_method_duration(
|
||||||
method_name or func.__name__,
|
method_name or func.__name__, duration, handler_type, "success"
|
||||||
duration,
|
|
||||||
handler_type,
|
|
||||||
"success"
|
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
metrics.record_method_duration(
|
metrics.record_method_duration(
|
||||||
method_name or func.__name__,
|
method_name or func.__name__, duration, handler_type, "error"
|
||||||
duration,
|
|
||||||
handler_type,
|
|
||||||
"error"
|
|
||||||
)
|
)
|
||||||
metrics.record_error(
|
metrics.record_error(
|
||||||
type(e).__name__,
|
type(e).__name__, handler_type, method_name or func.__name__
|
||||||
handler_type,
|
|
||||||
method_name or func.__name__
|
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
if asyncio.iscoroutinefunction(func):
|
if asyncio.iscoroutinefunction(func):
|
||||||
return async_wrapper
|
return async_wrapper
|
||||||
return sync_wrapper
|
return sync_wrapper
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
def track_errors(handler_type: str = "unknown", method_name: str = None):
|
def track_errors(handler_type: str = "unknown", method_name: str = None):
|
||||||
"""Decorator to track errors in functions."""
|
"""Decorator to track errors in functions."""
|
||||||
|
|
||||||
def decorator(func):
|
def decorator(func):
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
async def async_wrapper(*args, **kwargs):
|
async def async_wrapper(*args, **kwargs):
|
||||||
@@ -518,32 +538,32 @@ def track_errors(handler_type: str = "unknown", method_name: str = None):
|
|||||||
return await func(*args, **kwargs)
|
return await func(*args, **kwargs)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
metrics.record_error(
|
metrics.record_error(
|
||||||
type(e).__name__,
|
type(e).__name__, handler_type, method_name or func.__name__
|
||||||
handler_type,
|
|
||||||
method_name or func.__name__
|
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
def sync_wrapper(*args, **kwargs):
|
def sync_wrapper(*args, **kwargs):
|
||||||
try:
|
try:
|
||||||
return func(*args, **kwargs)
|
return func(*args, **kwargs)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
metrics.record_error(
|
metrics.record_error(
|
||||||
type(e).__name__,
|
type(e).__name__, handler_type, method_name or func.__name__
|
||||||
handler_type,
|
|
||||||
method_name or func.__name__
|
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
if asyncio.iscoroutinefunction(func):
|
if asyncio.iscoroutinefunction(func):
|
||||||
return async_wrapper
|
return async_wrapper
|
||||||
return sync_wrapper
|
return sync_wrapper
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
def db_query_time(query_type: str = "unknown", table_name: str = "unknown", operation: str = "unknown"):
|
def db_query_time(
|
||||||
|
query_type: str = "unknown", table_name: str = "unknown", operation: str = "unknown"
|
||||||
|
):
|
||||||
"""Decorator to track database query execution time."""
|
"""Decorator to track database query execution time."""
|
||||||
|
|
||||||
def decorator(func):
|
def decorator(func):
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
async def async_wrapper(*args, **kwargs):
|
async def async_wrapper(*args, **kwargs):
|
||||||
@@ -557,18 +577,11 @@ def db_query_time(query_type: str = "unknown", table_name: str = "unknown", oper
|
|||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
metrics.record_db_query(query_type, duration, table_name, operation)
|
metrics.record_db_query(query_type, duration, table_name, operation)
|
||||||
metrics.record_db_error(
|
metrics.record_db_error(
|
||||||
type(e).__name__,
|
type(e).__name__, query_type, table_name, operation
|
||||||
query_type,
|
|
||||||
table_name,
|
|
||||||
operation
|
|
||||||
)
|
|
||||||
metrics.record_error(
|
|
||||||
type(e).__name__,
|
|
||||||
"database",
|
|
||||||
func.__name__
|
|
||||||
)
|
)
|
||||||
|
metrics.record_error(type(e).__name__, "database", func.__name__)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
def sync_wrapper(*args, **kwargs):
|
def sync_wrapper(*args, **kwargs):
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
@@ -581,21 +594,15 @@ def db_query_time(query_type: str = "unknown", table_name: str = "unknown", oper
|
|||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
metrics.record_db_query(query_type, duration, table_name, operation)
|
metrics.record_db_query(query_type, duration, table_name, operation)
|
||||||
metrics.record_db_error(
|
metrics.record_db_error(
|
||||||
type(e).__name__,
|
type(e).__name__, query_type, table_name, operation
|
||||||
query_type,
|
|
||||||
table_name,
|
|
||||||
operation
|
|
||||||
)
|
|
||||||
metrics.record_error(
|
|
||||||
type(e).__name__,
|
|
||||||
"database",
|
|
||||||
func.__name__
|
|
||||||
)
|
)
|
||||||
|
metrics.record_error(type(e).__name__, "database", func.__name__)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
if asyncio.iscoroutinefunction(func):
|
if asyncio.iscoroutinefunction(func):
|
||||||
return async_wrapper
|
return async_wrapper
|
||||||
return sync_wrapper
|
return sync_wrapper
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
@@ -610,16 +617,13 @@ async def track_middleware(middleware_name: str):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
metrics.record_middleware(middleware_name, duration, "error")
|
metrics.record_middleware(middleware_name, duration, "error")
|
||||||
metrics.record_error(
|
metrics.record_error(type(e).__name__, "middleware", middleware_name)
|
||||||
type(e).__name__,
|
|
||||||
"middleware",
|
|
||||||
middleware_name
|
|
||||||
)
|
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
def track_media_processing(content_type: str = "unknown"):
|
def track_media_processing(content_type: str = "unknown"):
|
||||||
"""Decorator to track media processing operations."""
|
"""Decorator to track media processing operations."""
|
||||||
|
|
||||||
def decorator(func):
|
def decorator(func):
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
async def async_wrapper(*args, **kwargs):
|
async def async_wrapper(*args, **kwargs):
|
||||||
@@ -633,7 +637,7 @@ def track_media_processing(content_type: str = "unknown"):
|
|||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
metrics.record_media_processing(content_type, duration, False)
|
metrics.record_media_processing(content_type, duration, False)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
def sync_wrapper(*args, **kwargs):
|
def sync_wrapper(*args, **kwargs):
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
@@ -646,15 +650,17 @@ def track_media_processing(content_type: str = "unknown"):
|
|||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
metrics.record_media_processing(content_type, duration, False)
|
metrics.record_media_processing(content_type, duration, False)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
if asyncio.iscoroutinefunction(func):
|
if asyncio.iscoroutinefunction(func):
|
||||||
return async_wrapper
|
return async_wrapper
|
||||||
return sync_wrapper
|
return sync_wrapper
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
def track_file_operations(content_type: str = "unknown"):
|
def track_file_operations(content_type: str = "unknown"):
|
||||||
"""Decorator to track file download/upload operations."""
|
"""Decorator to track file download/upload operations."""
|
||||||
|
|
||||||
def decorator(func):
|
def decorator(func):
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
async def async_wrapper(*args, **kwargs):
|
async def async_wrapper(*args, **kwargs):
|
||||||
@@ -662,43 +668,44 @@ def track_file_operations(content_type: str = "unknown"):
|
|||||||
try:
|
try:
|
||||||
result = await func(*args, **kwargs)
|
result = await func(*args, **kwargs)
|
||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
|
|
||||||
# Получаем размер файла из результата
|
# Получаем размер файла из результата
|
||||||
file_size = 0
|
file_size = 0
|
||||||
if result and isinstance(result, str) and os.path.exists(result):
|
if result and isinstance(result, str) and os.path.exists(result):
|
||||||
file_size = os.path.getsize(result)
|
file_size = os.path.getsize(result)
|
||||||
|
|
||||||
# Записываем метрики
|
# Записываем метрики
|
||||||
metrics.record_file_download(content_type, file_size, duration)
|
metrics.record_file_download(content_type, file_size, duration)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
metrics.record_file_download_error(content_type, str(e))
|
metrics.record_file_download_error(content_type, str(e))
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
def sync_wrapper(*args, **kwargs):
|
def sync_wrapper(*args, **kwargs):
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
try:
|
try:
|
||||||
result = func(*args, **kwargs)
|
result = func(*args, **kwargs)
|
||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
|
|
||||||
# Получаем размер файла из результата
|
# Получаем размер файла из результата
|
||||||
file_size = 0
|
file_size = 0
|
||||||
if result and isinstance(result, str) and os.path.exists(result):
|
if result and isinstance(result, str) and os.path.exists(result):
|
||||||
file_size = os.path.getsize(result)
|
file_size = os.path.getsize(result)
|
||||||
|
|
||||||
# Записываем метрики
|
# Записываем метрики
|
||||||
metrics.record_file_download(content_type, file_size, duration)
|
metrics.record_file_download(content_type, file_size, duration)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
metrics.record_file_download_error(content_type, str(e))
|
metrics.record_file_download_error(content_type, str(e))
|
||||||
raise
|
raise
|
||||||
|
|
||||||
if asyncio.iscoroutinefunction(func):
|
if asyncio.iscoroutinefunction(func):
|
||||||
return async_wrapper
|
return async_wrapper
|
||||||
return sync_wrapper
|
return sync_wrapper
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
"""
|
"""
|
||||||
Мониторинг и статистика rate limiting
|
Мониторинг и статистика rate limiting
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from typing import Dict, List, Optional
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from collections import defaultdict, deque
|
from collections import defaultdict, deque
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from logs.custom_logger import logger
|
from logs.custom_logger import logger
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class RateLimitStats:
|
class RateLimitStats:
|
||||||
"""Статистика rate limiting для чата"""
|
"""Статистика rate limiting для чата"""
|
||||||
|
|
||||||
chat_id: int
|
chat_id: int
|
||||||
total_requests: int = 0
|
total_requests: int = 0
|
||||||
successful_requests: int = 0
|
successful_requests: int = 0
|
||||||
@@ -20,53 +23,61 @@ class RateLimitStats:
|
|||||||
total_wait_time: float = 0.0
|
total_wait_time: float = 0.0
|
||||||
last_request_time: float = 0.0
|
last_request_time: float = 0.0
|
||||||
request_times: deque = field(default_factory=lambda: deque(maxlen=100))
|
request_times: deque = field(default_factory=lambda: deque(maxlen=100))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def success_rate(self) -> float:
|
def success_rate(self) -> float:
|
||||||
"""Процент успешных запросов"""
|
"""Процент успешных запросов"""
|
||||||
if self.total_requests == 0:
|
if self.total_requests == 0:
|
||||||
return 1.0
|
return 1.0
|
||||||
return self.successful_requests / self.total_requests
|
return self.successful_requests / self.total_requests
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def error_rate(self) -> float:
|
def error_rate(self) -> float:
|
||||||
"""Процент ошибок"""
|
"""Процент ошибок"""
|
||||||
return 1.0 - self.success_rate
|
return 1.0 - self.success_rate
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def average_wait_time(self) -> float:
|
def average_wait_time(self) -> float:
|
||||||
"""Среднее время ожидания"""
|
"""Среднее время ожидания"""
|
||||||
if self.total_requests == 0:
|
if self.total_requests == 0:
|
||||||
return 0.0
|
return 0.0
|
||||||
return self.total_wait_time / self.total_requests
|
return self.total_wait_time / self.total_requests
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def requests_per_minute(self) -> float:
|
def requests_per_minute(self) -> float:
|
||||||
"""Запросов в минуту"""
|
"""Запросов в минуту"""
|
||||||
if not self.request_times:
|
if not self.request_times:
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
minute_ago = current_time - 60
|
minute_ago = current_time - 60
|
||||||
|
|
||||||
# Подсчитываем запросы за последнюю минуту
|
# Подсчитываем запросы за последнюю минуту
|
||||||
recent_requests = sum(1 for req_time in self.request_times if req_time > minute_ago)
|
recent_requests = sum(
|
||||||
|
1 for req_time in self.request_times if req_time > minute_ago
|
||||||
|
)
|
||||||
return recent_requests
|
return recent_requests
|
||||||
|
|
||||||
|
|
||||||
class RateLimitMonitor:
|
class RateLimitMonitor:
|
||||||
"""Монитор для отслеживания статистики rate limiting"""
|
"""Монитор для отслеживания статистики rate limiting"""
|
||||||
|
|
||||||
def __init__(self, max_history_size: int = 1000):
|
def __init__(self, max_history_size: int = 1000):
|
||||||
self.stats: Dict[int, RateLimitStats] = defaultdict(lambda: RateLimitStats(0))
|
self.stats: Dict[int, RateLimitStats] = defaultdict(lambda: RateLimitStats(0))
|
||||||
self.global_stats = RateLimitStats(0)
|
self.global_stats = RateLimitStats(0)
|
||||||
self.max_history_size = max_history_size
|
self.max_history_size = max_history_size
|
||||||
self.error_history: deque = deque(maxlen=max_history_size)
|
self.error_history: deque = deque(maxlen=max_history_size)
|
||||||
|
|
||||||
def record_request(self, chat_id: int, success: bool, wait_time: float = 0.0, error_type: Optional[str] = None):
|
def record_request(
|
||||||
|
self,
|
||||||
|
chat_id: int,
|
||||||
|
success: bool,
|
||||||
|
wait_time: float = 0.0,
|
||||||
|
error_type: Optional[str] = None,
|
||||||
|
):
|
||||||
"""Записывает информацию о запросе"""
|
"""Записывает информацию о запросе"""
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
|
|
||||||
# Обновляем статистику для чата
|
# Обновляем статистику для чата
|
||||||
chat_stats = self.stats[chat_id]
|
chat_stats = self.stats[chat_id]
|
||||||
chat_stats.chat_id = chat_id
|
chat_stats.chat_id = chat_id
|
||||||
@@ -74,7 +85,7 @@ class RateLimitMonitor:
|
|||||||
chat_stats.total_wait_time += wait_time
|
chat_stats.total_wait_time += wait_time
|
||||||
chat_stats.last_request_time = current_time
|
chat_stats.last_request_time = current_time
|
||||||
chat_stats.request_times.append(current_time)
|
chat_stats.request_times.append(current_time)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
chat_stats.successful_requests += 1
|
chat_stats.successful_requests += 1
|
||||||
else:
|
else:
|
||||||
@@ -83,21 +94,23 @@ class RateLimitMonitor:
|
|||||||
chat_stats.retry_after_errors += 1
|
chat_stats.retry_after_errors += 1
|
||||||
else:
|
else:
|
||||||
chat_stats.other_errors += 1
|
chat_stats.other_errors += 1
|
||||||
|
|
||||||
# Записываем ошибку в историю
|
# Записываем ошибку в историю
|
||||||
self.error_history.append({
|
self.error_history.append(
|
||||||
'chat_id': chat_id,
|
{
|
||||||
'error_type': error_type,
|
"chat_id": chat_id,
|
||||||
'timestamp': current_time,
|
"error_type": error_type,
|
||||||
'wait_time': wait_time
|
"timestamp": current_time,
|
||||||
})
|
"wait_time": wait_time,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Обновляем глобальную статистику
|
# Обновляем глобальную статистику
|
||||||
self.global_stats.total_requests += 1
|
self.global_stats.total_requests += 1
|
||||||
self.global_stats.total_wait_time += wait_time
|
self.global_stats.total_wait_time += wait_time
|
||||||
self.global_stats.last_request_time = current_time
|
self.global_stats.last_request_time = current_time
|
||||||
self.global_stats.request_times.append(current_time)
|
self.global_stats.request_times.append(current_time)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
self.global_stats.successful_requests += 1
|
self.global_stats.successful_requests += 1
|
||||||
else:
|
else:
|
||||||
@@ -106,56 +119,54 @@ class RateLimitMonitor:
|
|||||||
self.global_stats.retry_after_errors += 1
|
self.global_stats.retry_after_errors += 1
|
||||||
else:
|
else:
|
||||||
self.global_stats.other_errors += 1
|
self.global_stats.other_errors += 1
|
||||||
|
|
||||||
def get_chat_stats(self, chat_id: int) -> Optional[RateLimitStats]:
|
def get_chat_stats(self, chat_id: int) -> Optional[RateLimitStats]:
|
||||||
"""Получает статистику для конкретного чата"""
|
"""Получает статистику для конкретного чата"""
|
||||||
return self.stats.get(chat_id)
|
return self.stats.get(chat_id)
|
||||||
|
|
||||||
def get_global_stats(self) -> RateLimitStats:
|
def get_global_stats(self) -> RateLimitStats:
|
||||||
"""Получает глобальную статистику"""
|
"""Получает глобальную статистику"""
|
||||||
return self.global_stats
|
return self.global_stats
|
||||||
|
|
||||||
def get_top_chats_by_requests(self, limit: int = 10) -> List[tuple]:
|
def get_top_chats_by_requests(self, limit: int = 10) -> List[tuple]:
|
||||||
"""Получает топ чатов по количеству запросов"""
|
"""Получает топ чатов по количеству запросов"""
|
||||||
sorted_chats = sorted(
|
sorted_chats = sorted(
|
||||||
self.stats.items(),
|
self.stats.items(), key=lambda x: x[1].total_requests, reverse=True
|
||||||
key=lambda x: x[1].total_requests,
|
|
||||||
reverse=True
|
|
||||||
)
|
)
|
||||||
return sorted_chats[:limit]
|
return sorted_chats[:limit]
|
||||||
|
|
||||||
def get_chats_with_high_error_rate(self, threshold: float = 0.1) -> List[tuple]:
|
def get_chats_with_high_error_rate(self, threshold: float = 0.1) -> List[tuple]:
|
||||||
"""Получает чаты с высоким процентом ошибок"""
|
"""Получает чаты с высоким процентом ошибок"""
|
||||||
high_error_chats = [
|
high_error_chats = [
|
||||||
(chat_id, stats) for chat_id, stats in self.stats.items()
|
(chat_id, stats)
|
||||||
|
for chat_id, stats in self.stats.items()
|
||||||
if stats.error_rate > threshold and stats.total_requests > 5
|
if stats.error_rate > threshold and stats.total_requests > 5
|
||||||
]
|
]
|
||||||
return sorted(high_error_chats, key=lambda x: x[1].error_rate, reverse=True)
|
return sorted(high_error_chats, key=lambda x: x[1].error_rate, reverse=True)
|
||||||
|
|
||||||
def get_recent_errors(self, minutes: int = 60) -> List[dict]:
|
def get_recent_errors(self, minutes: int = 60) -> List[dict]:
|
||||||
"""Получает недавние ошибки"""
|
"""Получает недавние ошибки"""
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
cutoff_time = current_time - (minutes * 60)
|
cutoff_time = current_time - (minutes * 60)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
error for error in self.error_history
|
error for error in self.error_history if error["timestamp"] > cutoff_time
|
||||||
if error['timestamp'] > cutoff_time
|
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_error_summary(self, minutes: int = 60) -> Dict[str, int]:
|
def get_error_summary(self, minutes: int = 60) -> Dict[str, int]:
|
||||||
"""Получает сводку ошибок за указанный период"""
|
"""Получает сводку ошибок за указанный период"""
|
||||||
recent_errors = self.get_recent_errors(minutes)
|
recent_errors = self.get_recent_errors(minutes)
|
||||||
error_summary = defaultdict(int)
|
error_summary = defaultdict(int)
|
||||||
|
|
||||||
for error in recent_errors:
|
for error in recent_errors:
|
||||||
error_summary[error['error_type']] += 1
|
error_summary[error["error_type"]] += 1
|
||||||
|
|
||||||
return dict(error_summary)
|
return dict(error_summary)
|
||||||
|
|
||||||
def log_statistics(self, log_level: str = "info"):
|
def log_statistics(self, log_level: str = "info"):
|
||||||
"""Логирует текущую статистику"""
|
"""Логирует текущую статистику"""
|
||||||
global_stats = self.get_global_stats()
|
global_stats = self.get_global_stats()
|
||||||
|
|
||||||
log_message = (
|
log_message = (
|
||||||
f"Rate Limit Statistics:\n"
|
f"Rate Limit Statistics:\n"
|
||||||
f" Total requests: {global_stats.total_requests}\n"
|
f" Total requests: {global_stats.total_requests}\n"
|
||||||
@@ -167,21 +178,25 @@ class RateLimitMonitor:
|
|||||||
f" Requests per minute: {global_stats.requests_per_minute:.1f}\n"
|
f" Requests per minute: {global_stats.requests_per_minute:.1f}\n"
|
||||||
f" Active chats: {len(self.stats)}"
|
f" Active chats: {len(self.stats)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if log_level == "error":
|
if log_level == "error":
|
||||||
logger.error(log_message)
|
logger.error(log_message)
|
||||||
elif log_level == "warning":
|
elif log_level == "warning":
|
||||||
logger.warning(log_message)
|
logger.warning(log_message)
|
||||||
else:
|
else:
|
||||||
logger.info(log_message)
|
logger.info(log_message)
|
||||||
|
|
||||||
# Логируем чаты с высоким процентом ошибок
|
# Логируем чаты с высоким процентом ошибок
|
||||||
high_error_chats = self.get_chats_with_high_error_rate(0.2)
|
high_error_chats = self.get_chats_with_high_error_rate(0.2)
|
||||||
if high_error_chats:
|
if high_error_chats:
|
||||||
logger.warning(f"Chats with high error rate (>20%): {len(high_error_chats)}")
|
logger.warning(
|
||||||
|
f"Chats with high error rate (>20%): {len(high_error_chats)}"
|
||||||
|
)
|
||||||
for chat_id, stats in high_error_chats[:5]: # Показываем только первые 5
|
for chat_id, stats in high_error_chats[:5]: # Показываем только первые 5
|
||||||
logger.warning(f" Chat {chat_id}: {stats.error_rate:.2%} error rate ({stats.failed_requests}/{stats.total_requests})")
|
logger.warning(
|
||||||
|
f" Chat {chat_id}: {stats.error_rate:.2%} error rate ({stats.failed_requests}/{stats.total_requests})"
|
||||||
|
)
|
||||||
|
|
||||||
def reset_stats(self, chat_id: Optional[int] = None):
|
def reset_stats(self, chat_id: Optional[int] = None):
|
||||||
"""Сбрасывает статистику"""
|
"""Сбрасывает статистику"""
|
||||||
if chat_id is None:
|
if chat_id is None:
|
||||||
@@ -199,7 +214,12 @@ class RateLimitMonitor:
|
|||||||
rate_limit_monitor = RateLimitMonitor()
|
rate_limit_monitor = RateLimitMonitor()
|
||||||
|
|
||||||
|
|
||||||
def record_rate_limit_request(chat_id: int, success: bool, wait_time: float = 0.0, error_type: Optional[str] = None):
|
def record_rate_limit_request(
|
||||||
|
chat_id: int,
|
||||||
|
success: bool,
|
||||||
|
wait_time: float = 0.0,
|
||||||
|
error_type: Optional[str] = None,
|
||||||
|
):
|
||||||
"""Удобная функция для записи информации о запросе"""
|
"""Удобная функция для записи информации о запросе"""
|
||||||
rate_limit_monitor.record_request(chat_id, success, wait_time, error_type)
|
rate_limit_monitor.record_request(chat_id, success, wait_time, error_type)
|
||||||
|
|
||||||
@@ -208,13 +228,13 @@ def get_rate_limit_summary() -> Dict:
|
|||||||
"""Получает краткую сводку по rate limiting"""
|
"""Получает краткую сводку по rate limiting"""
|
||||||
global_stats = rate_limit_monitor.get_global_stats()
|
global_stats = rate_limit_monitor.get_global_stats()
|
||||||
recent_errors = rate_limit_monitor.get_recent_errors(60) # За последний час
|
recent_errors = rate_limit_monitor.get_recent_errors(60) # За последний час
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'total_requests': global_stats.total_requests,
|
"total_requests": global_stats.total_requests,
|
||||||
'success_rate': global_stats.success_rate,
|
"success_rate": global_stats.success_rate,
|
||||||
'error_rate': global_stats.error_rate,
|
"error_rate": global_stats.error_rate,
|
||||||
'recent_errors_count': len(recent_errors),
|
"recent_errors_count": len(recent_errors),
|
||||||
'active_chats': len(rate_limit_monitor.stats),
|
"active_chats": len(rate_limit_monitor.stats),
|
||||||
'requests_per_minute': global_stats.requests_per_minute,
|
"requests_per_minute": global_stats.requests_per_minute,
|
||||||
'average_wait_time': global_stats.average_wait_time
|
"average_wait_time": global_stats.average_wait_time,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
"""
|
"""
|
||||||
Rate limiter для предотвращения Flood control ошибок в Telegram Bot API
|
Rate limiter для предотвращения Flood control ошибок в Telegram Bot API
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
from typing import Dict, Optional, Any, Callable
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from aiogram.exceptions import TelegramRetryAfter, TelegramAPIError
|
from typing import Any, Callable, Dict, Optional
|
||||||
|
|
||||||
|
from aiogram.exceptions import TelegramAPIError, TelegramRetryAfter
|
||||||
|
|
||||||
from logs.custom_logger import logger
|
from logs.custom_logger import logger
|
||||||
|
|
||||||
from .metrics import metrics
|
from .metrics import metrics
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class RateLimitConfig:
|
class RateLimitConfig:
|
||||||
"""Конфигурация для rate limiting"""
|
"""Конфигурация для rate limiting"""
|
||||||
|
|
||||||
messages_per_second: float = 0.5 # Максимум 0.5 сообщений в секунду на чат
|
messages_per_second: float = 0.5 # Максимум 0.5 сообщений в секунду на чат
|
||||||
burst_limit: int = 3 # Максимум 3 сообщения подряд
|
burst_limit: int = 3 # Максимум 3 сообщения подряд
|
||||||
retry_after_multiplier: float = 1.2 # Множитель для увеличения задержки при retry
|
retry_after_multiplier: float = 1.2 # Множитель для увеличения задержки при retry
|
||||||
@@ -21,23 +26,23 @@ class RateLimitConfig:
|
|||||||
|
|
||||||
class ChatRateLimiter:
|
class ChatRateLimiter:
|
||||||
"""Rate limiter для конкретного чата"""
|
"""Rate limiter для конкретного чата"""
|
||||||
|
|
||||||
def __init__(self, config: RateLimitConfig):
|
def __init__(self, config: RateLimitConfig):
|
||||||
self.config = config
|
self.config = config
|
||||||
self.last_send_time = 0.0
|
self.last_send_time = 0.0
|
||||||
self.burst_count = 0
|
self.burst_count = 0
|
||||||
self.burst_reset_time = 0.0
|
self.burst_reset_time = 0.0
|
||||||
self.retry_delay = 1.0
|
self.retry_delay = 1.0
|
||||||
|
|
||||||
async def wait_if_needed(self) -> None:
|
async def wait_if_needed(self) -> None:
|
||||||
"""Ждет если необходимо для соблюдения rate limit"""
|
"""Ждет если необходимо для соблюдения rate limit"""
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
|
|
||||||
# Сбрасываем счетчик burst если прошло достаточно времени
|
# Сбрасываем счетчик burst если прошло достаточно времени
|
||||||
if current_time >= self.burst_reset_time:
|
if current_time >= self.burst_reset_time:
|
||||||
self.burst_count = 0
|
self.burst_count = 0
|
||||||
self.burst_reset_time = current_time + 1.0
|
self.burst_reset_time = current_time + 1.0
|
||||||
|
|
||||||
# Проверяем burst limit
|
# Проверяем burst limit
|
||||||
if self.burst_count >= self.config.burst_limit:
|
if self.burst_count >= self.config.burst_limit:
|
||||||
wait_time = self.burst_reset_time - current_time
|
wait_time = self.burst_reset_time - current_time
|
||||||
@@ -47,16 +52,16 @@ class ChatRateLimiter:
|
|||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
self.burst_count = 0
|
self.burst_count = 0
|
||||||
self.burst_reset_time = current_time + 1.0
|
self.burst_reset_time = current_time + 1.0
|
||||||
|
|
||||||
# Проверяем минимальный интервал между сообщениями
|
# Проверяем минимальный интервал между сообщениями
|
||||||
time_since_last = current_time - self.last_send_time
|
time_since_last = current_time - self.last_send_time
|
||||||
min_interval = 1.0 / self.config.messages_per_second
|
min_interval = 1.0 / self.config.messages_per_second
|
||||||
|
|
||||||
if time_since_last < min_interval:
|
if time_since_last < min_interval:
|
||||||
wait_time = min_interval - time_since_last
|
wait_time = min_interval - time_since_last
|
||||||
logger.debug(f"Rate limiting: waiting {wait_time:.2f}s")
|
logger.debug(f"Rate limiting: waiting {wait_time:.2f}s")
|
||||||
await asyncio.sleep(wait_time)
|
await asyncio.sleep(wait_time)
|
||||||
|
|
||||||
# Обновляем время последней отправки
|
# Обновляем время последней отправки
|
||||||
self.last_send_time = time.time()
|
self.last_send_time = time.time()
|
||||||
self.burst_count += 1
|
self.burst_count += 1
|
||||||
@@ -64,125 +69,127 @@ class ChatRateLimiter:
|
|||||||
|
|
||||||
class GlobalRateLimiter:
|
class GlobalRateLimiter:
|
||||||
"""Глобальный rate limiter для всех чатов"""
|
"""Глобальный rate limiter для всех чатов"""
|
||||||
|
|
||||||
def __init__(self, config: RateLimitConfig):
|
def __init__(self, config: RateLimitConfig):
|
||||||
self.config = config
|
self.config = config
|
||||||
self.chat_limiters: Dict[int, ChatRateLimiter] = {}
|
self.chat_limiters: Dict[int, ChatRateLimiter] = {}
|
||||||
self.global_last_send = 0.0
|
self.global_last_send = 0.0
|
||||||
self.global_min_interval = 0.1 # Минимум 100ms между любыми сообщениями
|
self.global_min_interval = 0.1 # Минимум 100ms между любыми сообщениями
|
||||||
|
|
||||||
def get_chat_limiter(self, chat_id: int) -> ChatRateLimiter:
|
def get_chat_limiter(self, chat_id: int) -> ChatRateLimiter:
|
||||||
"""Получает rate limiter для конкретного чата"""
|
"""Получает rate limiter для конкретного чата"""
|
||||||
if chat_id not in self.chat_limiters:
|
if chat_id not in self.chat_limiters:
|
||||||
self.chat_limiters[chat_id] = ChatRateLimiter(self.config)
|
self.chat_limiters[chat_id] = ChatRateLimiter(self.config)
|
||||||
return self.chat_limiters[chat_id]
|
return self.chat_limiters[chat_id]
|
||||||
|
|
||||||
async def wait_if_needed(self, chat_id: int) -> None:
|
async def wait_if_needed(self, chat_id: int) -> None:
|
||||||
"""Ждет если необходимо для соблюдения глобального и чат-специфичного rate limit"""
|
"""Ждет если необходимо для соблюдения глобального и чат-специфичного rate limit"""
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
|
|
||||||
# Глобальный rate limit
|
# Глобальный rate limit
|
||||||
time_since_global = current_time - self.global_last_send
|
time_since_global = current_time - self.global_last_send
|
||||||
if time_since_global < self.global_min_interval:
|
if time_since_global < self.global_min_interval:
|
||||||
wait_time = self.global_min_interval - time_since_global
|
wait_time = self.global_min_interval - time_since_global
|
||||||
await asyncio.sleep(wait_time)
|
await asyncio.sleep(wait_time)
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
|
|
||||||
# Чат-специфичный rate limit
|
# Чат-специфичный rate limit
|
||||||
chat_limiter = self.get_chat_limiter(chat_id)
|
chat_limiter = self.get_chat_limiter(chat_id)
|
||||||
await chat_limiter.wait_if_needed()
|
await chat_limiter.wait_if_needed()
|
||||||
|
|
||||||
self.global_last_send = time.time()
|
self.global_last_send = time.time()
|
||||||
|
|
||||||
|
|
||||||
class RetryHandler:
|
class RetryHandler:
|
||||||
"""Обработчик повторных попыток с экспоненциальной задержкой"""
|
"""Обработчик повторных попыток с экспоненциальной задержкой"""
|
||||||
|
|
||||||
def __init__(self, config: RateLimitConfig):
|
def __init__(self, config: RateLimitConfig):
|
||||||
self.config = config
|
self.config = config
|
||||||
|
|
||||||
async def execute_with_retry(
|
async def execute_with_retry(
|
||||||
self,
|
self, func: Callable, chat_id: int, *args, max_retries: int = 3, **kwargs
|
||||||
func: Callable,
|
|
||||||
chat_id: int,
|
|
||||||
*args,
|
|
||||||
max_retries: int = 3,
|
|
||||||
**kwargs
|
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Выполняет функцию с повторными попытками при ошибках"""
|
"""Выполняет функцию с повторными попытками при ошибках"""
|
||||||
retry_count = 0
|
retry_count = 0
|
||||||
current_delay = self.config.retry_after_multiplier
|
current_delay = self.config.retry_after_multiplier
|
||||||
total_wait_time = 0.0
|
total_wait_time = 0.0
|
||||||
|
|
||||||
while retry_count <= max_retries:
|
while retry_count <= max_retries:
|
||||||
try:
|
try:
|
||||||
result = await func(*args, **kwargs)
|
result = await func(*args, **kwargs)
|
||||||
# Записываем успешный запрос
|
# Записываем успешный запрос
|
||||||
metrics.record_rate_limit_request(chat_id, True, total_wait_time)
|
metrics.record_rate_limit_request(chat_id, True, total_wait_time)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
except TelegramRetryAfter as e:
|
except TelegramRetryAfter as e:
|
||||||
retry_count += 1
|
retry_count += 1
|
||||||
if retry_count > max_retries:
|
if retry_count > max_retries:
|
||||||
logger.error(f"Max retries exceeded for RetryAfter: {e}")
|
logger.error(f"Max retries exceeded for RetryAfter: {e}")
|
||||||
metrics.record_rate_limit_request(chat_id, False, total_wait_time, "RetryAfter")
|
metrics.record_rate_limit_request(
|
||||||
|
chat_id, False, total_wait_time, "RetryAfter"
|
||||||
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
# Используем время ожидания от Telegram или наше увеличенное
|
# Используем время ожидания от Telegram или наше увеличенное
|
||||||
wait_time = max(e.retry_after, current_delay)
|
wait_time = max(e.retry_after, current_delay)
|
||||||
wait_time = min(wait_time, self.config.max_retry_delay)
|
wait_time = min(wait_time, self.config.max_retry_delay)
|
||||||
total_wait_time += wait_time
|
total_wait_time += wait_time
|
||||||
|
|
||||||
logger.warning(f"RetryAfter error, waiting {wait_time:.2f}s (attempt {retry_count}/{max_retries})")
|
logger.warning(
|
||||||
|
f"RetryAfter error, waiting {wait_time:.2f}s (attempt {retry_count}/{max_retries})"
|
||||||
|
)
|
||||||
await asyncio.sleep(wait_time)
|
await asyncio.sleep(wait_time)
|
||||||
current_delay *= self.config.retry_after_multiplier
|
current_delay *= self.config.retry_after_multiplier
|
||||||
|
|
||||||
except TelegramAPIError as e:
|
except TelegramAPIError as e:
|
||||||
retry_count += 1
|
retry_count += 1
|
||||||
if retry_count > max_retries:
|
if retry_count > max_retries:
|
||||||
logger.error(f"Max retries exceeded for TelegramAPIError: {e}")
|
logger.error(f"Max retries exceeded for TelegramAPIError: {e}")
|
||||||
metrics.record_rate_limit_request(chat_id, False, total_wait_time, "TelegramAPIError")
|
metrics.record_rate_limit_request(
|
||||||
|
chat_id, False, total_wait_time, "TelegramAPIError"
|
||||||
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
wait_time = min(current_delay, self.config.max_retry_delay)
|
wait_time = min(current_delay, self.config.max_retry_delay)
|
||||||
total_wait_time += wait_time
|
total_wait_time += wait_time
|
||||||
logger.warning(f"TelegramAPIError, waiting {wait_time:.2f}s (attempt {retry_count}/{max_retries}): {e}")
|
logger.warning(
|
||||||
|
f"TelegramAPIError, waiting {wait_time:.2f}s (attempt {retry_count}/{max_retries}): {e}"
|
||||||
|
)
|
||||||
await asyncio.sleep(wait_time)
|
await asyncio.sleep(wait_time)
|
||||||
current_delay *= self.config.retry_after_multiplier
|
current_delay *= self.config.retry_after_multiplier
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Для других ошибок не делаем retry
|
# Для других ошибок не делаем retry
|
||||||
logger.error(f"Non-retryable error: {e}")
|
logger.error(f"Non-retryable error: {e}")
|
||||||
metrics.record_rate_limit_request(chat_id, False, total_wait_time, "Other")
|
metrics.record_rate_limit_request(
|
||||||
|
chat_id, False, total_wait_time, "Other"
|
||||||
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
class TelegramRateLimiter:
|
class TelegramRateLimiter:
|
||||||
"""Основной класс для rate limiting в Telegram боте"""
|
"""Основной класс для rate limiting в Telegram боте"""
|
||||||
|
|
||||||
def __init__(self, config: Optional[RateLimitConfig] = None):
|
def __init__(self, config: Optional[RateLimitConfig] = None):
|
||||||
self.config = config or RateLimitConfig()
|
self.config = config or RateLimitConfig()
|
||||||
self.global_limiter = GlobalRateLimiter(self.config)
|
self.global_limiter = GlobalRateLimiter(self.config)
|
||||||
self.retry_handler = RetryHandler(self.config)
|
self.retry_handler = RetryHandler(self.config)
|
||||||
|
|
||||||
async def send_with_rate_limit(
|
async def send_with_rate_limit(
|
||||||
self,
|
self, send_func: Callable, chat_id: int, *args, **kwargs
|
||||||
send_func: Callable,
|
|
||||||
chat_id: int,
|
|
||||||
*args,
|
|
||||||
**kwargs
|
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Отправляет сообщение с соблюдением rate limit и retry логики"""
|
"""Отправляет сообщение с соблюдением rate limit и retry логики"""
|
||||||
|
|
||||||
async def _send():
|
async def _send():
|
||||||
await self.global_limiter.wait_if_needed(chat_id)
|
await self.global_limiter.wait_if_needed(chat_id)
|
||||||
return await send_func(*args, **kwargs)
|
return await send_func(*args, **kwargs)
|
||||||
|
|
||||||
return await self.retry_handler.execute_with_retry(_send, chat_id)
|
return await self.retry_handler.execute_with_retry(_send, chat_id)
|
||||||
|
|
||||||
|
|
||||||
# Глобальный экземпляр rate limiter
|
# Глобальный экземпляр rate limiter
|
||||||
from helper_bot.config.rate_limit_config import get_rate_limit_config, RateLimitSettings
|
from helper_bot.config.rate_limit_config import RateLimitSettings, get_rate_limit_config
|
||||||
|
|
||||||
|
|
||||||
def _create_rate_limit_config(settings: RateLimitSettings) -> RateLimitConfig:
|
def _create_rate_limit_config(settings: RateLimitSettings) -> RateLimitConfig:
|
||||||
"""Создает RateLimitConfig из RateLimitSettings"""
|
"""Создает RateLimitConfig из RateLimitSettings"""
|
||||||
@@ -190,9 +197,10 @@ def _create_rate_limit_config(settings: RateLimitSettings) -> RateLimitConfig:
|
|||||||
messages_per_second=settings.messages_per_second,
|
messages_per_second=settings.messages_per_second,
|
||||||
burst_limit=settings.burst_limit,
|
burst_limit=settings.burst_limit,
|
||||||
retry_after_multiplier=settings.retry_after_multiplier,
|
retry_after_multiplier=settings.retry_after_multiplier,
|
||||||
max_retry_delay=settings.max_retry_delay
|
max_retry_delay=settings.max_retry_delay,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Получаем конфигурацию из настроек
|
# Получаем конфигурацию из настроек
|
||||||
_rate_limit_settings = get_rate_limit_config("production")
|
_rate_limit_settings = get_rate_limit_config("production")
|
||||||
_default_config = _create_rate_limit_config(_rate_limit_settings)
|
_default_config = _create_rate_limit_config(_rate_limit_settings)
|
||||||
@@ -200,16 +208,20 @@ _default_config = _create_rate_limit_config(_rate_limit_settings)
|
|||||||
telegram_rate_limiter = TelegramRateLimiter(_default_config)
|
telegram_rate_limiter = TelegramRateLimiter(_default_config)
|
||||||
|
|
||||||
|
|
||||||
async def send_with_rate_limit(send_func: Callable, chat_id: int, *args, **kwargs) -> Any:
|
async def send_with_rate_limit(
|
||||||
|
send_func: Callable, chat_id: int, *args, **kwargs
|
||||||
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
Удобная функция для отправки сообщений с rate limiting
|
Удобная функция для отправки сообщений с rate limiting
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
send_func: Функция отправки (например, bot.send_message)
|
send_func: Функция отправки (например, bot.send_message)
|
||||||
chat_id: ID чата
|
chat_id: ID чата
|
||||||
*args, **kwargs: Аргументы для функции отправки
|
*args, **kwargs: Аргументы для функции отправки
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Результат выполнения функции отправки
|
Результат выполнения функции отправки
|
||||||
"""
|
"""
|
||||||
return await telegram_rate_limiter.send_with_rate_limit(send_func, chat_id, *args, **kwargs)
|
return await telegram_rate_limiter.send_with_rate_limit(
|
||||||
|
send_func, chat_id, *args, **kwargs
|
||||||
|
)
|
||||||
|
|||||||
190
helper_bot/utils/s3_storage.py
Normal file
190
helper_bot/utils/s3_storage.py
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
"""
|
||||||
|
Сервис для работы с S3 хранилищем.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import aioboto3
|
||||||
|
|
||||||
|
from logs.custom_logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
class S3StorageService:
|
||||||
|
"""Сервис для работы с S3 хранилищем."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
endpoint_url: str,
|
||||||
|
access_key: str,
|
||||||
|
secret_key: str,
|
||||||
|
bucket_name: str,
|
||||||
|
region: str = "us-east-1",
|
||||||
|
):
|
||||||
|
self.endpoint_url = endpoint_url
|
||||||
|
self.access_key = access_key
|
||||||
|
self.secret_key = secret_key
|
||||||
|
self.bucket_name = bucket_name
|
||||||
|
self.region = region
|
||||||
|
self.session = aioboto3.Session()
|
||||||
|
|
||||||
|
async def upload_file(
|
||||||
|
self, file_path: str, s3_key: str, content_type: Optional[str] = None
|
||||||
|
) -> bool:
|
||||||
|
"""Загружает файл в S3."""
|
||||||
|
try:
|
||||||
|
async with self.session.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=self.endpoint_url,
|
||||||
|
aws_access_key_id=self.access_key,
|
||||||
|
aws_secret_access_key=self.secret_key,
|
||||||
|
region_name=self.region,
|
||||||
|
) as s3:
|
||||||
|
extra_args = {}
|
||||||
|
if content_type:
|
||||||
|
extra_args["ContentType"] = content_type
|
||||||
|
|
||||||
|
await s3.upload_file(
|
||||||
|
file_path, self.bucket_name, s3_key, ExtraArgs=extra_args
|
||||||
|
)
|
||||||
|
logger.info(f"Файл загружен в S3: {s3_key}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка загрузки файла в S3 {s3_key}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def upload_fileobj(
|
||||||
|
self, file_obj, s3_key: str, content_type: Optional[str] = None
|
||||||
|
) -> bool:
|
||||||
|
"""Загружает файл из объекта в S3."""
|
||||||
|
try:
|
||||||
|
async with self.session.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=self.endpoint_url,
|
||||||
|
aws_access_key_id=self.access_key,
|
||||||
|
aws_secret_access_key=self.secret_key,
|
||||||
|
region_name=self.region,
|
||||||
|
) as s3:
|
||||||
|
extra_args = {}
|
||||||
|
if content_type:
|
||||||
|
extra_args["ContentType"] = content_type
|
||||||
|
|
||||||
|
await s3.upload_fileobj(
|
||||||
|
file_obj, self.bucket_name, s3_key, ExtraArgs=extra_args
|
||||||
|
)
|
||||||
|
logger.info(f"Файл загружен в S3 из объекта: {s3_key}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка загрузки файла в S3 из объекта {s3_key}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def download_file(self, s3_key: str, local_path: str) -> bool:
|
||||||
|
"""Скачивает файл из S3 на локальный диск."""
|
||||||
|
try:
|
||||||
|
async with self.session.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=self.endpoint_url,
|
||||||
|
aws_access_key_id=self.access_key,
|
||||||
|
aws_secret_access_key=self.secret_key,
|
||||||
|
region_name=self.region,
|
||||||
|
) as s3:
|
||||||
|
# Создаем директорию если её нет
|
||||||
|
os.makedirs(os.path.dirname(local_path), exist_ok=True)
|
||||||
|
|
||||||
|
await s3.download_file(self.bucket_name, s3_key, local_path)
|
||||||
|
logger.info(f"Файл скачан из S3: {s3_key} -> {local_path}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка скачивания файла из S3 {s3_key}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def download_to_temp(self, s3_key: str) -> Optional[str]:
|
||||||
|
"""Скачивает файл из S3 во временный файл. Возвращает путь к временному файлу."""
|
||||||
|
try:
|
||||||
|
# Определяем расширение из ключа
|
||||||
|
ext = Path(s3_key).suffix or ".bin"
|
||||||
|
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=ext)
|
||||||
|
temp_path = temp_file.name
|
||||||
|
temp_file.close()
|
||||||
|
|
||||||
|
success = await self.download_file(s3_key, temp_path)
|
||||||
|
if success:
|
||||||
|
return temp_path
|
||||||
|
else:
|
||||||
|
# Удаляем временный файл при ошибке
|
||||||
|
try:
|
||||||
|
os.remove(temp_path)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Ошибка скачивания файла из S3 во временный файл {s3_key}: {e}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def file_exists(self, s3_key: str) -> bool:
|
||||||
|
"""Проверяет существование файла в S3."""
|
||||||
|
try:
|
||||||
|
async with self.session.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=self.endpoint_url,
|
||||||
|
aws_access_key_id=self.access_key,
|
||||||
|
aws_secret_access_key=self.secret_key,
|
||||||
|
region_name=self.region,
|
||||||
|
) as s3:
|
||||||
|
await s3.head_object(Bucket=self.bucket_name, Key=s3_key)
|
||||||
|
return True
|
||||||
|
except:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def delete_file(self, s3_key: str) -> bool:
|
||||||
|
"""Удаляет файл из S3."""
|
||||||
|
try:
|
||||||
|
async with self.session.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=self.endpoint_url,
|
||||||
|
aws_access_key_id=self.access_key,
|
||||||
|
aws_secret_access_key=self.secret_key,
|
||||||
|
region_name=self.region,
|
||||||
|
) as s3:
|
||||||
|
await s3.delete_object(Bucket=self.bucket_name, Key=s3_key)
|
||||||
|
logger.info(f"Файл удален из S3: {s3_key}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка удаления файла из S3 {s3_key}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def generate_s3_key(self, content_type: str, file_id: str) -> str:
|
||||||
|
"""Генерирует S3 ключ для файла. Один и тот же для всех постов с этим file_id."""
|
||||||
|
type_folders = {
|
||||||
|
"photo": "photos",
|
||||||
|
"video": "videos",
|
||||||
|
"audio": "music",
|
||||||
|
"voice": "voice",
|
||||||
|
"video_note": "video_notes",
|
||||||
|
}
|
||||||
|
|
||||||
|
folder = type_folders.get(content_type, "other")
|
||||||
|
# Определяем расширение из file_id или используем дефолтное
|
||||||
|
ext = (
|
||||||
|
".jpg"
|
||||||
|
if content_type == "photo"
|
||||||
|
else (
|
||||||
|
".mp4"
|
||||||
|
if content_type == "video"
|
||||||
|
else (
|
||||||
|
".mp3"
|
||||||
|
if content_type == "audio"
|
||||||
|
else (
|
||||||
|
".ogg"
|
||||||
|
if content_type == "voice"
|
||||||
|
else ".mp4" if content_type == "video_note" else ".bin"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"{folder}/{file_id}{ext}"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from aiogram.fsm.state import StatesGroup, State
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
|
||||||
|
|
||||||
class StateUser(StatesGroup):
|
class StateUser(StatesGroup):
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
# Remove default handler
|
# Remove default handler
|
||||||
logger.remove()
|
logger.remove()
|
||||||
|
|
||||||
# Check if running in Docker/container
|
# Check if running in Docker/container
|
||||||
is_container = os.path.exists('/.dockerenv') or os.getenv('DOCKER_CONTAINER') == 'true'
|
is_container = os.path.exists("/.dockerenv") or os.getenv("DOCKER_CONTAINER") == "true"
|
||||||
|
|
||||||
if is_container:
|
if is_container:
|
||||||
# In container: log to stdout/stderr
|
# In container: log to stdout/stderr
|
||||||
@@ -15,23 +16,23 @@ if is_container:
|
|||||||
sys.stdout,
|
sys.stdout,
|
||||||
format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name} | {line} | {message}",
|
format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name} | {line} | {message}",
|
||||||
level=os.getenv("LOG_LEVEL", "INFO"),
|
level=os.getenv("LOG_LEVEL", "INFO"),
|
||||||
colorize=True
|
colorize=True,
|
||||||
)
|
)
|
||||||
logger.add(
|
logger.add(
|
||||||
sys.stderr,
|
sys.stderr,
|
||||||
format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name} | {line} | {message}",
|
format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name} | {line} | {message}",
|
||||||
level="ERROR",
|
level="ERROR",
|
||||||
colorize=True
|
colorize=True,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Local development: log to files
|
# Local development: log to files
|
||||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
if not os.path.exists(current_dir):
|
if not os.path.exists(current_dir):
|
||||||
os.makedirs(current_dir)
|
os.makedirs(current_dir)
|
||||||
|
|
||||||
today = datetime.date.today().strftime('%Y-%m-%d')
|
today = datetime.date.today().strftime("%Y-%m-%d")
|
||||||
filename = f'{current_dir}/helper_bot_{today}.log'
|
filename = f"{current_dir}/helper_bot_{today}.log"
|
||||||
|
|
||||||
logger.add(
|
logger.add(
|
||||||
filename,
|
filename,
|
||||||
rotation="00:00",
|
rotation="00:00",
|
||||||
@@ -41,4 +42,4 @@ else:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Bind logger name
|
# Bind logger name
|
||||||
logger = logger.bind(name='main_log')
|
logger = logger.bind(name="main_log")
|
||||||
|
|||||||
@@ -2,7 +2,14 @@
|
|||||||
name = "telegram-helper-bot"
|
name = "telegram-helper-bot"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
description = "Telegram bot with monitoring and metrics"
|
description = "Telegram bot with monitoring and metrics"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.11"
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 88
|
||||||
|
|
||||||
|
[tool.isort]
|
||||||
|
profile = "black"
|
||||||
|
line_length = 88
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
|||||||
@@ -9,5 +9,6 @@ coverage>=7.0.0
|
|||||||
|
|
||||||
# Development tools
|
# Development tools
|
||||||
black>=23.0.0
|
black>=23.0.0
|
||||||
|
isort>=5.12.0
|
||||||
flake8>=6.0.0
|
flake8>=6.0.0
|
||||||
mypy>=1.0.0
|
mypy>=1.0.0
|
||||||
|
|||||||
@@ -21,10 +21,16 @@ aiohttp==3.9.1
|
|||||||
# Network stability improvements
|
# Network stability improvements
|
||||||
aiohttp[speedups]>=3.9.1
|
aiohttp[speedups]>=3.9.1
|
||||||
aiodns>=3.0.0
|
aiodns>=3.0.0
|
||||||
cchardet>=2.1.7
|
charset-normalizer>=3.0.0
|
||||||
|
|
||||||
# Development tools
|
# Development tools
|
||||||
pluggy==1.5.0
|
pluggy==1.5.0
|
||||||
attrs~=23.2.0
|
attrs~=23.2.0
|
||||||
typing_extensions~=4.12.2
|
typing_extensions~=4.12.2
|
||||||
emoji~=2.8.0
|
emoji~=2.8.0
|
||||||
|
|
||||||
|
# S3 Storage (для хранения медиафайлов опубликованных постов)
|
||||||
|
aioboto3>=12.0.0
|
||||||
|
|
||||||
|
# HTTP клиент для RAG API
|
||||||
|
httpx>=0.24.0
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user