Dev 11 #13
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."""
|
||||
...
|
||||
```
|
||||
117
.cursor/rules/database-patterns.md
Normal file
117
.cursor/rules/database-patterns.md
Normal file
@@ -0,0 +1,117 @@
|
||||
---
|
||||
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` для доступа к репозиториям
|
||||
|
||||
## Миграции
|
||||
|
||||
- SQL миграции в `database/schema.sql`
|
||||
- Python скрипты для миграций в `scripts/`
|
||||
- Всегда проверяйте существование таблиц перед созданием: `CREATE TABLE IF NOT EXISTS`
|
||||
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. **Логируйте важные операции** с внешними сервисами
|
||||
156
.cursor/rules/error-handling.md
Normal file
156
.cursor/rules/error-handling.md
Normal file
@@ -0,0 +1,156 @@
|
||||
---
|
||||
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()` - отладочная информация
|
||||
- `logger.info()` - информационные сообщения о работе
|
||||
- `logger.warning()` - предупреждения о потенциальных проблемах
|
||||
- `logger.error()` - ошибки, требующие внимания
|
||||
- `logger.critical()` - критические ошибки
|
||||
|
||||
## Метрики ошибок
|
||||
|
||||
Декоратор `@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()`** для локальной регистрации на уровне модуля
|
||||
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. **Тестируйте граничные случаи** и ошибки
|
||||
@@ -1 +1 @@
|
||||
3.9.6
|
||||
3.11.9
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
###########################################
|
||||
# Этап 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 \
|
||||
@@ -21,7 +21,7 @@ RUN pip install --no-cache-dir --target /install -r requirements.txt
|
||||
###########################################
|
||||
# Этап 2: Финальный образ (Runtime)
|
||||
###########################################
|
||||
FROM python:3.9-alpine as runtime
|
||||
FROM python:3.11.9-alpine as runtime
|
||||
|
||||
# Минимальные рантайм-зависимости
|
||||
RUN apk add --no-cache \
|
||||
@@ -34,7 +34,7 @@ RUN addgroup -g 1001 deploy && adduser -D -u 1001 -G deploy deploy
|
||||
WORKDIR /app
|
||||
|
||||
# Копируем зависимости
|
||||
COPY --from=builder --chown=1001:1001 /install /usr/local/lib/python3.9/site-packages
|
||||
COPY --from=builder --chown=1001:1001 /install /usr/local/lib/python3.11/site-packages
|
||||
|
||||
# Создаем структуру папок
|
||||
RUN mkdir -p database logs voice_users && \
|
||||
|
||||
@@ -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 тестирование разных конфигураций
|
||||
- Интеграция с системой алертов
|
||||
@@ -139,6 +139,10 @@ class AsyncBotDB:
|
||||
"""Добавление контента поста."""
|
||||
return await self.factory.posts.add_post_content(post_id, message_id, content_name, content_type)
|
||||
|
||||
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."""
|
||||
return await self.factory.posts.get_post_content_by_helper_id(last_post_id)
|
||||
@@ -147,6 +151,22 @@ class AsyncBotDB:
|
||||
"""Алиас для 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."""
|
||||
return await self.factory.posts.get_post_text_by_helper_id(last_post_id)
|
||||
@@ -159,6 +179,10 @@ class AsyncBotDB:
|
||||
"""Получает ID сообщений по helper_text_message_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]:
|
||||
"""Получает ID автора по message_id."""
|
||||
return await self.factory.posts.get_author_id_by_message_id(message_id)
|
||||
|
||||
@@ -19,11 +19,31 @@ class PostRepository(DatabaseConnection):
|
||||
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
|
||||
)
|
||||
'''
|
||||
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 = '''
|
||||
CREATE TABLE IF NOT EXISTS content_post_from_telegram (
|
||||
@@ -47,6 +67,26 @@ class PostRepository(DatabaseConnection):
|
||||
'''
|
||||
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("Таблицы для постов созданы")
|
||||
|
||||
async def add_post(self, post: TelegramPost) -> None:
|
||||
@@ -57,14 +97,15 @@ class PostRepository(DatabaseConnection):
|
||||
# Преобразуем 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 = """
|
||||
INSERT INTO post_from_telegram_suggest (message_id, text, author_id, created_at, status, is_anonymous)
|
||||
INSERT OR IGNORE INTO post_from_telegram_suggest (message_id, text, author_id, created_at, status, is_anonymous)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
"""
|
||||
params = (post.message_id, post.text, post.author_id, post.created_at, status, is_anonymous_int)
|
||||
|
||||
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:
|
||||
"""Обновление helper сообщения."""
|
||||
@@ -160,6 +201,18 @@ class PostRepository(DatabaseConnection):
|
||||
self.logger.error(f"Ошибка при добавлении контента поста: {e}")
|
||||
return False
|
||||
|
||||
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."""
|
||||
query = """
|
||||
@@ -174,6 +227,20 @@ class PostRepository(DatabaseConnection):
|
||||
self.logger.info(f"Получен контент поста: {len(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]:
|
||||
"""Получает текст поста по helper_text_message_id."""
|
||||
query = "SELECT text FROM post_from_telegram_suggest WHERE helper_text_message_id = ?"
|
||||
@@ -252,3 +319,40 @@ class PostRepository(DatabaseConnection):
|
||||
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
|
||||
|
||||
@@ -73,6 +73,7 @@ CREATE TABLE IF NOT EXISTS post_from_telegram_suggest (
|
||||
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
|
||||
);
|
||||
|
||||
@@ -93,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
|
||||
);
|
||||
|
||||
-- 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)
|
||||
CREATE TABLE IF NOT EXISTS our_users (
|
||||
user_id INTEGER NOT NULL PRIMARY KEY,
|
||||
@@ -130,3 +140,5 @@ 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_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_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
|
||||
```
|
||||
@@ -12,6 +12,14 @@ IMPORTANT_LOGS=-1001234567890
|
||||
ARCHIVE=-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
|
||||
PREVIEW_LINK=false
|
||||
LOGS=false
|
||||
|
||||
@@ -13,7 +13,8 @@ def get_post_publish_service() -> PostPublishService:
|
||||
|
||||
db = bdf.get_db()
|
||||
settings = bdf.settings
|
||||
return PostPublishService(None, db, settings)
|
||||
s3_storage = bdf.get_s3_storage()
|
||||
return PostPublishService(None, db, settings, s3_storage)
|
||||
|
||||
|
||||
def get_ban_service() -> BanService:
|
||||
|
||||
@@ -3,6 +3,7 @@ import html
|
||||
from typing import Dict, Any
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram import types
|
||||
from aiogram.types import CallbackQuery
|
||||
|
||||
from helper_bot.utils.helper_func import (
|
||||
@@ -33,11 +34,12 @@ from helper_bot.utils.metrics import (
|
||||
|
||||
|
||||
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):
|
||||
# bot может быть None - в этом случае используем бота из контекста сообщения
|
||||
self.bot = bot
|
||||
self.db = db
|
||||
self.settings = settings
|
||||
self.s3_storage = s3_storage
|
||||
self.group_for_posts = settings['Telegram']['group_for_posts']
|
||||
self.main_public = settings['Telegram']['main_public']
|
||||
self.important_logs = settings['Telegram']['important_logs']
|
||||
@@ -52,7 +54,12 @@ class PostPublishService:
|
||||
@track_errors("post_publish_service", "publish_post")
|
||||
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:
|
||||
await self._publish_media_group(call)
|
||||
return
|
||||
@@ -98,9 +105,16 @@ class PostPublishService:
|
||||
# Формируем финальный текст с учетом is_anonymous
|
||||
formatted_text = get_text_message(raw_text, user.first_name, user.username, is_anonymous)
|
||||
|
||||
await send_text_message(self.main_public, call.message, formatted_text)
|
||||
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)
|
||||
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_errors("post_publish_service", "_publish_photo_post")
|
||||
@@ -126,9 +140,19 @@ class PostPublishService:
|
||||
# Формируем финальный текст с учетом is_anonymous
|
||||
formatted_text = get_text_message(raw_text, user.first_name, user.username, is_anonymous)
|
||||
|
||||
await send_photo_message(self.main_public, call.message, call.message.photo[-1].file_id, formatted_text)
|
||||
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)
|
||||
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_errors("post_publish_service", "_publish_video_post")
|
||||
@@ -154,9 +178,19 @@ class PostPublishService:
|
||||
# Формируем финальный текст с учетом is_anonymous
|
||||
formatted_text = get_text_message(raw_text, user.first_name, user.username, is_anonymous)
|
||||
|
||||
await send_video_message(self.main_public, call.message, call.message.video.file_id, formatted_text)
|
||||
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)
|
||||
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_errors("post_publish_service", "_publish_video_note_post")
|
||||
@@ -169,9 +203,19 @@ class PostPublishService:
|
||||
logger.error(f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'")
|
||||
raise PostNotFoundError(f"Пост с message_id={call.message.message_id} не найден в базе данных")
|
||||
|
||||
await send_video_note_message(self.main_public, call.message, call.message.video_note.file_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)
|
||||
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_errors("post_publish_service", "_publish_audio_post")
|
||||
@@ -197,9 +241,19 @@ class PostPublishService:
|
||||
# Формируем финальный текст с учетом is_anonymous
|
||||
formatted_text = get_text_message(raw_text, user.first_name, user.username, is_anonymous)
|
||||
|
||||
await send_audio_message(self.main_public, call.message, call.message.audio.file_id, formatted_text)
|
||||
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)
|
||||
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_errors("post_publish_service", "_publish_voice_post")
|
||||
@@ -212,67 +266,112 @@ class PostPublishService:
|
||||
logger.error(f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'")
|
||||
raise PostNotFoundError(f"Пост с message_id={call.message.message_id} не найден в базе данных")
|
||||
|
||||
await send_voice_message(self.main_public, call.message, call.message.voice.file_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)
|
||||
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_errors("post_publish_service", "_publish_media_group")
|
||||
@track_media_processing("media_group")
|
||||
async def _publish_media_group(self, call: CallbackQuery) -> None:
|
||||
"""Публикация медиагруппы"""
|
||||
logger.info(f"Начинаю публикацию медиагруппы. Helper message ID: {call.message.message_id}")
|
||||
try:
|
||||
# call.message.message_id - это ID helper сообщения
|
||||
helper_message_id = call.message.message_id
|
||||
|
||||
# Получаем контент медиагруппы по helper_message_id
|
||||
logger.debug(f"Получаю контент медиагруппы для helper_message_id: {helper_message_id}")
|
||||
media_group_message_ids = await self.db.get_post_ids_by_helper_id(helper_message_id)
|
||||
if not media_group_message_ids:
|
||||
logger.error(f"_publish_media_group: Не найдены message_id медиагруппы для helper_message_id={helper_message_id}")
|
||||
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"Контент медиагруппы не найден в базе данных для helper_message_id: {helper_message_id}")
|
||||
logger.error(f"_publish_media_group: Контент медиагруппы не найден в базе данных для helper_message_id={helper_message_id}")
|
||||
raise PublishError("Контент медиагруппы не найден в базе данных")
|
||||
|
||||
# Получаем сырой текст и is_anonymous по helper_message_id
|
||||
logger.debug(f"Получаю текст и is_anonymous поста для helper_message_id: {helper_message_id}")
|
||||
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 = ""
|
||||
logger.debug(f"Текст поста получен: {'пустой' if not raw_text else f'длина: {len(raw_text)} символов'}, is_anonymous={is_anonymous}")
|
||||
|
||||
# Получаем 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}")
|
||||
logger.error(f"_publish_media_group: Автор не найден для медиагруппы helper_message_id={helper_message_id}")
|
||||
raise PostNotFoundError(f"Автор не найден для медиагруппы {helper_message_id}")
|
||||
logger.debug(f"ID автора получен: {author_id}")
|
||||
|
||||
# Получаем данные автора
|
||||
user = await self.db.get_user_by_id(author_id)
|
||||
if not user:
|
||||
raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных")
|
||||
|
||||
# Формируем финальный текст с учетом is_anonymous
|
||||
formatted_text = get_text_message(raw_text, user.first_name, user.username, is_anonymous)
|
||||
logger.debug(f"Сформирован финальный текст: {'пустой' if not formatted_text else f'длина: {len(formatted_text)} символов'}")
|
||||
|
||||
# Отправляем медиагруппу в канал
|
||||
logger.info(f"Отправляю медиагруппу в канал {self.main_public}")
|
||||
await send_media_group_to_channel(
|
||||
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
|
||||
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")
|
||||
logger.debug(f"Удаляю медиагруппу и уведомляю автора {author_id}")
|
||||
await self._delete_media_group_and_notify_author(call, author_id)
|
||||
logger.info(f'Медиагруппа опубликована в канале {self.main_public}.')
|
||||
|
||||
# Удаляем 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:
|
||||
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)}")
|
||||
|
||||
@track_time("decline_post", "post_publish_service")
|
||||
@@ -321,27 +420,32 @@ class PostPublishService:
|
||||
@track_media_processing("media_group")
|
||||
async def _decline_media_group(self, call: CallbackQuery) -> None:
|
||||
"""Отклонение медиагруппы"""
|
||||
await self.db.update_status_for_media_group_by_helper_id(call.message.message_id, "declined")
|
||||
helper_message_id = call.message.message_id
|
||||
|
||||
await self.db.update_status_for_media_group_by_helper_id(helper_message_id, "declined")
|
||||
|
||||
post_ids = await self.db.get_post_ids_from_telegram_by_last_id(call.message.message_id)
|
||||
message_ids = post_ids.copy()
|
||||
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(helper_message_id)
|
||||
|
||||
author_id = await self._get_author_id_for_media_group(call.message.message_id)
|
||||
logger.debug(f"ID автора медиагруппы получен: {author_id}")
|
||||
message_ids_to_delete = media_group_message_ids.copy()
|
||||
message_ids_to_delete.append(helper_message_id)
|
||||
|
||||
logger.debug(f"Удаляю {len(message_ids)} сообщений из группы {self.group_for_posts}")
|
||||
await self._get_bot(call.message).delete_messages(chat_id=self.group_for_posts, message_ids=message_ids)
|
||||
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:
|
||||
logger.debug(f"Отправляю уведомление об отклонении автору медиагруппы {author_id}")
|
||||
await send_text_message(author_id, call.message, MESSAGE_POST_DECLINED)
|
||||
except Exception as e:
|
||||
if str(e) == ERROR_BOT_BLOCKED:
|
||||
logger.warning(f"Пользователь {author_id} заблокировал бота")
|
||||
logger.warning(f"_decline_media_group: Пользователь {author_id} заблокировал бота")
|
||||
raise UserBlockedBotError("Пользователь заблокировал бота")
|
||||
logger.error(f"Ошибка при отправке уведомления автору медиагруппы {author_id}: {e}")
|
||||
logger.error(f"_decline_media_group: Ошибка при отправке уведомления автору {author_id}: {e}")
|
||||
raise
|
||||
|
||||
@track_time("_get_author_id", "post_publish_service")
|
||||
@@ -399,12 +503,15 @@ class PostPublishService:
|
||||
@track_errors("post_publish_service", "_delete_media_group_and_notify_author")
|
||||
@track_media_processing("media_group")
|
||||
async def _delete_media_group_and_notify_author(self, call: CallbackQuery, author_id: int) -> None:
|
||||
"""Удаление медиагруппы и уведомление автора"""
|
||||
post_ids = await self.db.get_post_ids_from_telegram_by_last_id(call.message.message_id)
|
||||
"""Удаление медиагруппы и уведомление автора (legacy метод, используется для обратной совместимости)"""
|
||||
helper_message_id = call.message.message_id
|
||||
|
||||
media_group_message_ids = await self.db.get_post_ids_by_helper_id(helper_message_id)
|
||||
|
||||
#message_ids = post_ids.copy()
|
||||
post_ids.append(call.message.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:
|
||||
await send_text_message(author_id, call.message, MESSAGE_POST_PUBLISHED)
|
||||
except Exception as e:
|
||||
@@ -412,6 +519,34 @@ class PostPublishService:
|
||||
raise UserBlockedBotError("Пользователь заблокировал бота")
|
||||
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:
|
||||
def __init__(self, bot: Bot, db, settings: Dict[str, Any]):
|
||||
@@ -432,7 +567,12 @@ class BanService:
|
||||
@db_query_time("ban_user_from_post", "users", "mixed")
|
||||
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:
|
||||
raise UserNotFoundError(f"Автор не найден для сообщения {call.message.message_id}")
|
||||
|
||||
|
||||
@@ -44,16 +44,15 @@ sleep = asyncio.sleep
|
||||
class PrivateHandlers:
|
||||
"""Main handler class for private messages"""
|
||||
|
||||
def __init__(self, db: AsyncBotDB, settings: BotSettings):
|
||||
def __init__(self, db: AsyncBotDB, settings: BotSettings, s3_storage=None):
|
||||
self.db = db
|
||||
self.settings = settings
|
||||
self.user_service = UserService(db, settings)
|
||||
self.post_service = PostService(db, settings)
|
||||
self.post_service = PostService(db, settings, s3_storage)
|
||||
self.sticker_service = StickerService(settings)
|
||||
|
||||
# Create router
|
||||
self.router = Router()
|
||||
self.router.message.middleware(AlbumMiddleware())
|
||||
self.router.message.middleware(AlbumMiddleware(latency=5.0))
|
||||
self.router.message.middleware(BlacklistMiddleware())
|
||||
|
||||
# Register handlers
|
||||
@@ -158,16 +157,42 @@ class PrivateHandlers:
|
||||
@track_time("suggest_router", "private_handlers")
|
||||
async def suggest_router(self, message: types.Message, state: FSMContext, album: list = None, **kwargs):
|
||||
"""Handle post submission in suggest state"""
|
||||
# Post service operations with metrics
|
||||
await self.user_service.update_user_activity(message.from_user.id)
|
||||
await self.user_service.log_user_message(message)
|
||||
await self.post_service.process_post(message, album)
|
||||
# Проверяем, есть ли механизм для получения полной медиагруппы (для медиагрупп)
|
||||
album_getter = kwargs.get("album_getter")
|
||||
|
||||
# Send success message and return to start state
|
||||
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')
|
||||
await message.answer(success_send_message, reply_markup=markup_for_user)
|
||||
await state.set_state(FSM_STATES["START"])
|
||||
if album_getter and message.media_group_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')
|
||||
await message.answer(success_send_message, reply_markup=markup_for_user)
|
||||
await state.set_state(FSM_STATES["START"])
|
||||
|
||||
# В фоне ждем полную медиагруппу и обрабатываем пост
|
||||
async def process_media_group_background():
|
||||
try:
|
||||
# Ждем полную медиагруппу
|
||||
full_album = await album_getter.get_album(timeout=10.0)
|
||||
if not full_album:
|
||||
return
|
||||
|
||||
# Обрабатываем пост с полной медиагруппой
|
||||
await self.user_service.update_user_activity(message.from_user.id)
|
||||
await self.post_service.process_post(message, full_album)
|
||||
except Exception as e:
|
||||
from logs.custom_logger import logger
|
||||
logger.error(f"Ошибка при фоновой обработке медиагруппы: {e}")
|
||||
|
||||
asyncio.create_task(process_media_group_background())
|
||||
else:
|
||||
# Обычное сообщение или медиагруппа уже собрана - обрабатываем синхронно
|
||||
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)
|
||||
await self.post_service.process_post(message, album)
|
||||
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')
|
||||
await message.answer(success_send_message, reply_markup=markup_for_user)
|
||||
await state.set_state(FSM_STATES["START"])
|
||||
|
||||
@error_handler
|
||||
@track_errors("private_handlers", "stickers")
|
||||
@@ -224,9 +249,9 @@ class PrivateHandlers:
|
||||
|
||||
|
||||
# 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) -> PrivateHandlers:
|
||||
"""Create private handlers instance with dependencies"""
|
||||
return PrivateHandlers(db, settings)
|
||||
return PrivateHandlers(db, settings, s3_storage)
|
||||
|
||||
|
||||
# Legacy router for backward compatibility
|
||||
@@ -252,7 +277,8 @@ def init_legacy_router():
|
||||
)
|
||||
|
||||
db = bdf.get_db()
|
||||
handlers = create_private_handlers(db, settings)
|
||||
s3_storage = bdf.get_s3_storage()
|
||||
handlers = create_private_handlers(db, settings, s3_storage)
|
||||
|
||||
# Instead of trying to copy handlers, we'll use the new router directly
|
||||
# This maintains backward compatibility while using the new architecture
|
||||
|
||||
@@ -143,9 +143,19 @@ class UserService:
|
||||
class PostService:
|
||||
"""Service for post-related operations"""
|
||||
|
||||
def __init__(self, db: DatabaseProtocol, settings: BotSettings) -> None:
|
||||
def __init__(self, db: DatabaseProtocol, settings: BotSettings, s3_storage=None) -> None:
|
||||
self.db = db
|
||||
self.settings = settings
|
||||
self.s3_storage = s3_storage
|
||||
|
||||
async def _save_media_background(self, sent_message: types.Message, bot_db: Any, s3_storage) -> None:
|
||||
"""Сохраняет медиа в фоне, чтобы не блокировать ответ пользователю"""
|
||||
try:
|
||||
success = await add_in_db_media(sent_message, bot_db, s3_storage)
|
||||
if not success:
|
||||
logger.warning(f"_save_media_background: Не удалось сохранить медиа для поста {sent_message.message_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"_save_media_background: Ошибка при сохранении медиа для поста {sent_message.message_id}: {e}")
|
||||
|
||||
@track_time("handle_text_post", "post_service")
|
||||
@track_errors("post_service", "handle_text_post")
|
||||
@@ -155,14 +165,14 @@ class PostService:
|
||||
post_text = get_text_message(message.text.lower(), first_name, message.from_user.username)
|
||||
markup = get_reply_keyboard_for_post()
|
||||
|
||||
sent_message_id = await send_text_message(self.settings.group_for_posts, message, post_text, markup)
|
||||
sent_message = await send_text_message(self.settings.group_for_posts, message, post_text, markup)
|
||||
|
||||
# Сохраняем сырой текст и определяем анонимность
|
||||
raw_text = message.text or ""
|
||||
is_anonymous = determine_anonymity(raw_text)
|
||||
|
||||
post = TelegramPost(
|
||||
message_id=sent_message_id,
|
||||
message_id=sent_message.message_id,
|
||||
text=raw_text,
|
||||
author_id=message.from_user.id,
|
||||
created_at=int(datetime.now().timestamp()),
|
||||
@@ -196,9 +206,8 @@ class PostService:
|
||||
is_anonymous=is_anonymous
|
||||
)
|
||||
await self.db.add_post(post)
|
||||
success = await add_in_db_media(sent_message, self.db)
|
||||
if not success:
|
||||
logger.warning(f"handle_photo_post: Не удалось сохранить медиа для поста {sent_message.message_id}")
|
||||
# Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю
|
||||
asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage))
|
||||
|
||||
@track_time("handle_video_post", "post_service")
|
||||
@track_errors("post_service", "handle_video_post")
|
||||
@@ -226,9 +235,8 @@ class PostService:
|
||||
is_anonymous=is_anonymous
|
||||
)
|
||||
await self.db.add_post(post)
|
||||
success = await add_in_db_media(sent_message, self.db)
|
||||
if not success:
|
||||
logger.warning(f"handle_photo_post: Не удалось сохранить медиа для поста {sent_message.message_id}")
|
||||
# Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю
|
||||
asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage))
|
||||
|
||||
@track_time("handle_video_note_post", "post_service")
|
||||
@track_errors("post_service", "handle_video_note_post")
|
||||
@@ -252,9 +260,8 @@ class PostService:
|
||||
is_anonymous=is_anonymous
|
||||
)
|
||||
await self.db.add_post(post)
|
||||
success = await add_in_db_media(sent_message, self.db)
|
||||
if not success:
|
||||
logger.warning(f"handle_photo_post: Не удалось сохранить медиа для поста {sent_message.message_id}")
|
||||
# Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю
|
||||
asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage))
|
||||
|
||||
@track_time("handle_audio_post", "post_service")
|
||||
@track_errors("post_service", "handle_audio_post")
|
||||
@@ -282,9 +289,8 @@ class PostService:
|
||||
is_anonymous=is_anonymous
|
||||
)
|
||||
await self.db.add_post(post)
|
||||
success = await add_in_db_media(sent_message, self.db)
|
||||
if not success:
|
||||
logger.warning(f"handle_photo_post: Не удалось сохранить медиа для поста {sent_message.message_id}")
|
||||
# Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю
|
||||
asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage))
|
||||
|
||||
@track_time("handle_voice_post", "post_service")
|
||||
@track_errors("post_service", "handle_voice_post")
|
||||
@@ -308,9 +314,8 @@ class PostService:
|
||||
is_anonymous=is_anonymous
|
||||
)
|
||||
await self.db.add_post(post)
|
||||
success = await add_in_db_media(sent_message, self.db)
|
||||
if not success:
|
||||
logger.warning(f"handle_photo_post: Не удалось сохранить медиа для поста {sent_message.message_id}")
|
||||
# Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю
|
||||
asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage))
|
||||
|
||||
@track_time("handle_media_group_post", "post_service")
|
||||
@track_errors("post_service", "handle_media_group_post")
|
||||
@@ -318,7 +323,6 @@ class PostService:
|
||||
@track_media_processing("media_group")
|
||||
async def handle_media_group_post(self, message: types.Message, album: list, first_name: str) -> None:
|
||||
"""Handle media group post submission"""
|
||||
#TODO: Мне кажется тут какая-то дичь с одинаковыми переменными, в которых post_caption никуда не ведет
|
||||
post_caption = " "
|
||||
raw_caption = ""
|
||||
|
||||
@@ -326,12 +330,17 @@ class PostService:
|
||||
raw_caption = album[0].caption or ""
|
||||
post_caption = get_text_message(album[0].caption.lower(), first_name, message.from_user.username)
|
||||
|
||||
# Определяем анонимность на основе сырого caption
|
||||
is_anonymous = determine_anonymity(raw_caption)
|
||||
media_group = await prepare_media_group_from_middlewares(album, post_caption)
|
||||
|
||||
media_group_message_ids = await send_media_group_message_to_private_chat(
|
||||
self.settings.group_for_posts, message, media_group, self.db, None, self.s3_storage
|
||||
)
|
||||
|
||||
main_post_id = media_group_message_ids[-1]
|
||||
|
||||
# Создаем основной пост для медиагруппы
|
||||
main_post = TelegramPost(
|
||||
message_id=message.message_id, # ID основного сообщения медиагруппы
|
||||
message_id=main_post_id,
|
||||
text=raw_caption,
|
||||
author_id=message.from_user.id,
|
||||
created_at=int(datetime.now().timestamp()),
|
||||
@@ -339,32 +348,32 @@ class PostService:
|
||||
)
|
||||
await self.db.add_post(main_post)
|
||||
|
||||
# Отправляем медиагруппу в группу для модерации
|
||||
media_group = await prepare_media_group_from_middlewares(album, post_caption)
|
||||
media_group_message_id = await send_media_group_message_to_private_chat(
|
||||
self.settings.group_for_posts, message, media_group, self.db, main_post.message_id
|
||||
)
|
||||
for msg_id in media_group_message_ids:
|
||||
await self.db.add_message_link(main_post_id, msg_id)
|
||||
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
# Создаем helper сообщение с кнопками
|
||||
markup = get_reply_keyboard_for_post()
|
||||
help_message_id = await send_text_message(self.settings.group_for_posts, message, "ВРУЧНУЮ ВЫКЛАДЫВАТЬ, ПОСЛЕ ВЫКЛАДКИ УДАЛИТЬ ОБА ПОСТА")
|
||||
helper_message = await send_text_message(
|
||||
self.settings.group_for_posts,
|
||||
message,
|
||||
"^",
|
||||
markup
|
||||
)
|
||||
helper_message_id = helper_message.message_id
|
||||
|
||||
# Создаем helper пост и связываем его с основным
|
||||
helper_post = TelegramPost(
|
||||
message_id=help_message_id, # ID helper сообщения
|
||||
text="^", # Специальный маркер для медиагруппы
|
||||
message_id=helper_message_id,
|
||||
text="^",
|
||||
author_id=message.from_user.id,
|
||||
helper_text_message_id=main_post.message_id, # Ссылка на основной пост
|
||||
helper_text_message_id=main_post_id,
|
||||
created_at=int(datetime.now().timestamp())
|
||||
)
|
||||
await self.db.add_post(helper_post)
|
||||
|
||||
# Обновляем основной пост, чтобы он ссылался на helper
|
||||
await self.db.update_helper_message(
|
||||
message_id=main_post.message_id,
|
||||
helper_message_id=help_message_id
|
||||
message_id=main_post_id,
|
||||
helper_message_id=helper_message_id
|
||||
)
|
||||
|
||||
@track_time("process_post", "post_service")
|
||||
@@ -373,13 +382,8 @@ class PostService:
|
||||
async def process_post(self, message: types.Message, album: Union[list, None] = None) -> None:
|
||||
"""Process post based on content type"""
|
||||
first_name = get_first_name(message)
|
||||
# TODO: Бесит меня этот функционал
|
||||
|
||||
if message.media_group_id is not None:
|
||||
safe_username = html.escape(message.from_user.username) if message.from_user.username else "Без никнейма"
|
||||
await send_text_message(
|
||||
self.settings.group_for_logs, message,
|
||||
f'Закинул медиагруппу, пользователь: имя - {first_name}, ник - {safe_username}'
|
||||
)
|
||||
await self.handle_media_group_post(message, album, first_name)
|
||||
return
|
||||
|
||||
|
||||
@@ -1,17 +1,45 @@
|
||||
import asyncio
|
||||
from typing import Any, Dict, Union, List
|
||||
from typing import Any, Dict, Union, List, Optional
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
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):
|
||||
"""
|
||||
Middleware для обработки медиа групп в Telegram.
|
||||
Собирает все сообщения одной медиа группы и передает их как album в data.
|
||||
Не блокирует handler - сразу вызывает его, а полную медиагруппу передает через Event.
|
||||
"""
|
||||
|
||||
def __init__(self, latency: Union[int, float] = 0.01):
|
||||
def __init__(self, latency: Union[int, float] = 5.0):
|
||||
"""
|
||||
Инициализация middleware.
|
||||
|
||||
@@ -20,7 +48,8 @@ class AlbumMiddleware(BaseMiddleware):
|
||||
"""
|
||||
super().__init__()
|
||||
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:
|
||||
"""
|
||||
@@ -41,42 +70,98 @@ class AlbumMiddleware(BaseMiddleware):
|
||||
self.album_data[event.media_group_id]["messages"].append(event)
|
||||
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:
|
||||
"""
|
||||
Основная логика middleware.
|
||||
|
||||
Для медиагрупп: сразу вызывает handler, передавая Event для получения полной медиагруппы.
|
||||
Для обычных сообщений: сразу вызывает handler.
|
||||
|
||||
Args:
|
||||
handler: Обработчик события
|
||||
event: Событие (сообщение)
|
||||
data: Данные для передачи в обработчик
|
||||
|
||||
|
||||
Returns:
|
||||
Результат выполнения обработчика
|
||||
"""
|
||||
# Если у события нет media_group_id, передаем его обработчику сразу
|
||||
if not event.media_group_id:
|
||||
return await handler(event, data)
|
||||
|
||||
# Собираем сообщения одной медиа группы
|
||||
total_before = self.collect_album_messages(event)
|
||||
media_group_id = event.media_group_id
|
||||
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
|
||||
album_event = asyncio.Event()
|
||||
self.album_data[media_group_id] = {
|
||||
"messages": [],
|
||||
"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
|
||||
|
||||
# Проверяем количество сообщений после задержки
|
||||
total_after = len(self.album_data[event.media_group_id]["messages"])
|
||||
# Добавляем сообщение в медиагруппу
|
||||
self.album_data[media_group_id]["messages"].append(event)
|
||||
|
||||
# Если за время задержки добавились новые сообщения, выходим
|
||||
if total_before != total_after:
|
||||
# Обрабатываем только первое сообщение медиагруппы
|
||||
if not is_first_message:
|
||||
# Для остальных сообщений просто возвращаемся, не вызывая handler
|
||||
return
|
||||
|
||||
# Сортируем сообщения по message_id и добавляем в data
|
||||
album_messages = self.album_data[event.media_group_id]["messages"]
|
||||
album_messages.sort(key=lambda x: x.message_id)
|
||||
data["album"] = album_messages
|
||||
# Передаем объект-геттер в data, чтобы handler мог получить полную медиагруппу
|
||||
album_getter = AlbumGetter(
|
||||
self.album_data,
|
||||
media_group_id,
|
||||
self.album_data[media_group_id]["event"]
|
||||
)
|
||||
data["album_getter"] = album_getter
|
||||
|
||||
# Удаляем медиа группу из отслеживания для освобождения памяти
|
||||
del self.album_data[event.media_group_id]
|
||||
|
||||
# Вызываем оригинальный обработчик события
|
||||
# Сразу вызываем handler только для первого сообщения (не блокируем)
|
||||
return await handler(event, data)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import os
|
||||
import sys
|
||||
from typing import Optional
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from database.async_db import AsyncBotDB
|
||||
from helper_bot.utils.s3_storage import S3StorageService
|
||||
|
||||
|
||||
class BaseDependencyFactory:
|
||||
@@ -21,6 +23,7 @@ class BaseDependencyFactory:
|
||||
self.database = AsyncBotDB(database_path)
|
||||
|
||||
self._load_settings_from_env()
|
||||
self._init_s3_storage()
|
||||
|
||||
def _load_settings_from_env(self):
|
||||
"""Загружает настройки из переменных окружения."""
|
||||
@@ -48,6 +51,29 @@ class BaseDependencyFactory:
|
||||
'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')
|
||||
}
|
||||
|
||||
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:
|
||||
"""Парсит строковое значение в boolean."""
|
||||
return value.lower() in ('true', '1', 'yes', 'on')
|
||||
@@ -65,6 +91,10 @@ class BaseDependencyFactory:
|
||||
def get_db(self) -> AsyncBotDB:
|
||||
"""Возвращает подключение к базе данных."""
|
||||
return self.database
|
||||
|
||||
def get_s3_storage(self) -> Optional[S3StorageService]:
|
||||
"""Возвращает S3StorageService если S3 включен, иначе None."""
|
||||
return self.s3_storage
|
||||
|
||||
|
||||
_global_instance = None
|
||||
|
||||
@@ -2,6 +2,8 @@ import html
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
import tempfile
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from time import sleep
|
||||
from typing import List, Dict, Any, Optional, TYPE_CHECKING, Union
|
||||
@@ -158,17 +160,19 @@ def get_text_message(post_text: str, first_name: str, username: str = None, is_a
|
||||
@track_time("download_file", "helper_func")
|
||||
@track_errors("helper_func", "download_file")
|
||||
@track_file_operations("unknown")
|
||||
async def download_file(message: types.Message, file_id: str, content_type: str = None) -> Optional[str]:
|
||||
async def download_file(message: types.Message, file_id: str, content_type: str = None,
|
||||
s3_storage = None) -> Optional[str]:
|
||||
"""
|
||||
Скачивает файл по file_id из Telegram и сохраняет в соответствующую папку.
|
||||
Скачивает файл по file_id из Telegram и сохраняет в S3 или на локальный диск.
|
||||
|
||||
Args:
|
||||
message: сообщение
|
||||
file_id: File ID файла
|
||||
content_type: тип контента (photo, video, audio, voice, video_note)
|
||||
s3_storage: опциональный S3StorageService для сохранения в S3
|
||||
|
||||
Returns:
|
||||
Путь к сохраненному файлу, если файл был скачан успешно, иначе None
|
||||
S3 ключ (если s3_storage указан) или локальный путь к файлу, иначе None
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
@@ -178,51 +182,95 @@ async def download_file(message: types.Message, file_id: str, content_type: str
|
||||
logger.error("download_file: Неверные параметры - file_id, message или bot отсутствуют")
|
||||
return None
|
||||
|
||||
# Определяем папку по типу контента
|
||||
type_folders = {
|
||||
'photo': 'photos',
|
||||
'video': 'videos',
|
||||
'audio': 'music',
|
||||
'voice': 'voice',
|
||||
'video_note': 'video_notes'
|
||||
}
|
||||
|
||||
folder = type_folders.get(content_type, 'other')
|
||||
base_path = "files"
|
||||
full_folder_path = os.path.join(base_path, folder)
|
||||
|
||||
# Создаем необходимые папки
|
||||
os.makedirs(base_path, exist_ok=True)
|
||||
os.makedirs(full_folder_path, exist_ok=True)
|
||||
|
||||
logger.debug(f"download_file: Начинаю скачивание файла {file_id} типа {content_type} в папку {folder}")
|
||||
|
||||
# Получаем информацию о файле
|
||||
file = await message.bot.get_file(file_id)
|
||||
if not file or not file.file_path:
|
||||
logger.error(f"download_file: Не удалось получить информацию о файле {file_id}")
|
||||
return None
|
||||
|
||||
# Генерируем уникальное имя файла
|
||||
# Определяем расширение
|
||||
original_filename = os.path.basename(file.file_path)
|
||||
file_extension = os.path.splitext(original_filename)[1] or '.bin'
|
||||
safe_filename = f"{file_id}{file_extension}"
|
||||
file_path = os.path.join(full_folder_path, safe_filename)
|
||||
|
||||
# Скачиваем файл
|
||||
await message.bot.download_file(file_path=file.file_path, destination=file_path)
|
||||
|
||||
# Проверяем, что файл действительно скачался
|
||||
if not os.path.exists(file_path):
|
||||
logger.error(f"download_file: Файл не был скачан - {file_path}")
|
||||
return None
|
||||
|
||||
file_size = os.path.getsize(file_path)
|
||||
download_time = time.time() - start_time
|
||||
|
||||
logger.info(f"download_file: Файл успешно скачан - {file_path}, размер: {file_size} байт, время: {download_time:.2f}с")
|
||||
|
||||
return file_path
|
||||
if s3_storage:
|
||||
# Сохраняем в S3
|
||||
# Скачиваем во временный файл
|
||||
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=file_extension)
|
||||
temp_path = temp_file.name
|
||||
temp_file.close()
|
||||
|
||||
try:
|
||||
# Скачиваем из Telegram
|
||||
await message.bot.download_file(file_path=file.file_path, destination=temp_path)
|
||||
|
||||
# Генерируем S3 ключ
|
||||
s3_key = s3_storage.generate_s3_key(content_type, file_id)
|
||||
|
||||
# Загружаем в S3
|
||||
success = await s3_storage.upload_file(temp_path, s3_key)
|
||||
|
||||
# Удаляем временный файл
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except:
|
||||
pass
|
||||
|
||||
if success:
|
||||
file_size = file.file_size if hasattr(file, 'file_size') else 0
|
||||
download_time = time.time() - start_time
|
||||
logger.info(f"download_file: Файл загружен в S3 - {s3_key}, размер: {file_size} байт, время: {download_time:.2f}с")
|
||||
return s3_key
|
||||
else:
|
||||
logger.error(f"download_file: Не удалось загрузить файл в S3: {s3_key}")
|
||||
return None
|
||||
except Exception as e:
|
||||
# Удаляем временный файл при ошибке
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except:
|
||||
pass
|
||||
download_time = time.time() - start_time
|
||||
logger.error(f"download_file: Ошибка загрузки файла в S3 {file_id}: {e}, время: {download_time:.2f}с")
|
||||
return None
|
||||
else:
|
||||
# Старая логика - сохраняем на локальный диск
|
||||
# Определяем папку по типу контента
|
||||
type_folders = {
|
||||
'photo': 'photos',
|
||||
'video': 'videos',
|
||||
'audio': 'music',
|
||||
'voice': 'voice',
|
||||
'video_note': 'video_notes'
|
||||
}
|
||||
|
||||
folder = type_folders.get(content_type, 'other')
|
||||
base_path = "files"
|
||||
full_folder_path = os.path.join(base_path, folder)
|
||||
|
||||
# Создаем необходимые папки
|
||||
os.makedirs(base_path, exist_ok=True)
|
||||
os.makedirs(full_folder_path, exist_ok=True)
|
||||
|
||||
logger.debug(f"download_file: Начинаю скачивание файла {file_id} типа {content_type} в папку {folder}")
|
||||
|
||||
# Генерируем уникальное имя файла
|
||||
safe_filename = f"{file_id}{file_extension}"
|
||||
file_path = os.path.join(full_folder_path, safe_filename)
|
||||
|
||||
# Скачиваем файл
|
||||
await message.bot.download_file(file_path=file.file_path, destination=file_path)
|
||||
|
||||
# Проверяем, что файл действительно скачался
|
||||
if not os.path.exists(file_path):
|
||||
logger.error(f"download_file: Файл не был скачан - {file_path}")
|
||||
return None
|
||||
|
||||
file_size = os.path.getsize(file_path)
|
||||
download_time = time.time() - start_time
|
||||
|
||||
logger.info(f"download_file: Файл успешно скачан - {file_path}, размер: {file_size} байт, время: {download_time:.2f}с")
|
||||
|
||||
return file_path
|
||||
|
||||
except Exception as e:
|
||||
download_time = time.time() - start_time
|
||||
@@ -283,11 +331,21 @@ async def prepare_media_group_from_middlewares(album, post_caption: str = ''):
|
||||
|
||||
return media_group
|
||||
|
||||
async def _save_media_group_background(sent_message: List[types.Message], bot_db: Any, main_post_id: Optional[int], s3_storage) -> None:
|
||||
"""Сохраняет медиагруппу в фоне, чтобы не блокировать ответ пользователю"""
|
||||
try:
|
||||
success = await add_in_db_media_mediagroup(sent_message, bot_db, main_post_id, s3_storage)
|
||||
if not success:
|
||||
logger.warning(f"_save_media_group_background: Не удалось сохранить медиа для медиагруппы {sent_message[-1].message_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"_save_media_group_background: Ошибка при сохранении медиа для медиагруппы {sent_message[-1].message_id}: {e}")
|
||||
|
||||
@track_time("add_in_db_media_mediagroup", "helper_func")
|
||||
@track_errors("helper_func", "add_in_db_media_mediagroup")
|
||||
@track_media_processing("media_group")
|
||||
@db_query_time("add_in_db_media_mediagroup", "posts", "insert")
|
||||
async def add_in_db_media_mediagroup(sent_message: List[types.Message], bot_db: Any, main_post_id: Optional[int] = None) -> bool:
|
||||
async def add_in_db_media_mediagroup(sent_message: List[types.Message], bot_db: Any,
|
||||
main_post_id: Optional[int] = None, s3_storage = None) -> bool:
|
||||
"""
|
||||
Добавляет контент медиа-группы в базу данных
|
||||
|
||||
@@ -311,9 +369,7 @@ async def add_in_db_media_mediagroup(sent_message: List[types.Message], bot_db:
|
||||
logger.warning("add_in_db_media_mediagroup: Пустая медиагруппа")
|
||||
return False
|
||||
|
||||
# Используем переданный main_post_id или ID последнего сообщения
|
||||
post_id = main_post_id or sent_message[-1].message_id
|
||||
logger.debug(f"add_in_db_media_mediagroup: Обрабатываю медиагруппу из {len(sent_message)} сообщений, post_id: {post_id}")
|
||||
|
||||
processed_count = 0
|
||||
failed_count = 0
|
||||
@@ -323,7 +379,6 @@ async def add_in_db_media_mediagroup(sent_message: List[types.Message], bot_db:
|
||||
content_type = None
|
||||
file_id = None
|
||||
|
||||
# Определяем тип контента и file_id
|
||||
if message.photo:
|
||||
content_type = 'photo'
|
||||
file_id = message.photo[-1].file_id
|
||||
@@ -349,46 +404,40 @@ async def add_in_db_media_mediagroup(sent_message: List[types.Message], bot_db:
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
logger.debug(f"add_in_db_media_mediagroup: Обрабатываю {content_type} в сообщении {i+1}/{len(sent_message)}")
|
||||
if s3_storage is None:
|
||||
bdf = get_global_instance()
|
||||
s3_storage = bdf.get_s3_storage()
|
||||
|
||||
# Скачиваем файл
|
||||
file_path = await download_file(message, file_id=file_id, content_type=content_type)
|
||||
file_path = await download_file(message, file_id=file_id, content_type=content_type, s3_storage=s3_storage)
|
||||
if not file_path:
|
||||
logger.error(f"add_in_db_media_mediagroup: Не удалось скачать файл {file_id} в сообщении {i+1}/{len(sent_message)}")
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
# Добавляем в базу данных
|
||||
success = await bot_db.add_post_content(post_id, message.message_id, file_path, content_type)
|
||||
success = await bot_db.add_post_content(post_id, post_id, file_path, content_type)
|
||||
if not success:
|
||||
logger.error(f"add_in_db_media_mediagroup: Не удалось добавить контент в БД для сообщения {i+1}/{len(sent_message)}")
|
||||
# Удаляем скачанный файл при ошибке БД
|
||||
try:
|
||||
os.remove(file_path)
|
||||
logger.debug(f"add_in_db_media_mediagroup: Удален файл {file_path} после ошибки БД")
|
||||
except Exception as e:
|
||||
logger.warning(f"add_in_db_media_mediagroup: Не удалось удалить файл {file_path}: {e}")
|
||||
if file_path.startswith('files/'):
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except Exception as e:
|
||||
logger.warning(f"add_in_db_media_mediagroup: Не удалось удалить файл {file_path}: {e}")
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
processed_count += 1
|
||||
logger.debug(f"add_in_db_media_mediagroup: Успешно обработано сообщение {i+1}/{len(sent_message)}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"add_in_db_media_mediagroup: Ошибка обработки сообщения {i+1}/{len(sent_message)}: {e}")
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
processing_time = time.time() - start_time
|
||||
|
||||
if processed_count == 0:
|
||||
logger.error(f"add_in_db_media_mediagroup: Не удалось обработать ни одного сообщения из медиагруппы {post_id}")
|
||||
return False
|
||||
|
||||
if failed_count > 0:
|
||||
logger.warning(f"add_in_db_media_mediagroup: Обработано {processed_count}/{len(sent_message)} сообщений медиагруппы {post_id}, ошибок: {failed_count}")
|
||||
else:
|
||||
logger.info(f"add_in_db_media_mediagroup: Успешно обработана медиагруппа {post_id} - {processed_count} сообщений, время: {processing_time:.2f}с")
|
||||
|
||||
return failed_count == 0
|
||||
|
||||
@@ -402,7 +451,7 @@ async def add_in_db_media_mediagroup(sent_message: List[types.Message], bot_db:
|
||||
@track_media_processing("media_group")
|
||||
@db_query_time("add_in_db_media", "posts", "insert")
|
||||
@track_file_operations("media")
|
||||
async def add_in_db_media(sent_message: types.Message, bot_db: Any) -> bool:
|
||||
async def add_in_db_media(sent_message: types.Message, bot_db: Any, s3_storage = None) -> bool:
|
||||
"""
|
||||
Добавляет контент одиночного сообщения в базу данных
|
||||
|
||||
@@ -451,8 +500,13 @@ async def add_in_db_media(sent_message: types.Message, bot_db: Any) -> bool:
|
||||
|
||||
logger.debug(f"add_in_db_media: Обрабатываю {content_type} для сообщения {post_id}")
|
||||
|
||||
# Скачиваем файл
|
||||
file_path = await download_file(sent_message, file_id=file_id, content_type=content_type)
|
||||
# Получаем s3_storage если не передан
|
||||
if s3_storage is None:
|
||||
bdf = get_global_instance()
|
||||
s3_storage = bdf.get_s3_storage()
|
||||
|
||||
# Скачиваем файл (в S3 или на локальный диск)
|
||||
file_path = await download_file(sent_message, file_id=file_id, content_type=content_type, s3_storage=s3_storage)
|
||||
if not file_path:
|
||||
logger.error(f"add_in_db_media: Не удалось скачать файл {file_id} для сообщения {post_id}")
|
||||
return False
|
||||
@@ -461,12 +515,13 @@ async def add_in_db_media(sent_message: types.Message, bot_db: Any) -> bool:
|
||||
success = await bot_db.add_post_content(post_id, sent_message.message_id, file_path, content_type)
|
||||
if not success:
|
||||
logger.error(f"add_in_db_media: Не удалось добавить контент в БД для сообщения {post_id}")
|
||||
# Удаляем скачанный файл при ошибке БД
|
||||
try:
|
||||
os.remove(file_path)
|
||||
logger.debug(f"add_in_db_media: Удален файл {file_path} после ошибки БД")
|
||||
except Exception as e:
|
||||
logger.warning(f"add_in_db_media: Не удалось удалить файл {file_path}: {e}")
|
||||
# Удаляем скачанный файл при ошибке БД (только если это локальный файл, не S3)
|
||||
if file_path.startswith('files/'):
|
||||
try:
|
||||
os.remove(file_path)
|
||||
logger.debug(f"add_in_db_media: Удален файл {file_path} после ошибки БД")
|
||||
except Exception as e:
|
||||
logger.warning(f"add_in_db_media: Не удалось удалить файл {file_path}: {e}")
|
||||
return False
|
||||
|
||||
processing_time = time.time() - start_time
|
||||
@@ -484,74 +539,115 @@ async def add_in_db_media(sent_message: types.Message, bot_db: Any) -> bool:
|
||||
@track_media_processing("media_group")
|
||||
@db_query_time("send_media_group_message_to_private_chat", "posts", "insert")
|
||||
async def send_media_group_message_to_private_chat(chat_id: int, message: types.Message,
|
||||
media_group: List, bot_db: Any, main_post_id: Optional[int] = None) -> int:
|
||||
sent_message = await message.bot.send_media_group(
|
||||
media_group: List, bot_db: Any, main_post_id: Optional[int] = None, s3_storage=None) -> List[int]:
|
||||
"""
|
||||
Отправляет медиагруппу в чат и возвращает все message_id отправленных сообщений.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата для отправки
|
||||
message: Оригинальное сообщение от пользователя
|
||||
media_group: Список InputMedia объектов
|
||||
bot_db: Экземпляр базы данных
|
||||
main_post_id: ID основного поста в БД (опционально)
|
||||
s3_storage: S3StorageService для сохранения медиа
|
||||
|
||||
Returns:
|
||||
List[int]: Список всех message_id отправленных сообщений медиагруппы
|
||||
"""
|
||||
sent_messages = await message.bot.send_media_group(
|
||||
chat_id=chat_id,
|
||||
media=media_group,
|
||||
)
|
||||
post = TelegramPost(
|
||||
message_id=sent_message[-1].message_id,
|
||||
text=sent_message[-1].caption or "",
|
||||
author_id=message.from_user.id,
|
||||
created_at=int(datetime.now().timestamp())
|
||||
)
|
||||
await bot_db.add_post(post)
|
||||
success = await add_in_db_media_mediagroup(sent_message, bot_db, main_post_id)
|
||||
if not success:
|
||||
logger.warning(f"send_media_group_message_to_private_chat: Не удалось сохранить медиа для медиагруппы {sent_message[-1].message_id}")
|
||||
message_id = sent_message[-1].message_id
|
||||
return message_id
|
||||
|
||||
sent_message_ids = [msg.message_id for msg in sent_messages]
|
||||
main_message_id = sent_message_ids[-1]
|
||||
|
||||
asyncio.create_task(_save_media_group_background(sent_messages, bot_db, main_message_id, s3_storage))
|
||||
|
||||
return sent_message_ids
|
||||
|
||||
@track_time("send_media_group_to_channel", "helper_func")
|
||||
@track_errors("helper_func", "send_media_group_to_channel")
|
||||
@track_media_processing("media_group")
|
||||
async def send_media_group_to_channel(bot, chat_id: int, post_content: List, post_text: str):
|
||||
async def send_media_group_to_channel(bot, chat_id: int, post_content: List, post_text: str, s3_storage = None):
|
||||
"""
|
||||
Отправляет медиа-группу с подписью к последнему файлу.
|
||||
|
||||
Args:
|
||||
bot: Экземпляр бота aiogram.
|
||||
chat_id: ID чата для отправки.
|
||||
post_content: Список кортежей с путями к файлам.
|
||||
post_content: Список кортежей с путями к файлам (локальные пути или S3 ключи).
|
||||
post_text: Текст подписи.
|
||||
s3_storage: опциональный S3StorageService для работы с S3.
|
||||
"""
|
||||
logger.info(f"Начинаю отправку медиа-группы в чат {chat_id}, количество файлов: {len(post_content)}")
|
||||
|
||||
# Получаем s3_storage если не передан
|
||||
if s3_storage is None:
|
||||
bdf = get_global_instance()
|
||||
s3_storage = bdf.get_s3_storage()
|
||||
|
||||
media = []
|
||||
for i, file_path in enumerate(post_content):
|
||||
try:
|
||||
file = FSInputFile(path=file_path[0])
|
||||
type = file_path[1]
|
||||
logger.debug(f"Обрабатываю файл {i+1}/{len(post_content)}: {file_path[0]} (тип: {type})")
|
||||
|
||||
if type == 'video':
|
||||
media.append(types.InputMediaVideo(media=file))
|
||||
elif type == 'photo':
|
||||
media.append(types.InputMediaPhoto(media=file))
|
||||
else:
|
||||
logger.warning(f"Неизвестный тип файла: {type} для {file_path[0]}")
|
||||
except FileNotFoundError:
|
||||
logger.error(f"Файл не найден: {file_path[0]}")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке файла {file_path[0]}: {e}")
|
||||
return
|
||||
|
||||
logger.info(f"Подготовлено {len(media)} медиа-файлов для отправки")
|
||||
|
||||
# Добавляем подпись к последнему файлу
|
||||
if media:
|
||||
# Экранируем post_text для безопасного использования в HTML
|
||||
safe_post_text = html.escape(str(post_text)) if post_text else ""
|
||||
media[-1].caption = safe_post_text
|
||||
logger.debug(f"Добавлена подпись к последнему файлу: {safe_post_text[:50]}{'...' if len(safe_post_text) > 50 else ''}")
|
||||
|
||||
temp_files = [] # Для хранения путей к временным файлам
|
||||
|
||||
try:
|
||||
await bot.send_media_group(chat_id=chat_id, media=media)
|
||||
logger.info(f"Медиа-группа успешно отправлена в чат {chat_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке медиа-группы в чат {chat_id}: {e}")
|
||||
raise
|
||||
for i, file_path_tuple in enumerate(post_content):
|
||||
try:
|
||||
file_path, content_type = file_path_tuple
|
||||
logger.debug(f"Обрабатываю файл {i+1}/{len(post_content)}: {file_path} (тип: {content_type})")
|
||||
|
||||
# Проверяем, это S3 ключ или локальный путь
|
||||
actual_path = file_path
|
||||
if s3_storage and not file_path.startswith('files/') and not os.path.exists(file_path):
|
||||
# Это S3 ключ, скачиваем во временный файл
|
||||
temp_path = await s3_storage.download_to_temp(file_path)
|
||||
if not temp_path:
|
||||
logger.error(f"Не удалось скачать файл из S3: {file_path}")
|
||||
continue
|
||||
temp_files.append(temp_path)
|
||||
actual_path = temp_path
|
||||
elif not os.path.exists(file_path):
|
||||
logger.error(f"Файл не найден: {file_path}")
|
||||
continue
|
||||
|
||||
file = FSInputFile(path=actual_path)
|
||||
|
||||
if content_type == 'video':
|
||||
media.append(types.InputMediaVideo(media=file))
|
||||
elif content_type == 'photo':
|
||||
media.append(types.InputMediaPhoto(media=file))
|
||||
else:
|
||||
logger.warning(f"Неизвестный тип файла: {content_type} для {file_path}")
|
||||
except FileNotFoundError:
|
||||
logger.error(f"Файл не найден: {file_path_tuple[0]}")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке файла {file_path_tuple[0]}: {e}")
|
||||
continue
|
||||
|
||||
logger.info(f"Подготовлено {len(media)} медиа-файлов для отправки")
|
||||
|
||||
# Добавляем подпись к последнему файлу
|
||||
if media:
|
||||
# Экранируем post_text для безопасного использования в HTML
|
||||
safe_post_text = html.escape(str(post_text)) if post_text else ""
|
||||
media[-1].caption = safe_post_text
|
||||
logger.debug(f"Добавлена подпись к последнему файлу: {safe_post_text[:50]}{'...' if len(safe_post_text) > 50 else ''}")
|
||||
|
||||
try:
|
||||
sent_messages = await bot.send_media_group(chat_id=chat_id, media=media)
|
||||
logger.info(f"Медиа-группа успешно отправлена в чат {chat_id}, количество сообщений: {len(sent_messages)}")
|
||||
return sent_messages
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке медиа-группы в чат {chat_id}: {e}")
|
||||
raise
|
||||
finally:
|
||||
# Удаляем временные файлы
|
||||
for temp_file in temp_files:
|
||||
try:
|
||||
os.remove(temp_file)
|
||||
except:
|
||||
pass
|
||||
|
||||
@track_time("send_text_message", "helper_func")
|
||||
@track_errors("helper_func", "send_text_message")
|
||||
@@ -575,7 +671,7 @@ async def send_text_message(chat_id, message: types.Message, post_text: str, mar
|
||||
)
|
||||
|
||||
sent_message = await send_with_rate_limit(_send_message, chat_id)
|
||||
return sent_message.message_id
|
||||
return sent_message
|
||||
|
||||
@track_time("send_photo_message", "helper_func")
|
||||
@track_errors("helper_func", "send_photo_message")
|
||||
|
||||
175
helper_bot/utils/s3_storage.py
Normal file
175
helper_bot/utils/s3_storage.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
Сервис для работы с S3 хранилищем.
|
||||
"""
|
||||
import aioboto3
|
||||
import os
|
||||
import tempfile
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
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}"
|
||||
@@ -2,7 +2,7 @@
|
||||
name = "telegram-helper-bot"
|
||||
version = "1.0.0"
|
||||
description = "Telegram bot with monitoring and metrics"
|
||||
requires-python = ">=3.9"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
@@ -27,4 +27,7 @@ charset-normalizer>=3.0.0
|
||||
pluggy==1.5.0
|
||||
attrs~=23.2.0
|
||||
typing_extensions~=4.12.2
|
||||
emoji~=2.8.0
|
||||
emoji~=2.8.0
|
||||
|
||||
# S3 Storage (для хранения медиафайлов опубликованных постов)
|
||||
aioboto3>=12.0.0
|
||||
166
scripts/add_published_posts_support.py
Executable file
166
scripts/add_published_posts_support.py
Executable file
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Скрипт миграции для добавления поддержки опубликованных постов:
|
||||
1. Добавляет колонку published_message_id в таблицу post_from_telegram_suggest
|
||||
2. Создает таблицу published_post_content для хранения медиафайлов опубликованных постов
|
||||
3. Создает индексы для производительности
|
||||
"""
|
||||
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"
|
||||
|
||||
|
||||
def _column_exists(rows: list, name: str) -> bool:
|
||||
"""Проверяет существование колонки в таблице.
|
||||
PRAGMA table_info returns (cid, name, type, notnull, dflt_value, pk)."""
|
||||
for row in rows:
|
||||
if row[1] == name:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
|
||||
|
||||
async def main(db_path: str, dry_run: bool = False) -> None:
|
||||
"""Выполняет миграцию БД для поддержки опубликованных постов."""
|
||||
db_path = os.path.abspath(db_path)
|
||||
if not os.path.exists(db_path):
|
||||
logger.error("База данных не найдена: %s", db_path)
|
||||
print(f"Ошибка: база данных не найдена: {db_path}")
|
||||
return
|
||||
|
||||
async with aiosqlite.connect(db_path) as conn:
|
||||
await conn.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
changes_made = []
|
||||
|
||||
# 1. Проверяем и добавляем колонку published_message_id
|
||||
cursor = await conn.execute(
|
||||
"PRAGMA table_info(post_from_telegram_suggest)"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
await cursor.close()
|
||||
|
||||
if not _column_exists(rows, "published_message_id"):
|
||||
if dry_run:
|
||||
print("DRY RUN: Будет добавлена колонка published_message_id в post_from_telegram_suggest")
|
||||
changes_made.append("Добавление колонки published_message_id")
|
||||
else:
|
||||
logger.info("Добавление колонки published_message_id в post_from_telegram_suggest")
|
||||
await conn.execute(
|
||||
"ALTER TABLE post_from_telegram_suggest "
|
||||
"ADD COLUMN published_message_id INTEGER"
|
||||
)
|
||||
await conn.commit()
|
||||
print("✓ Колонка published_message_id добавлена в post_from_telegram_suggest")
|
||||
changes_made.append("Добавлена колонка published_message_id")
|
||||
else:
|
||||
print("✓ Колонка published_message_id уже существует в post_from_telegram_suggest")
|
||||
|
||||
# 2. Проверяем и создаем таблицу published_post_content
|
||||
cursor = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='published_post_content'"
|
||||
)
|
||||
table_exists = await cursor.fetchone()
|
||||
await cursor.close()
|
||||
|
||||
if not table_exists:
|
||||
if dry_run:
|
||||
print("DRY RUN: Будет создана таблица published_post_content")
|
||||
changes_made.append("Создание таблицы published_post_content")
|
||||
else:
|
||||
logger.info("Создание таблицы published_post_content")
|
||||
await conn.execute("""
|
||||
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 conn.commit()
|
||||
print("✓ Таблица published_post_content создана")
|
||||
changes_made.append("Создана таблица published_post_content")
|
||||
else:
|
||||
print("✓ Таблица published_post_content уже существует")
|
||||
|
||||
# 3. Проверяем и создаем индексы
|
||||
indexes = [
|
||||
("idx_published_post_content_message_id",
|
||||
"CREATE INDEX IF NOT EXISTS idx_published_post_content_message_id "
|
||||
"ON published_post_content(published_message_id)"),
|
||||
("idx_post_from_telegram_suggest_published",
|
||||
"CREATE INDEX IF NOT EXISTS idx_post_from_telegram_suggest_published "
|
||||
"ON post_from_telegram_suggest(published_message_id)")
|
||||
]
|
||||
|
||||
for index_name, index_sql in indexes:
|
||||
cursor = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND name=?",
|
||||
(index_name,)
|
||||
)
|
||||
index_exists = await cursor.fetchone()
|
||||
await cursor.close()
|
||||
|
||||
if not index_exists:
|
||||
if dry_run:
|
||||
print(f"DRY RUN: Будет создан индекс {index_name}")
|
||||
changes_made.append(f"Создание индекса {index_name}")
|
||||
else:
|
||||
logger.info(f"Создание индекса {index_name}")
|
||||
await conn.execute(index_sql)
|
||||
await conn.commit()
|
||||
print(f"✓ Индекс {index_name} создан")
|
||||
changes_made.append(f"Создан индекс {index_name}")
|
||||
else:
|
||||
print(f"✓ Индекс {index_name} уже существует")
|
||||
|
||||
# Финальная статистика
|
||||
if dry_run:
|
||||
if changes_made:
|
||||
print("\n" + "="*60)
|
||||
print("DRY RUN: Следующие изменения будут выполнены:")
|
||||
for change in changes_made:
|
||||
print(f" - {change}")
|
||||
print("="*60)
|
||||
else:
|
||||
print("\n✓ Все необходимые изменения уже применены. Ничего делать не нужно.")
|
||||
else:
|
||||
if changes_made:
|
||||
logger.info(f"Миграция завершена. Выполнено изменений: {len(changes_made)}")
|
||||
print(f"\n✓ Миграция завершена успешно!")
|
||||
print(f"Выполнено изменений: {len(changes_made)}")
|
||||
for change in changes_made:
|
||||
print(f" - {change}")
|
||||
else:
|
||||
print("\n✓ Все необходимые изменения уже применены. Ничего делать не нужно.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Добавление поддержки опубликованных постов в БД"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--db",
|
||||
default=os.environ.get("DB_PATH", DEFAULT_DB_PATH),
|
||||
help="Путь к БД (или переменная окружения DB_PATH)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Показать что будет сделано без выполнения изменений",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
asyncio.run(main(args.db, dry_run=args.dry_run))
|
||||
144
scripts/test_s3_connection.py
Executable file
144
scripts/test_s3_connection.py
Executable file
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Скрипт для проверки подключения к S3 хранилищу.
|
||||
Читает настройки из .env файла или переменных окружения.
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
# Загружаем .env файл
|
||||
from dotenv import load_dotenv
|
||||
env_path = os.path.join(project_root, '.env')
|
||||
if os.path.exists(env_path):
|
||||
load_dotenv(env_path)
|
||||
|
||||
try:
|
||||
import aioboto3
|
||||
except ImportError:
|
||||
print("❌ Библиотека aioboto3 не установлена.")
|
||||
print("Установите её командой: pip install aioboto3")
|
||||
sys.exit(1)
|
||||
|
||||
# Данные для подключения из .env или переменных окружения
|
||||
S3_ACCESS_KEY = os.getenv('S3_ACCESS_KEY', 'j3tears100@gmail.com')
|
||||
S3_SECRET_KEY = os.getenv('S3_SECRET_KEY', 'wQ1-6sZEPs92sbZTSf96')
|
||||
S3_ENDPOINT_URL = os.getenv('S3_ENDPOINT_URL', 'https://api.s3.miran.ru:443')
|
||||
S3_BUCKET_NAME = os.getenv('S3_BUCKET_NAME', 'telegram-helper-bot')
|
||||
S3_REGION = os.getenv('S3_REGION', 'us-east-1')
|
||||
|
||||
async def test_s3_connection():
|
||||
"""Тестирует подключение к S3 хранилищу."""
|
||||
print("🔍 Тестирование подключения к S3 хранилищу...")
|
||||
print(f"Endpoint: {S3_ENDPOINT_URL}")
|
||||
print(f"Bucket: {S3_BUCKET_NAME}")
|
||||
print(f"Region: {S3_REGION}")
|
||||
print(f"Access Key: {S3_ACCESS_KEY}")
|
||||
print()
|
||||
|
||||
session = aioboto3.Session()
|
||||
|
||||
try:
|
||||
async with session.client(
|
||||
's3',
|
||||
endpoint_url=S3_ENDPOINT_URL,
|
||||
aws_access_key_id=S3_ACCESS_KEY,
|
||||
aws_secret_access_key=S3_SECRET_KEY,
|
||||
region_name=S3_REGION
|
||||
) as s3:
|
||||
# Пытаемся получить список бакетов (может не иметь прав, пропускаем если ошибка)
|
||||
print("📦 Получение списка бакетов...")
|
||||
try:
|
||||
response = await s3.list_buckets()
|
||||
buckets = response.get('Buckets', [])
|
||||
print(f"✅ Подключение успешно! Найдено бакетов: {len(buckets)}")
|
||||
|
||||
if buckets:
|
||||
print("\n📋 Список бакетов:")
|
||||
for bucket in buckets:
|
||||
print(f" - {bucket['Name']} (создан: {bucket.get('CreationDate', 'неизвестно')})")
|
||||
else:
|
||||
print("\n⚠️ Бакеты не найдены.")
|
||||
except Exception as list_error:
|
||||
print(f"⚠️ Не удалось получить список бакетов: {list_error}")
|
||||
print(" Это нормально, если нет прав на list_buckets")
|
||||
print(" Продолжаем тестирование с указанным бакетом...")
|
||||
|
||||
# Пытаемся создать тестовый файл в указанном бакете
|
||||
print("\n🧪 Тестирование записи файла...")
|
||||
# Используем первый найденный бакет, если указанный не найден
|
||||
test_bucket = S3_BUCKET_NAME
|
||||
if buckets:
|
||||
# Проверяем, есть ли указанный бакет в списке
|
||||
bucket_names = [b['Name'] for b in buckets]
|
||||
if test_bucket not in bucket_names:
|
||||
print(f"⚠️ Бакет '{test_bucket}' не найден в списке.")
|
||||
print(f" Используем первый найденный бакет: '{buckets[0]['Name']}'")
|
||||
test_bucket = buckets[0]['Name']
|
||||
|
||||
test_key = 'test-connection.txt'
|
||||
test_content = b'Test connection to S3 storage'
|
||||
|
||||
try:
|
||||
# Проверяем существование бакета
|
||||
try:
|
||||
await s3.head_bucket(Bucket=test_bucket)
|
||||
print(f"✅ Бакет '{test_bucket}' существует и доступен")
|
||||
except Exception as head_error:
|
||||
print(f"❌ Бакет '{test_bucket}' недоступен: {head_error}")
|
||||
print(" Проверьте права доступа к бакету")
|
||||
return False
|
||||
|
||||
await s3.put_object(
|
||||
Bucket=test_bucket,
|
||||
Key=test_key,
|
||||
Body=test_content
|
||||
)
|
||||
print(f"✅ Файл успешно записан в бакет '{test_bucket}' с ключом '{test_key}'")
|
||||
|
||||
# Пытаемся прочитать файл
|
||||
print("🧪 Тестирование чтения файла...")
|
||||
response = await s3.get_object(Bucket=test_bucket, Key=test_key)
|
||||
content = await response['Body'].read()
|
||||
|
||||
if content == test_content:
|
||||
print("✅ Файл успешно прочитан, содержимое совпадает")
|
||||
else:
|
||||
print("⚠️ Файл прочитан, но содержимое не совпадает")
|
||||
|
||||
# Удаляем тестовый файл
|
||||
print("🧹 Удаление тестового файла...")
|
||||
await s3.delete_object(Bucket=test_bucket, Key=test_key)
|
||||
print("✅ Тестовый файл удален")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка при тестировании записи/чтения: {e}")
|
||||
print(f" Тип ошибки: {type(e).__name__}")
|
||||
import traceback
|
||||
print(f" Полный traceback:")
|
||||
traceback.print_exc()
|
||||
print("\nВозможные причины:")
|
||||
print(" 1. Неверное имя бакета")
|
||||
print(" 2. Нет прав на запись в бакет")
|
||||
print(" 3. Неверный endpoint URL или регион")
|
||||
print(" 4. Проблемы с форматом endpoint (попробуйте без :443)")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка подключения к S3: {e}")
|
||||
print("\nВозможные причины:")
|
||||
print(" 1. Неверные credentials (Access Key / Secret Key)")
|
||||
print(" 2. Неверный endpoint URL")
|
||||
print(" 3. Проблемы с сетью")
|
||||
print(" 4. Неверный регион (попробуйте изменить region_name)")
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = asyncio.run(test_s3_connection())
|
||||
sys.exit(0 if result else 1)
|
||||
@@ -1,150 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Скрипт для тестирования rate limiting решения
|
||||
"""
|
||||
import asyncio
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from aiogram.types import Message, User, Chat
|
||||
|
||||
from helper_bot.utils.rate_limiter import send_with_rate_limit
|
||||
from helper_bot.utils.rate_limit_monitor import rate_limit_monitor, get_rate_limit_summary
|
||||
|
||||
|
||||
async def test_rate_limiting():
|
||||
"""Тестирует rate limiting с имитацией отправки сообщений"""
|
||||
|
||||
print("🚀 Начинаем тестирование rate limiting...")
|
||||
|
||||
# Создаем мок объекты
|
||||
mock_bot = MagicMock()
|
||||
mock_user = User(id=123, is_bot=False, first_name="Test")
|
||||
mock_chat = Chat(id=456, type="private")
|
||||
|
||||
# Создаем Message с bot в конструкторе
|
||||
mock_message = Message(
|
||||
message_id=1,
|
||||
date=int(time.time()),
|
||||
chat=mock_chat,
|
||||
from_user=mock_user,
|
||||
content_type="text",
|
||||
bot=mock_bot
|
||||
)
|
||||
|
||||
# Настраиваем мок для send_voice
|
||||
mock_bot.send_voice = AsyncMock(return_value=MagicMock(message_id=1))
|
||||
|
||||
# Функция для отправки голосового сообщения
|
||||
async def send_voice_test():
|
||||
return await mock_bot.send_voice(
|
||||
chat_id=mock_chat.id,
|
||||
voice="test_voice_id"
|
||||
)
|
||||
|
||||
print("📊 Отправляем 5 сообщений подряд...")
|
||||
|
||||
# Отправляем несколько сообщений подряд
|
||||
start_time = time.time()
|
||||
for i in range(5):
|
||||
print(f" Отправка сообщения {i+1}/5...")
|
||||
try:
|
||||
result = await send_with_rate_limit(send_voice_test, mock_chat.id)
|
||||
print(f" ✅ Сообщение {i+1} отправлено успешно")
|
||||
except Exception as e:
|
||||
print(f" ❌ Ошибка при отправке сообщения {i+1}: {e}")
|
||||
|
||||
end_time = time.time()
|
||||
total_time = end_time - start_time
|
||||
|
||||
print(f"\n⏱️ Общее время выполнения: {total_time:.2f} секунд")
|
||||
print(f"📈 Среднее время на сообщение: {total_time/5:.2f} секунд")
|
||||
|
||||
# Показываем статистику
|
||||
print("\n📊 Статистика rate limiting:")
|
||||
summary = get_rate_limit_summary()
|
||||
for key, value in summary.items():
|
||||
if isinstance(value, float):
|
||||
print(f" {key}: {value:.2f}")
|
||||
else:
|
||||
print(f" {key}: {value}")
|
||||
|
||||
# Показываем детальную статистику
|
||||
print("\n🔍 Детальная статистика:")
|
||||
global_stats = rate_limit_monitor.get_global_stats()
|
||||
print(f" Всего запросов: {global_stats.total_requests}")
|
||||
print(f" Успешных: {global_stats.successful_requests}")
|
||||
print(f" Неудачных: {global_stats.failed_requests}")
|
||||
print(f" Процент успеха: {global_stats.success_rate:.1%}")
|
||||
print(f" Среднее время ожидания: {global_stats.average_wait_time:.2f}с")
|
||||
|
||||
# Проверяем что rate limiting работает
|
||||
if total_time > 8: # Должно занять больше 8 секунд (5 сообщений * 1.6с минимум)
|
||||
print("\n✅ Rate limiting работает корректно - сообщения отправляются с задержкой")
|
||||
else:
|
||||
print("\n⚠️ Rate limiting может работать некорректно - сообщения отправлены слишком быстро")
|
||||
|
||||
print("\n🎉 Тестирование завершено!")
|
||||
|
||||
|
||||
async def test_error_handling():
|
||||
"""Тестирует обработку ошибок"""
|
||||
|
||||
print("\n🧪 Тестируем обработку ошибок...")
|
||||
|
||||
# Создаем мок который будет падать с RetryAfter
|
||||
from aiogram.exceptions import TelegramRetryAfter
|
||||
|
||||
mock_bot = MagicMock()
|
||||
mock_chat = Chat(id=789, type="private")
|
||||
|
||||
call_count = 0
|
||||
async def failing_send():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count <= 2:
|
||||
raise TelegramRetryAfter(
|
||||
method=MagicMock(),
|
||||
message="Flood control exceeded",
|
||||
retry_after=1
|
||||
)
|
||||
return MagicMock(message_id=call_count)
|
||||
|
||||
mock_bot.send_voice = failing_send
|
||||
|
||||
print("📤 Отправляем сообщение с имитацией RetryAfter ошибки...")
|
||||
|
||||
start_time = time.time()
|
||||
try:
|
||||
result = await send_with_rate_limit(failing_send, mock_chat.id)
|
||||
end_time = time.time()
|
||||
print(f"✅ Сообщение отправлено после {call_count} попыток за {end_time - start_time:.2f}с")
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка: {e}")
|
||||
|
||||
print("🎯 Тест обработки ошибок завершен!")
|
||||
|
||||
|
||||
async def main():
|
||||
"""Основная функция"""
|
||||
print("🔧 Тестирование решения Flood Control")
|
||||
print("=" * 50)
|
||||
|
||||
# Сбрасываем статистику
|
||||
rate_limit_monitor.reset_stats()
|
||||
|
||||
# Запускаем тесты
|
||||
await test_rate_limiting()
|
||||
await test_error_handling()
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print("📋 Итоговая статистика:")
|
||||
summary = get_rate_limit_summary()
|
||||
for key, value in summary.items():
|
||||
if isinstance(value, float):
|
||||
print(f" {key}: {value:.2f}")
|
||||
else:
|
||||
print(f" {key}: {value}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -239,7 +239,10 @@ class TestBlacklistRepository:
|
||||
blacklist_repository._execute_query_with_result.assert_called_once()
|
||||
call_args = blacklist_repository._execute_query_with_result.call_args
|
||||
|
||||
assert call_args[0][0] == "SELECT user_id, message_for_user, date_to_unban, created_at FROM blacklist LIMIT ?, ?"
|
||||
# Нормализуем SQL запрос (убираем лишние пробелы и переносы строк)
|
||||
actual_query = ' '.join(call_args[0][0].split())
|
||||
expected_query = "SELECT user_id, message_for_user, date_to_unban, created_at, ban_author FROM blacklist LIMIT ?, ?"
|
||||
assert actual_query == expected_query
|
||||
assert call_args[0][1] == (0, 10)
|
||||
|
||||
# Проверяем логирование
|
||||
@@ -250,10 +253,10 @@ class TestBlacklistRepository:
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_users_no_limit(self, blacklist_repository):
|
||||
"""Тест получения всех пользователей без лимитов"""
|
||||
# Симулируем результат запроса
|
||||
# Симулируем результат запроса (теперь включает ban_author)
|
||||
mock_rows = [
|
||||
(12345, "Нарушение правил", int(time.time()) + 86400, int(time.time())),
|
||||
(67890, "Постоянный бан", None, int(time.time()) - 86400)
|
||||
(12345, "Нарушение правил", int(time.time()) + 86400, int(time.time()), 999),
|
||||
(67890, "Постоянный бан", None, int(time.time()) - 86400, None)
|
||||
]
|
||||
blacklist_repository._execute_query_with_result.return_value = mock_rows
|
||||
|
||||
@@ -266,7 +269,10 @@ class TestBlacklistRepository:
|
||||
blacklist_repository._execute_query_with_result.assert_called_once()
|
||||
call_args = blacklist_repository._execute_query_with_result.call_args
|
||||
|
||||
assert call_args[0][0] == "SELECT user_id, message_for_user, date_to_unban, created_at FROM blacklist"
|
||||
# Нормализуем SQL запрос (убираем лишние пробелы и переносы строк)
|
||||
actual_query = ' '.join(call_args[0][0].split())
|
||||
expected_query = "SELECT user_id, message_for_user, date_to_unban, created_at, ban_author FROM blacklist"
|
||||
assert actual_query == expected_query
|
||||
# Проверяем, что параметры пустые (без лимитов)
|
||||
assert len(call_args[0]) == 1 # Только SQL запрос, без параметров
|
||||
|
||||
|
||||
@@ -258,16 +258,15 @@ class TestSendMediaGroupMessageToPrivateChat:
|
||||
|
||||
# Мокаем БД
|
||||
mock_db = AsyncMock()
|
||||
mock_db.add_post = AsyncMock()
|
||||
|
||||
with patch('helper_bot.utils.helper_func.add_in_db_media_mediagroup', return_value=True):
|
||||
result = await send_media_group_message_to_private_chat(
|
||||
100, mock_message, [], mock_db, main_post_id=789
|
||||
)
|
||||
|
||||
assert result == 456
|
||||
mock_message.bot.send_media_group.assert_called_once()
|
||||
mock_db.add_post.assert_called_once()
|
||||
with patch('asyncio.create_task'): # Мокаем create_task, чтобы фоновая задача не выполнялась
|
||||
result = await send_media_group_message_to_private_chat(
|
||||
100, mock_message, [], mock_db, main_post_id=789
|
||||
)
|
||||
|
||||
assert result == [456] # Функция возвращает список message_id
|
||||
mock_message.bot.send_media_group.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_media_group_message_media_processing_fails(self):
|
||||
@@ -285,16 +284,15 @@ class TestSendMediaGroupMessageToPrivateChat:
|
||||
|
||||
# Мокаем БД
|
||||
mock_db = AsyncMock()
|
||||
mock_db.add_post = AsyncMock()
|
||||
|
||||
with patch('helper_bot.utils.helper_func.add_in_db_media_mediagroup', return_value=False):
|
||||
result = await send_media_group_message_to_private_chat(
|
||||
100, mock_message, [], mock_db, main_post_id=789
|
||||
)
|
||||
|
||||
assert result == 456 # Функция все равно возвращает message_id
|
||||
mock_message.bot.send_media_group.assert_called_once()
|
||||
mock_db.add_post.assert_called_once()
|
||||
with patch('asyncio.create_task'): # Мокаем create_task, чтобы фоновая задача не выполнялась
|
||||
result = await send_media_group_message_to_private_chat(
|
||||
100, mock_message, [], mock_db, main_post_id=789
|
||||
)
|
||||
|
||||
assert result == [456] # Функция возвращает список message_id
|
||||
mock_message.bot.send_media_group.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -62,33 +62,38 @@ class TestPostRepository:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_tables(self, post_repository):
|
||||
"""Тест создания таблиц."""
|
||||
# Мокаем _execute_query
|
||||
# Мокаем _execute_query и _execute_query_with_result
|
||||
post_repository._execute_query = AsyncMock()
|
||||
post_repository._execute_query_with_result = AsyncMock(return_value=[]) # Для проверки столбца
|
||||
|
||||
await post_repository.create_tables()
|
||||
|
||||
# Проверяем, что create_tables вызвался 3 раза (для каждой таблицы)
|
||||
assert post_repository._execute_query.call_count == 3
|
||||
# Проверяем, что create_tables вызвался минимум 3 раза (для каждой таблицы)
|
||||
# Может быть больше из-за ALTER TABLE и индексов
|
||||
assert post_repository._execute_query.call_count >= 3
|
||||
|
||||
# Проверяем, что все нужные таблицы созданы (порядок может быть разным из-за ALTER TABLE)
|
||||
calls = post_repository._execute_query.call_args_list
|
||||
all_queries = [call[0][0] for call in calls]
|
||||
|
||||
# Проверяем создание таблицы постов
|
||||
calls = post_repository._execute_query.call_args_list
|
||||
post_table_call = calls[0][0][0]
|
||||
assert "CREATE TABLE IF NOT EXISTS post_from_telegram_suggest" in post_table_call
|
||||
assert "message_id INTEGER NOT NULL PRIMARY KEY" in post_table_call
|
||||
assert "created_at INTEGER NOT NULL" in post_table_call
|
||||
assert "status TEXT NOT NULL DEFAULT 'suggest'" in post_table_call
|
||||
assert "is_anonymous INTEGER" in post_table_call
|
||||
assert "FOREIGN KEY (author_id) REFERENCES our_users (user_id) ON DELETE CASCADE" in post_table_call
|
||||
post_table_queries = [q for q in all_queries if "CREATE TABLE IF NOT EXISTS post_from_telegram_suggest" in q]
|
||||
assert len(post_table_queries) > 0
|
||||
assert "message_id INTEGER NOT NULL PRIMARY KEY" in post_table_queries[0]
|
||||
assert "created_at INTEGER NOT NULL" in post_table_queries[0]
|
||||
assert "status TEXT NOT NULL DEFAULT 'suggest'" in post_table_queries[0]
|
||||
assert "is_anonymous INTEGER" in post_table_queries[0]
|
||||
assert "FOREIGN KEY (author_id) REFERENCES our_users (user_id) ON DELETE CASCADE" in post_table_queries[0]
|
||||
|
||||
# Проверяем создание таблицы контента
|
||||
content_table_call = calls[1][0][0]
|
||||
assert "CREATE TABLE IF NOT EXISTS content_post_from_telegram" in content_table_call
|
||||
assert "PRIMARY KEY (message_id, content_name)" in content_table_call
|
||||
content_table_queries = [q for q in all_queries if "CREATE TABLE IF NOT EXISTS content_post_from_telegram" in q]
|
||||
assert len(content_table_queries) > 0
|
||||
assert "PRIMARY KEY (message_id, content_name)" in content_table_queries[0]
|
||||
|
||||
# Проверяем создание таблицы связей
|
||||
link_table_call = calls[2][0][0]
|
||||
assert "CREATE TABLE IF NOT EXISTS message_link_to_content" in link_table_call
|
||||
assert "PRIMARY KEY (post_id, message_id)" in link_table_call
|
||||
link_table_queries = [q for q in all_queries if "CREATE TABLE IF NOT EXISTS message_link_to_content" in q]
|
||||
assert len(link_table_queries) > 0
|
||||
assert "PRIMARY KEY (post_id, message_id)" in link_table_queries[0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_post_with_date(self, post_repository, sample_post):
|
||||
@@ -103,7 +108,7 @@ class TestPostRepository:
|
||||
query = call_args[0][0]
|
||||
params = call_args[0][1]
|
||||
|
||||
assert "INSERT INTO post_from_telegram_suggest" in query
|
||||
assert "INSERT OR IGNORE INTO post_from_telegram_suggest" in query
|
||||
assert "status" in query
|
||||
assert "is_anonymous" in query
|
||||
assert "VALUES (?, ?, ?, ?, ?, ?)" in query
|
||||
@@ -148,9 +153,11 @@ class TestPostRepository:
|
||||
|
||||
await post_repository.add_post(sample_post)
|
||||
|
||||
post_repository.logger.info.assert_called_once_with(
|
||||
f"Пост добавлен: message_id={sample_post.message_id}"
|
||||
)
|
||||
# Проверяем, что логирование вызвано с новым форматом сообщения
|
||||
post_repository.logger.info.assert_called_once()
|
||||
log_call = post_repository.logger.info.call_args[0][0]
|
||||
assert f"message_id={sample_post.message_id}" in log_call
|
||||
assert "Пост добавлен" in log_call or "уже существует" in log_call
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_helper_message(self, post_repository):
|
||||
@@ -174,29 +181,61 @@ class TestPostRepository:
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_status_by_message_id(self, post_repository):
|
||||
"""Тест обновления статуса поста по message_id."""
|
||||
# Создаем таблицы перед тестом
|
||||
post_repository._execute_query = AsyncMock()
|
||||
post_repository._execute_query_with_result = AsyncMock(return_value=[])
|
||||
post_repository._get_connection = AsyncMock()
|
||||
mock_conn = AsyncMock()
|
||||
mock_cur = AsyncMock()
|
||||
mock_cur.fetchone = AsyncMock(return_value=(1,)) # 1 строка обновлена
|
||||
mock_conn.execute = AsyncMock(return_value=mock_cur)
|
||||
post_repository._get_connection.return_value = mock_conn
|
||||
post_repository.logger = MagicMock()
|
||||
|
||||
# Создаем таблицы
|
||||
await post_repository.create_tables()
|
||||
post_repository._execute_query.reset_mock()
|
||||
post_repository._execute_query_with_result.reset_mock()
|
||||
post_repository.logger.info.reset_mock() # Сбрасываем счетчик логирования
|
||||
|
||||
message_id = 12345
|
||||
status = "approved"
|
||||
|
||||
await post_repository.update_status_by_message_id(message_id, status)
|
||||
|
||||
post_repository._execute_query.assert_called_once()
|
||||
call_args = post_repository._execute_query.call_args
|
||||
query = call_args[0][0]
|
||||
params = call_args[0][1]
|
||||
# Проверяем, что conn.execute был вызван с правильными параметрами
|
||||
assert mock_conn.execute.call_count >= 1
|
||||
update_call = mock_conn.execute.call_args_list[0]
|
||||
query = update_call[0][0]
|
||||
params = update_call[0][1]
|
||||
|
||||
assert "UPDATE post_from_telegram_suggest" in query
|
||||
assert "SET status = ? WHERE message_id = ?" in query
|
||||
assert params == (status, message_id)
|
||||
post_repository.logger.info.assert_called_once()
|
||||
# Проверяем, что после создания таблиц было вызвано логирование обновления статуса
|
||||
post_repository.logger.info.assert_called()
|
||||
log_calls = [str(call) for call in post_repository.logger.info.call_args_list]
|
||||
assert any("Статус поста message_id=12345 обновлён на approved" in str(call) for call in post_repository.logger.info.call_args_list)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_status_for_media_group_by_helper_id(self, post_repository):
|
||||
"""Тест обновления статуса медиагруппы по helper_message_id."""
|
||||
# Создаем таблицы перед тестом
|
||||
post_repository._execute_query = AsyncMock()
|
||||
post_repository._execute_query_with_result = AsyncMock(return_value=[])
|
||||
post_repository._get_connection = AsyncMock()
|
||||
mock_conn = AsyncMock()
|
||||
mock_cur = AsyncMock()
|
||||
mock_cur.fetchone = AsyncMock(return_value=(1,)) # 1 строка обновлена
|
||||
mock_conn.execute = AsyncMock(return_value=mock_cur)
|
||||
post_repository._get_connection.return_value = mock_conn
|
||||
post_repository.logger = MagicMock()
|
||||
|
||||
# Создаем таблицы
|
||||
await post_repository.create_tables()
|
||||
post_repository._execute_query.reset_mock()
|
||||
post_repository._execute_query_with_result.reset_mock()
|
||||
post_repository.logger.info.reset_mock() # Сбрасываем счетчик логирования
|
||||
|
||||
helper_message_id = 99999
|
||||
status = "declined"
|
||||
@@ -205,16 +244,19 @@ class TestPostRepository:
|
||||
helper_message_id, status
|
||||
)
|
||||
|
||||
post_repository._execute_query.assert_called_once()
|
||||
call_args = post_repository._execute_query.call_args
|
||||
query = call_args[0][0]
|
||||
params = call_args[0][1]
|
||||
# Проверяем, что conn.execute был вызван с правильными параметрами
|
||||
assert mock_conn.execute.call_count >= 1
|
||||
update_call = mock_conn.execute.call_args_list[0]
|
||||
query = update_call[0][0]
|
||||
params = update_call[0][1]
|
||||
|
||||
assert "UPDATE post_from_telegram_suggest" in query
|
||||
assert "SET status = ?" in query
|
||||
assert "message_id = ? OR helper_text_message_id = ?" in query
|
||||
assert params == (status, helper_message_id, helper_message_id)
|
||||
post_repository.logger.info.assert_called_once()
|
||||
# Проверяем, что после создания таблиц было вызвано логирование обновления статуса
|
||||
post_repository.logger.info.assert_called()
|
||||
assert any("Статус медиагруппы helper_message_id=99999 обновлён на declined" in str(call) for call in post_repository.logger.info.call_args_list)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_post_content_success(self, post_repository):
|
||||
@@ -648,10 +690,12 @@ class TestPostRepository:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_tables_logs_success(self, post_repository):
|
||||
"""Тест логирования успешного создания таблиц."""
|
||||
# Мокаем _execute_query и logger
|
||||
# Мокаем _execute_query, _execute_query_with_result и logger
|
||||
post_repository._execute_query = AsyncMock()
|
||||
post_repository._execute_query_with_result = AsyncMock(return_value=[]) # Для проверки столбца
|
||||
post_repository.logger = MagicMock()
|
||||
|
||||
await post_repository.create_tables()
|
||||
|
||||
post_repository.logger.info.assert_called_once_with("Таблицы для постов созданы")
|
||||
# Проверяем, что финальное сообщение о создании таблиц было вызвано
|
||||
post_repository.logger.info.assert_any_call("Таблицы для постов созданы")
|
||||
|
||||
@@ -19,6 +19,7 @@ class TestPostService:
|
||||
db.add_post = AsyncMock()
|
||||
db.update_helper_message = AsyncMock()
|
||||
db.get_user_by_id = AsyncMock()
|
||||
db.add_message_link = AsyncMock()
|
||||
return db
|
||||
|
||||
@pytest.fixture
|
||||
@@ -60,8 +61,11 @@ class TestPostService:
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_text_post_saves_raw_text(self, post_service, mock_message, mock_db):
|
||||
"""Test that handle_text_post saves raw text to database"""
|
||||
mock_sent_message = Mock()
|
||||
mock_sent_message.message_id = 200
|
||||
|
||||
with patch('helper_bot.handlers.private.services.get_text_message', return_value="Formatted text"):
|
||||
with patch('helper_bot.handlers.private.services.send_text_message', return_value=200):
|
||||
with patch('helper_bot.handlers.private.services.send_text_message', return_value=mock_sent_message):
|
||||
with patch('helper_bot.handlers.private.services.get_first_name', return_value="Test"):
|
||||
with patch('helper_bot.handlers.private.services.get_reply_keyboard_for_post', return_value=None):
|
||||
with patch('helper_bot.handlers.private.services.determine_anonymity', return_value=False):
|
||||
@@ -83,9 +87,11 @@ class TestPostService:
|
||||
async def test_handle_text_post_determines_anonymity(self, post_service, mock_message, mock_db):
|
||||
"""Test that handle_text_post determines anonymity correctly"""
|
||||
mock_message.text = "Тестовый пост анон"
|
||||
mock_sent_message = Mock()
|
||||
mock_sent_message.message_id = 200
|
||||
|
||||
with patch('helper_bot.handlers.private.services.get_text_message', return_value="Formatted text"):
|
||||
with patch('helper_bot.handlers.private.services.send_text_message', return_value=200):
|
||||
with patch('helper_bot.handlers.private.services.send_text_message', return_value=mock_sent_message):
|
||||
with patch('helper_bot.handlers.private.services.get_first_name', return_value="Test"):
|
||||
with patch('helper_bot.handlers.private.services.get_reply_keyboard_for_post', return_value=None):
|
||||
with patch('helper_bot.handlers.private.services.determine_anonymity', return_value=True):
|
||||
@@ -241,14 +247,17 @@ class TestPostService:
|
||||
album = [Mock()]
|
||||
album[0].caption = "Медиагруппа подпись"
|
||||
|
||||
mock_helper_message = Mock()
|
||||
mock_helper_message.message_id = 302
|
||||
|
||||
with patch('helper_bot.handlers.private.services.get_text_message', return_value="Formatted"):
|
||||
with patch('helper_bot.handlers.private.services.prepare_media_group_from_middlewares', return_value=[]):
|
||||
with patch('helper_bot.handlers.private.services.send_media_group_message_to_private_chat', return_value=301):
|
||||
with patch('helper_bot.handlers.private.services.send_media_group_message_to_private_chat', return_value=[301]):
|
||||
with patch('helper_bot.handlers.private.services.get_first_name', return_value="Test"):
|
||||
with patch('helper_bot.handlers.private.services.get_reply_keyboard_for_post', return_value=None):
|
||||
with patch('helper_bot.handlers.private.services.send_text_message', return_value=302):
|
||||
with patch('helper_bot.handlers.private.services.send_text_message', return_value=mock_helper_message):
|
||||
with patch('helper_bot.handlers.private.services.determine_anonymity', return_value=True):
|
||||
with patch('asyncio.sleep', return_value=None):
|
||||
with patch('asyncio.sleep', new_callable=AsyncMock):
|
||||
|
||||
await post_service.handle_media_group_post(mock_message, album, "Test")
|
||||
|
||||
@@ -257,7 +266,7 @@ class TestPostService:
|
||||
main_post = calls[0][0][0]
|
||||
|
||||
assert main_post.text == "Медиагруппа подпись" # Raw caption
|
||||
assert main_post.message_id == 300
|
||||
assert main_post.message_id == 301 # Последний message_id из списка
|
||||
assert main_post.is_anonymous is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -269,14 +278,17 @@ class TestPostService:
|
||||
album = [Mock()]
|
||||
album[0].caption = None
|
||||
|
||||
mock_helper_message = Mock()
|
||||
mock_helper_message.message_id = 303
|
||||
|
||||
with patch('helper_bot.handlers.private.services.get_text_message', return_value=" "):
|
||||
with patch('helper_bot.handlers.private.services.prepare_media_group_from_middlewares', return_value=[]):
|
||||
with patch('helper_bot.handlers.private.services.send_media_group_message_to_private_chat', return_value=302):
|
||||
with patch('helper_bot.handlers.private.services.send_media_group_message_to_private_chat', return_value=[302]):
|
||||
with patch('helper_bot.handlers.private.services.get_first_name', return_value="Test"):
|
||||
with patch('helper_bot.handlers.private.services.get_reply_keyboard_for_post', return_value=None):
|
||||
with patch('helper_bot.handlers.private.services.send_text_message', return_value=303):
|
||||
with patch('helper_bot.handlers.private.services.send_text_message', return_value=mock_helper_message):
|
||||
with patch('helper_bot.handlers.private.services.determine_anonymity', return_value=False):
|
||||
with patch('asyncio.sleep', return_value=None):
|
||||
with patch('asyncio.sleep', new_callable=AsyncMock):
|
||||
|
||||
await post_service.handle_media_group_post(mock_message, album, "Test")
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ class TestAdminService:
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(UserAlreadyBannedError, match=f"Пользователь {user_id} уже заблокирован"):
|
||||
await self.admin_service.ban_user(user_id, username, reason, ban_days)
|
||||
await self.admin_service.ban_user(user_id, username, reason, ban_days, ban_author_id=999)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ban_user_permanent(self):
|
||||
|
||||
@@ -89,7 +89,6 @@ class TestHelperFunctions:
|
||||
"""Тест функции get_text_message с is_anonymous=True"""
|
||||
text = "Тестовый пост"
|
||||
result = get_text_message(text, "Test", "testuser", is_anonymous=True)
|
||||
assert "Пост из ТГ:" in result
|
||||
assert "Тестовый пост" in result
|
||||
assert "Пост опубликован анонимно" in result
|
||||
assert "Автор поста" not in result
|
||||
@@ -98,7 +97,6 @@ class TestHelperFunctions:
|
||||
"""Тест функции get_text_message с is_anonymous=False"""
|
||||
text = "Тестовый пост"
|
||||
result = get_text_message(text, "Test", "testuser", is_anonymous=False)
|
||||
assert "Пост из ТГ:" in result
|
||||
assert "Тестовый пост" in result
|
||||
assert "Автор поста" in result
|
||||
assert "Test" in result
|
||||
@@ -110,14 +108,12 @@ class TestHelperFunctions:
|
||||
# Тест с "анон" в тексте
|
||||
text = "Тестовый пост анон"
|
||||
result = get_text_message(text, "Test", "testuser", is_anonymous=None)
|
||||
assert "Пост из ТГ:" in result
|
||||
assert "Тестовый пост анон" in result
|
||||
assert "Пост опубликован анонимно" in result
|
||||
|
||||
# Тест с "неанон" в тексте
|
||||
text = "Тестовый пост неанон"
|
||||
result = get_text_message(text, "Test", "testuser", is_anonymous=None)
|
||||
assert "Пост из ТГ:" in result
|
||||
assert "Тестовый пост неанон" in result
|
||||
assert "Автор поста" in result
|
||||
|
||||
@@ -579,13 +575,14 @@ class TestSendMessageFunctions:
|
||||
mock_sent_message.message_id = 456
|
||||
mock_message.bot.send_message.return_value = mock_sent_message
|
||||
|
||||
result = await send_text_message(123, mock_message, "Тестовое сообщение")
|
||||
|
||||
assert result == 456
|
||||
mock_message.bot.send_message.assert_called_once_with(
|
||||
chat_id=123,
|
||||
text="Тестовое сообщение"
|
||||
)
|
||||
# Мокаем rate_limiter (он импортируется внутри функции)
|
||||
with patch('helper_bot.utils.rate_limiter.send_with_rate_limit', new_callable=AsyncMock) as mock_rate_limit:
|
||||
mock_rate_limit.return_value = mock_sent_message
|
||||
|
||||
result = await send_text_message(123, mock_message, "Тестовое сообщение")
|
||||
|
||||
assert result == mock_sent_message
|
||||
assert result.message_id == 456
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_text_message_with_markup(self):
|
||||
@@ -599,14 +596,14 @@ class TestSendMessageFunctions:
|
||||
mock_sent_message.message_id = 456
|
||||
mock_message.bot.send_message.return_value = mock_sent_message
|
||||
|
||||
result = await send_text_message(123, mock_message, "Тестовое сообщение", mock_markup)
|
||||
|
||||
assert result == 456
|
||||
mock_message.bot.send_message.assert_called_once_with(
|
||||
chat_id=123,
|
||||
text="Тестовое сообщение",
|
||||
reply_markup=mock_markup
|
||||
)
|
||||
# Мокаем rate_limiter (он импортируется внутри функции)
|
||||
with patch('helper_bot.utils.rate_limiter.send_with_rate_limit', new_callable=AsyncMock) as mock_rate_limit:
|
||||
mock_rate_limit.return_value = mock_sent_message
|
||||
|
||||
result = await send_text_message(123, mock_message, "Тестовое сообщение", mock_markup)
|
||||
|
||||
assert result == mock_sent_message
|
||||
assert result.message_id == 456
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_photo_message(self):
|
||||
|
||||
Reference in New Issue
Block a user