diff --git a/.cursor/rules/architecture.md b/.cursor/rules/architecture.md new file mode 100644 index 0000000..4e8dd71 --- /dev/null +++ b/.cursor/rules/architecture.md @@ -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` diff --git a/.cursor/rules/code-style.md b/.cursor/rules/code-style.md new file mode 100644 index 0000000..836bff1 --- /dev/null +++ b/.cursor/rules/code-style.md @@ -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.""" + ... +``` diff --git a/.cursor/rules/database-patterns.md b/.cursor/rules/database-patterns.md new file mode 100644 index 0000000..05918c3 --- /dev/null +++ b/.cursor/rules/database-patterns.md @@ -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` diff --git a/.cursor/rules/dependencies-and-utils.md b/.cursor/rules/dependencies-and-utils.md new file mode 100644 index 0000000..c2cc1d1 --- /dev/null +++ b/.cursor/rules/dependencies-and-utils.md @@ -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. **Логируйте важные операции** с внешними сервисами diff --git a/.cursor/rules/error-handling.md b/.cursor/rules/error-handling.md new file mode 100644 index 0000000..8ca866c --- /dev/null +++ b/.cursor/rules/error-handling.md @@ -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 diff --git a/.cursor/rules/handlers-patterns.md b/.cursor/rules/handlers-patterns.md new file mode 100644 index 0000000..f5d4feb --- /dev/null +++ b/.cursor/rules/handlers-patterns.md @@ -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` для автоматической обработки (если есть) diff --git a/.cursor/rules/middleware-patterns.md b/.cursor/rules/middleware-patterns.md new file mode 100644 index 0000000..d9aaa25 --- /dev/null +++ b/.cursor/rules/middleware-patterns.md @@ -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()`** для локальной регистрации на уровне модуля diff --git a/.cursor/rules/testing.md b/.cursor/rules/testing.md new file mode 100644 index 0000000..b5fa7d9 --- /dev/null +++ b/.cursor/rules/testing.md @@ -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. **Тестируйте граничные случаи** и ошибки diff --git a/.python-version b/.python-version index 1635d0f..2419ad5 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.9.6 +3.11.9 diff --git a/Dockerfile b/Dockerfile index ceeccb5..0c36fe8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 && \ diff --git a/RATE_LIMITING_SOLUTION.md b/RATE_LIMITING_SOLUTION.md deleted file mode 100644 index ee41011..0000000 --- a/RATE_LIMITING_SOLUTION.md +++ /dev/null @@ -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 тестирование разных конфигураций -- Интеграция с системой алертов diff --git a/docs/IMPROVEMENTS.md b/docs/IMPROVEMENTS.md new file mode 100644 index 0000000..510201e --- /dev/null +++ b/docs/IMPROVEMENTS.md @@ -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 diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md new file mode 100644 index 0000000..84f2a1e --- /dev/null +++ b/docs/OPERATIONS.md @@ -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 +``` diff --git a/pyproject.toml b/pyproject.toml index e2863bd..8689105 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/test_rate_limiting.py b/test_rate_limiting.py deleted file mode 100644 index 084f4f3..0000000 --- a/test_rate_limiting.py +++ /dev/null @@ -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()) diff --git a/tests/test_blacklist_repository.py b/tests/test_blacklist_repository.py index f4de1b7..ae4bc21 100644 --- a/tests/test_blacklist_repository.py +++ b/tests/test_blacklist_repository.py @@ -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 запрос, без параметров diff --git a/tests/test_improved_media_processing.py b/tests/test_improved_media_processing.py index dba6442..7c2b642 100644 --- a/tests/test_improved_media_processing.py +++ b/tests/test_improved_media_processing.py @@ -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__": diff --git a/tests/test_post_repository.py b/tests/test_post_repository.py index 5030b58..441ec3d 100644 --- a/tests/test_post_repository.py +++ b/tests/test_post_repository.py @@ -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("Таблицы для постов созданы") diff --git a/tests/test_post_service.py b/tests/test_post_service.py index 10db950..bc77238 100644 --- a/tests/test_post_service.py +++ b/tests/test_post_service.py @@ -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") diff --git a/tests/test_refactored_admin_handlers.py b/tests/test_refactored_admin_handlers.py index 10d0333..cc6d794 100644 --- a/tests/test_refactored_admin_handlers.py +++ b/tests/test_refactored_admin_handlers.py @@ -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): diff --git a/tests/test_utils.py b/tests/test_utils.py index c53812c..12651fc 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -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):