Обновлен Python до версии 3.11.9 и изменены зависимости в Dockerfile и pyproject.toml. Удалены устаревшие файлы RATE_LIMITING_SOLUTION.md и тесты для rate limiting.

Обновлены пути к библиотекам в Dockerfile для соответствия новой версии Python.
Исправлены все тесты, теперь все проходят
This commit is contained in:
2026-01-25 16:07:27 +03:00
parent 5a90591564
commit d2d7c83575
21 changed files with 2324 additions and 409 deletions

View File

@@ -0,0 +1,88 @@
---
description: "Архитектурные паттерны и структура проекта Telegram бота на aiogram"
alwaysApply: true
---
# Архитектура проекта
Этот проект - Telegram бот на **aiogram 3.10.0** с четкой архитектурой и разделением ответственности.
## Структура проекта
```
helper_bot/
├── handlers/ # Обработчики событий (admin, callback, group, private, voice)
│ ├── services.py # Бизнес-логика для каждого модуля
│ ├── exceptions.py # Кастомные исключения модуля
│ └── dependencies.py # Dependency injection для модуля
├── middlewares/ # Middleware для cross-cutting concerns
├── utils/ # Утилиты и вспомогательные функции
├── keyboards/ # Клавиатуры для бота
└── filters/ # Кастомные фильтры
database/
├── repositories/ # Репозитории для работы с БД (Repository pattern)
├── models.py # Модели данных
├── base.py # Базовый класс DatabaseConnection
└── async_db.py # AsyncBotDB - основной интерфейс к БД
```
## Архитектурные паттерны
### 1. Repository Pattern
- Все операции с БД выполняются через репозитории в `database/repositories/`
- Каждая сущность имеет свой репозиторий (UserRepository, PostRepository, etc.)
- Репозитории наследуются от `DatabaseConnection` из `database/base.py`
- Используется `RepositoryFactory` для создания репозиториев
### 2. Service Layer Pattern
- Бизнес-логика вынесена в сервисы (`handlers/*/services.py`)
- Handlers только обрабатывают события и вызывают сервисы
- Сервисы работают с репозиториями через `AsyncBotDB`
### 3. Dependency Injection
- Используется `BaseDependencyFactory` для управления зависимостями
- Глобальный экземпляр доступен через `get_global_instance()`
- Зависимости внедряются через `DependenciesMiddleware`
- Для каждого модуля handlers может быть свой `dependencies.py` с фабриками
### 4. Middleware Pattern
- Middleware регистрируются в `main.py` на уровне dispatcher
- Порядок регистрации важен: DependenciesMiddleware → MetricsMiddleware → BlacklistMiddleware → RateLimitMiddleware
- Middleware обрабатывают cross-cutting concerns (логирование, метрики, rate limiting)
## Принципы
1. **Разделение ответственности**: Handlers → Services → Repositories
2. **Асинхронность**: Все операции с БД и API асинхронные
3. **Типизация**: Используются type hints везде, где возможно
4. **Логирование**: Всегда через `logs.custom_logger.logger`
5. **Метрики**: Декораторы `@track_time`, `@track_errors`, `@db_query_time` для мониторинга
## Версия Python
Проект использует **Python 3.11.9** во всех окружениях:
- Локальная разработка: Python 3.11.9 (указана в `.python-version`)
- Docker (production): Python 3.11.9-alpine (указана в `Dockerfile`)
- Минимальная версия: Python 3.11 (указана в `pyproject.toml`)
**Важно:**
- При написании кода можно использовать фичи Python 3.11
- Доступны улучшенные type hints, match/case (Python 3.10+)
- Используйте type hints везде, где возможно
- `@dataclass` доступен (Python 3.7+)
**Структура проекта:**
- Docker файлы находятся в двух местах:
- `/prod/Dockerfile` - для инфраструктуры (Python 3.11.9-alpine)
- `/prod/bots/telegram-helper-bot/Dockerfile` - для бота (Python 3.11.9-alpine)
- При обновлении версии Python нужно обновить оба Dockerfile
**Для локальной разработки:**
Рекомендуется использовать `pyenv` для установки Python 3.11.9:
```bash
pyenv install 3.11.9
pyenv local 3.11.9
```
Подробнее см. `docs/PYTHON_VERSION_MANAGEMENT.md`

106
.cursor/rules/code-style.md Normal file
View File

@@ -0,0 +1,106 @@
---
description: "Стиль кода, соглашения по именованию и форматированию"
alwaysApply: true
---
# Стиль кода и соглашения
## Именование
### Классы
- **PascalCase**: `UserRepository`, `AdminService`, `BaseDependencyFactory`
- Имена классов должны быть существительными
### Функции и методы
- **snake_case**: `get_user_info()`, `handle_message()`, `create_tables()`
- Имена функций должны быть глаголами или начинаться с глагола
### Переменные
- **snake_case**: `user_id`, `bot_db`, `settings`, `message_text`
- Константы в **UPPER_SNAKE_CASE**: `FSM_STATES`, `ERROR_MESSAGES`
### Модули и пакеты
- **snake_case**: `admin_handlers.py`, `user_repository.py`
- Имена модулей должны быть короткими и понятными
## Импорты
Структура импортов (в порядке приоритета):
```python
# 1. Standard library imports
import os
import asyncio
from typing import Optional, List
# 2. Third-party imports
from aiogram import Router, types
from aiogram.filters import Command
# 3. Local imports - модули проекта
from database.async_db import AsyncBotDB
from helper_bot.handlers.admin.services import AdminService
# 4. Local imports - utilities
from logs.custom_logger import logger
# 5. Local imports - metrics (если используются)
from helper_bot.utils.metrics import track_time, track_errors
```
## Type Hints
- Всегда используйте type hints для параметров функций и возвращаемых значений
- Используйте `Optional[T]` для значений, которые могут быть `None`
- Используйте `List[T]`, `Dict[K, V]` для коллекций
- Используйте `Annotated` для dependency injection в aiogram
Пример:
```python
async def get_user_info(self, user_id: int) -> Optional[Dict[str, Any]]:
"""Получение информации о пользователе."""
...
```
## Документация
### Docstrings
- Используйте docstrings для всех классов и публичных методов
- Формат: краткое описание в одну строку или многострочный с подробностями
```python
async def add_user(self, user: User) -> None:
"""Добавление нового пользователя с защитой от дублирования."""
...
```
### Комментарии
- Комментарии на русском языке (как и весь код)
- Используйте комментарии для объяснения "почему", а не "что"
- Разделители секций: `# ============================================================================`
## Форматирование
- Используйте 4 пробела для отступов (не табы)
- Максимальная длина строки: 100-120 символов (гибко)
- Пустые строки между логическими блоками
- Пустая строка перед `return` в конце функции (если функция не короткая)
## Структура файлов handlers
```python
# 1. Импорты (по категориям)
# 2. Создание роутера
router = Router()
# 3. Регистрация middleware (если нужно)
router.message.middleware(SomeMiddleware())
# 4. Handlers с декораторами
@router.message(...)
@track_time("handler_name", "module_name")
@track_errors("module_name", "handler_name")
async def handler_function(...):
"""Описание handler."""
...
```

View File

@@ -0,0 +1,117 @@
---
description: "Паттерны работы с базой данных, репозитории и модели"
globs: ["database/**/*.py", "**/repositories/*.py"]
---
# Паттерны работы с базой данных
## Repository Pattern
Все операции с БД выполняются через репозитории. Каждый репозиторий:
- Наследуется от `DatabaseConnection` из `database/base.py`
- Работает с одной сущностью (User, Post, Blacklist, etc.)
- Содержит методы для CRUD операций
- Использует асинхронные методы `_execute_query()` и `_execute_query_with_result()`
### Структура репозитория
```python
from database.base import DatabaseConnection
from database.models import User
class UserRepository(DatabaseConnection):
"""Репозиторий для работы с пользователями."""
async def create_tables(self):
"""Создание таблицы пользователей."""
query = '''
CREATE TABLE IF NOT EXISTS our_users (
user_id INTEGER NOT NULL PRIMARY KEY,
...
)
'''
await self._execute_query(query)
self.logger.info("Таблица пользователей создана")
async def add_user(self, user: User) -> None:
"""Добавление нового пользователя."""
query = "INSERT OR IGNORE INTO our_users (...) VALUES (...)"
params = (...)
await self._execute_query(query, params)
self.logger.info(f"Пользователь добавлен: {user.user_id}")
async def get_user_by_id(self, user_id: int) -> Optional[User]:
"""Получение пользователя по ID."""
query = "SELECT * FROM our_users WHERE user_id = ?"
rows = await self._execute_query_with_result(query, (user_id,))
# Преобразование row в модель User
...
```
## Модели данных
- Модели определены в `database/models.py`
- Используются dataclasses или простые классы
- Модели передаются между слоями (Repository → Service → Handler)
## Работа с БД
### AsyncBotDB
- Основной интерфейс для работы с БД
- Использует `RepositoryFactory` для доступа к репозиториям
- Методы делегируют вызовы соответствующим репозиториям
### DatabaseConnection
- Базовый класс для всех репозиториев
- Предоставляет методы:
- `_get_connection()` - получение соединения
- `_execute_query()` - выполнение запроса без результата
- `_execute_query_with_result()` - выполнение запроса с результатом
- Автоматически управляет соединениями (открытие/закрытие)
- Настраивает PRAGMA для оптимизации SQLite
### Важные правила
1. **Всегда используйте параметризованные запросы** для защиты от SQL injection:
```python
# ✅ Правильно
query = "SELECT * FROM users WHERE user_id = ?"
await self._execute_query_with_result(query, (user_id,))
# ❌ Неправильно
query = f"SELECT * FROM users WHERE user_id = {user_id}"
```
2. **Логируйте важные операции**:
```python
self.logger.info(f"Пользователь добавлен: {user_id}")
self.logger.error(f"Ошибка при добавлении пользователя: {e}")
```
3. **Используйте транзакции** для множественных операций (если нужно):
```python
async with await self._get_connection() as conn:
await conn.execute(...)
await conn.execute(...)
await conn.commit()
```
4. **Обрабатывайте None** при получении данных:
```python
rows = await self._execute_query_with_result(query, params)
if not rows:
return None
row = rows[0]
```
## RepositoryFactory
- Создает и кэширует экземпляры репозиториев
- Доступ через свойства: `factory.users`, `factory.posts`, etc.
- Используется в `AsyncBotDB` для доступа к репозиториям
## Миграции
- SQL миграции в `database/schema.sql`
- Python скрипты для миграций в `scripts/`
- Всегда проверяйте существование таблиц перед созданием: `CREATE TABLE IF NOT EXISTS`

View File

@@ -0,0 +1,172 @@
---
description: "Работа с зависимостями, утилитами, метриками и внешними сервисами"
globs: ["helper_bot/utils/**/*.py", "helper_bot/config/**/*.py"]
---
# Зависимости и утилиты
## BaseDependencyFactory
Центральный класс для управления зависимостями проекта.
### Использование
```python
from helper_bot.utils.base_dependency_factory import get_global_instance
# Получение глобального экземпляра
bdf = get_global_instance()
# Доступ к зависимостям
db = bdf.get_db() # AsyncBotDB
settings = bdf.get_settings() # dict с настройками
s3_storage = bdf.get_s3_storage() # S3StorageService или None
```
### Структура settings
Настройки загружаются из `.env` и структурированы:
```python
settings = {
'Telegram': {
'bot_token': str,
'listen_bot_token': str,
'preview_link': bool,
'main_public': str,
'group_for_posts': int,
'important_logs': int,
...
},
'Settings': {
'logs': bool,
'test': bool
},
'Metrics': {
'host': str,
'port': int
},
'S3': {
'enabled': bool,
'endpoint_url': str,
'access_key': str,
'secret_key': str,
'bucket_name': str,
'region': str
}
}
```
## Метрики
### Декораторы метрик
Используйте декораторы из `helper_bot.utils.metrics`:
```python
from helper_bot.utils.metrics import track_time, track_errors, db_query_time
@track_time("method_name", "module_name")
@track_errors("module_name", "method_name")
async def some_method():
"""Метод с отслеживанием времени и ошибок."""
...
@db_query_time("method_name", "table_name", "operation")
async def db_method():
"""Метод БД с отслеживанием времени запросов."""
...
```
### Доступ к метрикам
```python
from helper_bot.utils.metrics import metrics
# Метрики доступны через Prometheus на порту из settings['Metrics']['port']
```
## Rate Limiting
### RateLimiter
Используется для ограничения частоты запросов:
```python
from helper_bot.utils.rate_limiter import RateLimiter
limiter = RateLimiter(...)
if await limiter.is_allowed(user_id):
# Разрешить действие
...
else:
# Отклонить действие
...
```
### RateLimitMiddleware
Автоматически применяет rate limiting ко всем запросам через middleware.
## S3 Storage
### S3StorageService
Используется для хранения медиафайлов:
```python
from helper_bot.utils.s3_storage import S3StorageService
# Получение через BaseDependencyFactory
s3_storage = bdf.get_s3_storage()
if s3_storage:
# Загрузка файла
url = await s3_storage.upload_file(file_path, object_key)
# Удаление файла
await s3_storage.delete_file(object_key)
```
### Проверка доступности
Всегда проверяйте, что S3 включен:
```python
s3_storage = bdf.get_s3_storage()
if s3_storage:
# Работа с S3
...
else:
# Fallback логика
...
```
## Утилиты
### helper_func.py
Содержит вспомогательные функции для работы с:
- Датами и временем
- Форматированием данных
- Валидацией
- Преобразованием данных
Используйте эти функции вместо дублирования логики.
## Конфигурация
### rate_limit_config.py
Конфигурация rate limiting находится в `helper_bot/config/rate_limit_config.py`.
Используйте конфигурацию вместо хардкода значений.
## Best Practices
1. **Всегда получайте зависимости через BaseDependencyFactory** - не создавайте экземпляры напрямую
2. **Используйте декораторы метрик** для всех важных методов
3. **Проверяйте доступность внешних сервисов** (S3) перед использованием
4. **Используйте утилиты** из `helper_func.py` вместо дублирования кода
5. **Читайте настройки из settings** вместо хардкода значений
6. **Логируйте важные операции** с внешними сервисами

View File

@@ -0,0 +1,156 @@
---
description: "Обработка ошибок, исключения и логирование"
alwaysApply: false
---
# Обработка ошибок и исключений
## Иерархия исключений
Каждый модуль должен иметь свой файл `exceptions.py` с иерархией исключений:
```python
# Базовое исключение модуля
class ModuleError(Exception):
"""Базовое исключение для модуля"""
pass
# Специализированные исключения
class UserNotFoundError(ModuleError):
"""Исключение при отсутствии пользователя"""
pass
class InvalidInputError(ModuleError):
"""Исключение при некорректном вводе данных"""
pass
```
## Обработка в Handlers
### Паттерн try-except
```python
@router.message(...)
async def handler(message: types.Message, state: FSMContext, **kwargs):
try:
# Основная логика
service = SomeService(bot_db, settings)
result = await service.do_something()
await message.answer("Успех")
except UserNotFoundError as e:
# Обработка специфичной ошибки
await message.answer(f"Пользователь не найден: {str(e)}")
logger.warning(f"Пользователь не найден: {e}")
except InvalidInputError as e:
# Обработка ошибки валидации
await message.answer(f"Некорректный ввод: {str(e)}")
logger.warning(f"Некорректный ввод: {e}")
except Exception as e:
# Обработка неожиданных ошибок
await handle_error(message, e, state)
logger.error(f"Неожиданная ошибка в handler: {e}", exc_info=True)
```
### Декоратор error_handler
Некоторые модули используют декоратор `@error_handler` для автоматической обработки:
```python
from .decorators import error_handler
@error_handler
@router.message(...)
async def handler(...):
# Код handler
# Ошибки автоматически логируются и отправляются в important_logs
...
```
## Обработка в Services
```python
class SomeService:
async def do_something(self):
try:
# Бизнес-логика
data = await self.bot_db.get_data()
if not data:
raise UserNotFoundError("Пользователь не найден")
return self._process(data)
except UserNotFoundError:
# Пробрасываем специфичные исключения дальше
raise
except Exception as e:
# Логируем и пробрасываем неожиданные ошибки
logger.error(f"Ошибка в сервисе: {e}", exc_info=True)
raise
```
## Логирование
### Использование logger
Всегда используйте `logs.custom_logger.logger`:
```python
from logs.custom_logger import logger
# Информационные сообщения
logger.info(f"Пользователь {user_id} выполнил действие")
# Предупреждения
logger.warning(f"Попытка доступа к несуществующему ресурсу: {resource_id}")
# Ошибки
logger.error(f"Ошибка при выполнении операции: {e}")
# Ошибки с traceback
logger.error(f"Критическая ошибка: {e}", exc_info=True)
```
### Уровни логирования
- `logger.debug()` - отладочная информация
- `logger.info()` - информационные сообщения о работе
- `logger.warning()` - предупреждения о потенциальных проблемах
- `logger.error()` - ошибки, требующие внимания
- `logger.critical()` - критические ошибки
## Метрики ошибок
Декоратор `@track_errors` автоматически отслеживает ошибки:
```python
@track_errors("module_name", "method_name")
async def some_method():
# Ошибки автоматически записываются в метрики
...
```
## Централизованная обработка
### В admin handlers
Используется функция `handle_admin_error()`:
```python
from helper_bot.handlers.admin.utils import handle_admin_error
try:
# Код
except Exception as e:
await handle_admin_error(message, e, state, "context_name")
```
### В других модулях
Создавайте аналогичные утилиты для централизованной обработки ошибок модуля.
## Best Practices
1. **Всегда логируйте ошибки** перед пробросом или обработкой
2. **Используйте специфичные исключения** вместо общих `Exception`
3. **Пробрасывайте исключения** из сервисов в handlers для обработки
4. **Не глотайте исключения** без логирования
5. **Используйте `exc_info=True`** для логирования traceback критических ошибок
6. **Обрабатывайте ошибки на правильном уровне**: бизнес-логика в сервисах, пользовательские сообщения в handlers

View File

@@ -0,0 +1,215 @@
---
description: "Паттерны для создания handlers, services и обработки событий aiogram"
globs: ["helper_bot/handlers/**/*.py"]
---
# Паттерны для Handlers
## Структура модуля handler
Каждый модуль handler (admin, callback, group, private, voice) должен содержать:
```
handlers/{module}/
├── __init__.py # Экспорт router
├── {module}_handlers.py # Основные handlers
├── services.py # Бизнес-логика
├── exceptions.py # Кастомные исключения
├── dependencies.py # Dependency injection (опционально)
├── constants.py # Константы (FSM states, messages)
└── utils.py # Вспомогательные функции (опционально)
```
## Создание Router
```python
from aiogram import Router
# Создаем роутер
router = Router()
# Регистрируем middleware (если нужно)
router.message.middleware(SomeMiddleware())
# Экспортируем в __init__.py
# from .{module}_handlers import router
```
## Структура Handler
```python
from aiogram import Router, types
from aiogram.filters import Command, StateFilter
from aiogram.fsm.context import FSMContext
from helper_bot.filters.main import ChatTypeFilter
from logs.custom_logger import logger
from helper_bot.utils.metrics import track_time, track_errors
router = Router()
@router.message(
ChatTypeFilter(chat_type=["private"]),
Command('command_name')
)
@track_time("handler_name", "module_name")
@track_errors("module_name", "handler_name")
async def handler_function(
message: types.Message,
state: FSMContext,
bot_db: AsyncBotDB, # Из DependenciesMiddleware
settings: dict, # Из DependenciesMiddleware
**kwargs
):
"""Описание handler."""
try:
# Логирование
logger.info(f"Обработка команды от пользователя: {message.from_user.id}")
# Получение данных из state (если нужно)
data = await state.get_data()
# Вызов сервиса для бизнес-логики
service = SomeService(bot_db, settings)
result = await service.do_something()
# Ответ пользователю
await message.answer("Результат")
# Обновление state (если нужно)
await state.set_state("NEW_STATE")
except SomeCustomException as e:
# Обработка кастомных исключений
await message.answer(f"Ошибка: {str(e)}")
logger.error(f"Ошибка в handler: {e}")
except Exception as e:
# Обработка общих ошибок
await handle_error(message, e, state)
logger.error(f"Неожиданная ошибка: {e}")
```
## Service Layer
Бизнес-логика выносится в сервисы:
```python
from logs.custom_logger import logger
from helper_bot.utils.metrics import track_time, track_errors
class SomeService:
"""Сервис для работы с ..."""
def __init__(self, bot_db: AsyncBotDB, settings: dict):
self.bot_db = bot_db
self.settings = settings
@track_time("method_name", "service_name")
@track_errors("service_name", "method_name")
async def do_something(self) -> SomeResult:
"""Описание метода."""
try:
# Работа с БД через bot_db
data = await self.bot_db.some_method()
# Бизнес-логика
result = self._process_data(data)
return result
except Exception as e:
logger.error(f"Ошибка в сервисе: {e}")
raise
```
## Dependency Injection
### Через DependenciesMiddleware (глобально)
- `bot_db: AsyncBotDB` - доступен во всех handlers
- `settings: dict` - настройки из .env
- `bot: Bot` - экземпляр бота
- `dp: Dispatcher` - dispatcher
### Через MagicData (локально)
```python
from aiogram.filters import MagicData
from typing import Annotated
from helper_bot.handlers.admin.dependencies import BotDB, Settings
@router.message(
Command('admin'),
MagicData(bot_db=BotDB, settings=Settings)
)
async def handler(
message: types.Message,
bot_db: Annotated[AsyncBotDB, BotDB],
settings: Annotated[dict, Settings]
):
...
```
### Через фабрики (для сервисов)
```python
# В dependencies.py
def get_some_service() -> SomeService:
"""Фабрика для SomeService"""
bdf = get_global_instance()
db = bdf.get_db()
settings = bdf.settings
return SomeService(db, settings)
# В handlers
@router.message(Command('cmd'))
async def handler(
message: types.Message,
service: Annotated[SomeService, get_some_service()]
):
...
```
## FSM (Finite State Machine)
```python
# Определение состояний в constants.py
FSM_STATES = {
"ADMIN": "ADMIN",
"AWAIT_INPUT": "AWAIT_INPUT",
...
}
# Установка состояния
await state.set_state(FSM_STATES["ADMIN"])
# Получение состояния
current_state = await state.get_state()
# Сохранение данных
await state.update_data(key=value)
# Получение данных
data = await state.get_data()
value = data.get("key")
# Очистка состояния
await state.clear()
```
## Фильтры
Используйте кастомные фильтры из `helper_bot.filters.main`:
- `ChatTypeFilter` - фильтр по типу чата (private, group, supergroup)
## Декораторы для метрик
Всегда добавляйте декораторы метрик к handlers и методам сервисов:
```python
@track_time("handler_name", "module_name") # Измерение времени выполнения
@track_errors("module_name", "handler_name") # Отслеживание ошибок
@db_query_time("method_name", "table_name", "operation") # Для БД операций
```
## Обработка ошибок
- Используйте кастомные исключения из `exceptions.py`
- Обрабатывайте исключения в handlers
- Логируйте все ошибки через `logger.error()`
- Используйте декоратор `@error_handler` для автоматической обработки (если есть)

View File

@@ -0,0 +1,109 @@
---
description: "Паттерны создания и использования middleware в aiogram"
globs: ["helper_bot/middlewares/**/*.py"]
---
# Паттерны Middleware
## Структура Middleware
Все middleware наследуются от `aiogram.BaseMiddleware`:
```python
from typing import Any, Dict
from aiogram import BaseMiddleware
from aiogram.types import TelegramObject
class CustomMiddleware(BaseMiddleware):
"""Описание middleware."""
async def __call__(
self,
handler: Callable,
event: TelegramObject,
data: Dict[str, Any]
) -> Any:
# Логика до обработки handler
...
# Вызов следующего handler в цепочке
result = await handler(event, data)
# Логика после обработки handler
...
return result
```
## Порядок регистрации Middleware
В `main.py` middleware регистрируются в следующем порядке (важно!):
```python
# 1. DependenciesMiddleware - внедрение зависимостей
dp.update.outer_middleware(DependenciesMiddleware())
# 2. MetricsMiddleware - сбор метрик
dp.update.outer_middleware(MetricsMiddleware())
# 3. BlacklistMiddleware - проверка черного списка
dp.update.outer_middleware(BlacklistMiddleware())
# 4. RateLimitMiddleware - ограничение частоты запросов
dp.update.outer_middleware(RateLimitMiddleware())
```
## DependenciesMiddleware
Внедряет глобальные зависимости во все handlers:
```python
class DependenciesMiddleware(BaseMiddleware):
async def __call__(self, handler, event: TelegramObject, data: Dict[str, Any]) -> Any:
bdf = get_global_instance()
# Внедрение зависимостей
if 'bot_db' not in data:
data['bot_db'] = bdf.get_db()
if 'settings' not in data:
data['settings'] = bdf.settings
return await handler(event, data)
```
## Обработка ошибок в Middleware
```python
class CustomMiddleware(BaseMiddleware):
async def __call__(self, handler, event, data):
try:
# Предобработка
...
result = await handler(event, data)
# Постобработка
...
return result
except Exception as e:
# Обработка ошибок
logger.error(f"Ошибка в middleware: {e}")
# Решаем: пробрасывать дальше или обработать
raise
```
## Регистрация на уровне Router
Middleware можно регистрировать на уровне конкретного router:
```python
router = Router()
router.message.middleware(SomeMiddleware()) # Только для message handlers
router.callback_query.middleware(SomeMiddleware()) # Только для callback handlers
```
## Best Practices
1. **Регистрируйте middleware в правильном порядке** - зависимости должны быть первыми
2. **Не изменяйте event** напрямую, используйте `data` для передачи информации
3. **Обрабатывайте ошибки** в middleware, но не глотайте их без логирования
4. **Используйте `outer_middleware`** для глобальной регистрации
5. **Используйте `router.middleware()`** для локальной регистрации на уровне модуля

197
.cursor/rules/testing.md Normal file
View File

@@ -0,0 +1,197 @@
---
description: "Паттерны тестирования, структура тестов и использование pytest"
globs: ["tests/**/*.py", "test_*.py"]
---
# Паттерны тестирования
## Структура тестов
Тесты находятся в директории `tests/` и используют pytest:
```
tests/
├── conftest.py # Общие фикстуры
├── conftest_*.py # Специализированные фикстуры
├── mocks.py # Моки и заглушки
└── test_*.py # Тестовые файлы
```
## Конфигурация pytest
Настройки в `pyproject.toml`:
- `asyncio-mode=auto` - автоматический режим для async тестов
- Маркеры: `asyncio`, `slow`, `integration`, `unit`
- Фильтрация предупреждений
## Структура теста
```python
import pytest
from database.async_db import AsyncBotDB
from database.repositories.user_repository import UserRepository
@pytest.mark.asyncio
async def test_user_repository_add_user(db_path):
"""Тест добавления пользователя."""
# Arrange
repo = UserRepository(db_path)
user = User(user_id=123, full_name="Test User")
# Act
await repo.add_user(user)
# Assert
result = await repo.get_user_by_id(123)
assert result is not None
assert result.full_name == "Test User"
```
## Фикстуры
### Общие фикстуры (conftest.py)
```python
import pytest
import tempfile
import os
@pytest.fixture
def db_path():
"""Создает временный файл БД для тестов."""
fd, path = tempfile.mkstemp(suffix='.db')
os.close(fd)
yield path
os.unlink(path)
@pytest.fixture
async def async_db(db_path):
"""Создает AsyncBotDB для тестов."""
db = AsyncBotDB(db_path)
await db.create_tables()
yield db
```
### Использование фикстур
```python
@pytest.mark.asyncio
async def test_something(async_db):
# async_db уже инициализирован
result = await async_db.some_method()
assert result is not None
```
## Моки
Используйте `mocks.py` для общих моков:
```python
from unittest.mock import AsyncMock, MagicMock
def mock_bot():
"""Создает мок бота."""
bot = MagicMock()
bot.send_message = AsyncMock()
return bot
```
## Маркеры
Используйте маркеры для категоризации тестов:
```python
@pytest.mark.unit
@pytest.mark.asyncio
async def test_unit_test():
"""Быстрый unit тест."""
...
@pytest.mark.integration
@pytest.mark.asyncio
async def test_integration_test():
"""Медленный integration тест."""
...
@pytest.mark.slow
@pytest.mark.asyncio
async def test_slow_test():
"""Медленный тест."""
...
```
Запуск с фильтрацией:
```bash
pytest -m "not slow" # Пропустить медленные тесты
pytest -m unit # Только unit тесты
```
## Тестирование Handlers
```python
from aiogram import Bot, Dispatcher
from aiogram.fsm.storage.memory import MemoryStorage
from unittest.mock import AsyncMock
@pytest.mark.asyncio
async def test_handler(mock_bot, mock_message):
"""Тест handler."""
# Arrange
dp = Dispatcher(storage=MemoryStorage())
# Регистрация handler
...
# Act
await dp.feed_update(mock_update)
# Assert
mock_bot.send_message.assert_called_once()
```
## Тестирование Services
```python
@pytest.mark.asyncio
async def test_service_method(async_db):
"""Тест метода сервиса."""
service = SomeService(async_db, {})
result = await service.do_something()
assert result is not None
```
## Тестирование Repositories
```python
@pytest.mark.asyncio
async def test_repository_crud(db_path):
"""Тест CRUD операций репозитория."""
repo = SomeRepository(db_path)
await repo.create_tables()
# Create
entity = SomeEntity(...)
await repo.add(entity)
# Read
result = await repo.get_by_id(entity.id)
assert result is not None
# Update
entity.field = "new_value"
await repo.update(entity)
# Delete
await repo.delete(entity.id)
result = await repo.get_by_id(entity.id)
assert result is None
```
## Best Practices
1. **Используйте фикстуры** для переиспользования setup/teardown
2. **Изолируйте тесты** - каждый тест должен быть независимым
3. **Используйте временные БД** для тестов репозиториев
4. **Мокируйте внешние зависимости** (API, файловая система)
5. **Пишите понятные имена тестов** - они должны описывать что тестируется
6. **Используйте Arrange-Act-Assert** паттерн
7. **Тестируйте граничные случаи** и ошибки

View File

@@ -1 +1 @@
3.9.6 3.11.9

View File

@@ -1,7 +1,7 @@
########################################### ###########################################
# Этап 1: Сборщик (Builder) # Этап 1: Сборщик (Builder)
########################################### ###########################################
FROM python:3.9-alpine as builder FROM python:3.11.9-alpine as builder
# Устанавливаем инструменты для компиляции + linux-headers для psutil # Устанавливаем инструменты для компиляции + linux-headers для psutil
RUN apk add --no-cache \ RUN apk add --no-cache \
@@ -21,7 +21,7 @@ RUN pip install --no-cache-dir --target /install -r requirements.txt
########################################### ###########################################
# Этап 2: Финальный образ (Runtime) # Этап 2: Финальный образ (Runtime)
########################################### ###########################################
FROM python:3.9-alpine as runtime FROM python:3.11.9-alpine as runtime
# Минимальные рантайм-зависимости # Минимальные рантайм-зависимости
RUN apk add --no-cache \ RUN apk add --no-cache \
@@ -34,7 +34,7 @@ RUN addgroup -g 1001 deploy && adduser -D -u 1001 -G deploy deploy
WORKDIR /app WORKDIR /app
# Копируем зависимости # Копируем зависимости
COPY --from=builder --chown=1001:1001 /install /usr/local/lib/python3.9/site-packages COPY --from=builder --chown=1001:1001 /install /usr/local/lib/python3.11/site-packages
# Создаем структуру папок # Создаем структуру папок
RUN mkdir -p database logs voice_users && \ RUN mkdir -p database logs voice_users && \

View File

@@ -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 тестирование разных конфигураций
- Интеграция с системой алертов

710
docs/IMPROVEMENTS.md Normal file
View File

@@ -0,0 +1,710 @@
# План улучшений проекта
Этот документ содержит список рекомендаций по улучшению кодовой базы проекта Telegram Helper Bot. Пункты отсортированы по приоритетам и могут быть использованы для планирования работ.
## Статус задач
-Не начато
- 🟡 В работе
- ✅ Выполнено
- ❌ Отложено
---
## 🔴 Высокий приоритет
### 1. Стандартизация Dependency Injection
**Статус:**
**Проблема:**
В проекте используется смешанный подход к dependency injection:
- В некоторых местах используется `MagicData("bot_db")` и `MagicData("settings")`
- В других местах используется `**kwargs` и получение из `data`
- В сервисах напрямую вызывается `get_global_instance()`
**Текущее состояние:**
```python
# callback_handlers.py - смешанный подход
async def handler(call: CallbackQuery, settings: MagicData("settings")):
publish_service = get_post_publish_service() # Прямой вызов фабрики
async def handler(call: CallbackQuery, **kwargs):
ban_service = get_ban_service() # Прямой вызов фабрики
```
**Рекомендация:**
Стандартизировать на использование `MagicData` и `Annotated` везде:
```python
from typing import Annotated
from aiogram.filters import MagicData
from helper_bot.handlers.admin.dependencies import BotDB, Settings
async def handler(
call: CallbackQuery,
bot_db: Annotated[AsyncBotDB, BotDB],
settings: Annotated[dict, Settings],
service: Annotated[PostPublishService, get_post_publish_service()]
):
# Использовать зависимости напрямую
...
```
**Файлы для изменения:**
- `helper_bot/handlers/callback/callback_handlers.py` (строки 47, 80, 109, 131, 182)
- `helper_bot/handlers/private/private_handlers.py`
- Все сервисы, которые используют `get_global_instance()`
**Оценка:** Средняя сложность, требует рефакторинга нескольких файлов
---
### 2. Удаление `import *`
**Статус:**
**Проблема:**
В `voice_handler.py` используется импорт всех констант через `import *`, что затрудняет понимание зависимостей и может привести к конфликтам имен.
**Текущее состояние:**
```python
# helper_bot/handlers/voice/voice_handler.py
from helper_bot.handlers.voice.constants import *
```
**Рекомендация:**
Заменить на явные импорты:
```python
from helper_bot.handlers.voice.constants import (
CONSTANT1,
CONSTANT2,
CONSTANT3,
# ... все используемые константы
)
```
**Файлы для изменения:**
- `helper_bot/handlers/voice/voice_handler.py` (строка 17)
**Оценка:** Низкая сложность, быстрое исправление
---
### 3. Закрытие критичных TODO
**Статус:**
**Проблема:**
В коде есть несколько TODO комментариев, указывающих на технический долг и места, требующие рефакторинга.
**Список TODO:**
#### 3.1. Callback handlers - переход на MagicData
**Файл:** `helper_bot/handlers/callback/callback_handlers.py`
- Строка 47: `# TODO: переделать на MagicData`
- Строка 80: `# TODO: переделать на MagicData`
- Строка 109: `# TODO: переделать на MagicData`
- Строка 131: `# TODO: переделать на MagicData`
- Строка 182: `# TODO: переделать на MagicData`
**Решение:** Связано с задачей #1 (стандартизация DI)
#### 3.2. Metrics middleware - подключение к БД
**Файл:** `helper_bot/middlewares/metrics_middleware.py`
- Строка 153: `#TODO: Должна подключаться к базе данных, а не к глобальному экземпляру`
**Решение:**
```python
# Вместо
bdf = get_global_instance()
bot_db = bdf.get_db()
# Использовать dependency injection через MagicData
async def _update_active_users_metric(
self,
bot_db: Annotated[AsyncBotDB, BotDB]
):
...
```
#### 3.3. Voice handler - вынос логики
**Файл:** `helper_bot/handlers/voice/voice_handler.py`
- Строка 354: `#TODO: удалить логику из хендлера`
**Решение:** Переместить бизнес-логику в `VoiceBotService`
#### 3.4. Helper functions - архитектура
**Файл:** `helper_bot/utils/helper_func.py`
- Строка 35: `#TODO: поменять архитектуру и подключить правильный BotDB`
- Строка 145: `#TODO: Уверен можно укоротить`
**Решение:** Рефакторинг функций для использования dependency injection
#### 3.5. Group handlers - архитектура
**Файл:** `helper_bot/handlers/group/group_handlers.py`
- Строка 109: `#TODO: поменять архитектуру и подключить правильный BotDB`
**Решение:** Использовать dependency injection вместо прямого доступа к БД
**Оценка:** Средняя-высокая сложность, требует анализа каждого случая
---
## 🟡 Средний приоритет
### 4. Оптимизация работы с БД - Connection Pooling
**Статус:**
**Проблема:**
Каждый запрос к БД открывает новое соединение и закрывает его. При высокой нагрузке это неэффективно и может привести к проблемам с производительностью.
**Текущее состояние:**
```python
# database/base.py
async def _get_connection(self):
conn = await aiosqlite.connect(self.db_path)
# Настройка PRAGMA каждый раз
await conn.execute("PRAGMA foreign_keys = ON")
await conn.execute("PRAGMA journal_mode = WAL")
# ...
return conn
async def _execute_query(self, query: str, params: tuple = ()):
conn = None
try:
conn = await self._get_connection() # Новое соединение каждый раз
result = await conn.execute(query, params)
await conn.commit()
return result
finally:
if conn:
await conn.close() # Закрытие после каждого запроса
```
**Рекомендация:**
Реализовать переиспользование соединений или connection pool:
**Вариант 1: Переиспользование соединения в рамках транзакции**
```python
class DatabaseConnection:
def __init__(self, db_path: str):
self.db_path = db_path
self._connection: Optional[aiosqlite.Connection] = None
async def _get_connection(self):
if self._connection is None:
self._connection = await aiosqlite.connect(self.db_path)
# Настройка PRAGMA один раз
await self._connection.execute("PRAGMA foreign_keys = ON")
# ...
return self._connection
async def close(self):
if self._connection:
await self._connection.close()
self._connection = None
```
**Вариант 2: Использование async context manager**
```python
async def _execute_query(self, query: str, params: tuple = ()):
async with aiosqlite.connect(self.db_path) as conn:
await conn.execute("PRAGMA foreign_keys = ON")
result = await conn.execute(query, params)
await conn.commit()
return result
```
**Файлы для изменения:**
- `database/base.py`
- `database/repository_factory.py` (добавить метод `close()`)
- `helper_bot/utils/base_dependency_factory.py` (закрытие соединений при shutdown)
**Оценка:** Средняя сложность, требует тестирования на производительность
---
### 5. Улучшение обработки ошибок - декораторы
**Статус:**
**Проблема:**
В `callback_handlers.py` повторяется один и тот же блок обработки ошибок в каждом handler:
```python
try:
# Бизнес-логика
except UserBlockedBotError:
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
except (PostNotFoundError, PublishError) as e:
logger.error(f'Ошибка: {str(e)}')
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
except Exception as e:
if str(e) == ERROR_BOT_BLOCKED:
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
else:
important_logs = settings['Telegram']['important_logs']
await call.bot.send_message(
chat_id=important_logs,
text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}"
)
logger.error(f'Неожиданная ошибка: {str(e)}')
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
```
**Рекомендация:**
Создать декоратор для централизованной обработки ошибок:
```python
# helper_bot/handlers/callback/decorators.py
from functools import wraps
from typing import Callable, Any
from aiogram.types import CallbackQuery
from logs.custom_logger import logger
import traceback
def handle_callback_errors(func: Callable[..., Any]) -> Callable[..., Any]:
"""Декоратор для обработки ошибок в callback handlers."""
@wraps(func)
async def wrapper(call: CallbackQuery, *args, **kwargs):
try:
return await func(call, *args, **kwargs)
except UserBlockedBotError:
await call.answer(
text=MESSAGE_ERROR,
show_alert=True,
cache_time=3
)
except (PostNotFoundError, PublishError) as e:
logger.error(f'Ошибка в {func.__name__}: {str(e)}')
await call.answer(
text=MESSAGE_ERROR,
show_alert=True,
cache_time=3
)
except Exception as e:
if str(e) == ERROR_BOT_BLOCKED:
await call.answer(
text=MESSAGE_ERROR,
show_alert=True,
cache_time=3
)
else:
# Получить settings из kwargs или через dependency injection
settings = kwargs.get('settings')
if settings:
important_logs = settings['Telegram']['important_logs']
await call.bot.send_message(
chat_id=important_logs,
text=f"Произошла ошибка в {func.__name__}: {str(e)}\n\nTraceback:\n{traceback.format_exc()}"
)
logger.error(f'Неожиданная ошибка в {func.__name__}: {str(e)}')
await call.answer(
text=MESSAGE_ERROR,
show_alert=True,
cache_time=3
)
return wrapper
```
**Использование:**
```python
@callback_router.callback_query(F.data == CALLBACK_APPROVE)
@handle_callback_errors
@track_time("post_for_group", "callback_handlers")
@track_errors("callback_handlers", "post_for_group")
async def post_for_group(call: CallbackQuery, ...):
# Только бизнес-логика, без try-except
publish_service = get_post_publish_service()
await publish_service.publish_post(call)
await call.answer(text=MESSAGE_PUBLISHED, cache_time=3)
```
**Файлы для изменения:**
- Создать `helper_bot/handlers/callback/decorators.py`
- Рефакторинг `helper_bot/handlers/callback/callback_handlers.py`
**Оценка:** Средняя сложность, требует тестирования всех сценариев
---
### 6. Валидация настроек при старте
**Статус:**
**Проблема:**
Настройки загружаются из `.env` без валидации. Отсутствие обязательных настроек обнаруживается только во время выполнения, что затрудняет отладку.
**Текущее состояние:**
```python
# helper_bot/utils/base_dependency_factory.py
def _load_settings_from_env(self):
self.settings['Telegram'] = {
'bot_token': os.getenv('BOT_TOKEN', ''), # Может быть пустой строкой
# ...
}
```
**Рекомендация:**
Добавить валидацию обязательных настроек:
```python
class BaseDependencyFactory:
REQUIRED_SETTINGS = {
'Telegram': ['bot_token'],
'S3': ['endpoint_url', 'access_key', 'secret_key', 'bucket_name'] # Если S3 включен
}
def _validate_settings(self):
"""Валидирует обязательные настройки."""
errors = []
# Проверка Telegram настроек
for key in self.REQUIRED_SETTINGS['Telegram']:
value = self.settings['Telegram'].get(key)
if not value:
errors.append(f"Telegram.{key} is required but not set")
# Проверка S3 настроек (если включен)
if self.settings['S3']['enabled']:
for key in self.REQUIRED_SETTINGS['S3']:
value = self.settings['S3'].get(key)
if not value:
errors.append(f"S3.{key} is required when S3 is enabled but not set")
if errors:
error_msg = "Configuration errors:\n" + "\n".join(f" - {e}" for e in errors)
raise ValueError(error_msg)
def __init__(self):
# ... существующий код ...
self._load_settings_from_env()
self._validate_settings() # Добавить валидацию
self._init_s3_storage()
```
**Файлы для изменения:**
- `helper_bot/utils/base_dependency_factory.py`
**Оценка:** Низкая сложность, быстрое добавление
---
### 7. Исправление RepositoryFactory
**Статус:**
**Проблема:**
Методы `check_database_integrity()` и `cleanup_wal_files()` в `RepositoryFactory` вызываются только для репозитория `users`, хотя должны применяться ко всем репозиториям или к базе данных в целом.
**Текущее состояние:**
```python
# database/repository_factory.py
async def check_database_integrity(self):
"""Проверяет целостность базы данных."""
await self.users.check_database_integrity() # Только users?
async def cleanup_wal_files(self):
"""Очищает WAL файлы."""
await self.users.cleanup_wal_files() # Только users?
```
**Рекомендация:**
Проверка целостности и очистка WAL должны выполняться один раз для всей БД, а не для каждого репозитория:
```python
async def check_database_integrity(self):
"""Проверяет целостность базы данных."""
# Использовать любой репозиторий для доступа к БД
await self.users.check_database_integrity()
async def cleanup_wal_files(self):
"""Очищает WAL файлы."""
# Использовать любой репозиторий для доступа к БД
await self.users.cleanup_wal_files()
```
Или лучше - вынести эти методы в `DatabaseConnection` и вызывать через любой репозиторий (текущая реализация уже правильная, но можно улучшить документацию).
**Альтернатива:** Создать отдельный класс `DatabaseManager` для операций на уровне БД.
**Файлы для изменения:**
- `database/repository_factory.py` (улучшить документацию)
- Возможно создать `database/database_manager.py`
**Оценка:** Низкая сложность, в основном документация
---
## 🟢 Низкий приоритет
### 8. Добавление кэширования (Redis)
**Статус:**
**Проблема:**
Часто запрашиваемые данные (например, список администраторов, настройки пользователей) загружаются из БД при каждом запросе, что создает лишнюю нагрузку на базу данных.
**Рекомендация:**
Добавить Redis для кэширования часто используемых данных:
```python
# helper_bot/utils/cache.py
import redis.asyncio as redis
from typing import Optional, Any
import json
from helper_bot.utils.base_dependency_factory import get_global_instance
class CacheService:
def __init__(self):
bdf = get_global_instance()
settings = bdf.get_settings()
self.redis_client = None
if settings.get('Redis', {}).get('enabled', False):
self.redis_client = redis.from_url(
settings['Redis']['url'],
decode_responses=True
)
async def get(self, key: str) -> Optional[Any]:
"""Получить значение из кэша."""
if not self.redis_client:
return None
try:
value = await self.redis_client.get(key)
if value:
return json.loads(value)
except Exception as e:
logger.error(f"Ошибка получения из кэша: {e}")
return None
async def set(self, key: str, value: Any, ttl: int = 3600):
"""Установить значение в кэш."""
if not self.redis_client:
return
try:
await self.redis_client.setex(
key,
ttl,
json.dumps(value)
)
except Exception as e:
logger.error(f"Ошибка записи в кэш: {e}")
async def delete(self, key: str):
"""Удалить значение из кэша."""
if not self.redis_client:
return
try:
await self.redis_client.delete(key)
except Exception as e:
logger.error(f"Ошибка удаления из кэша: {e}")
```
**Использование:**
```python
# В репозиториях или сервисах
cache = CacheService()
# Получение с кэшированием
async def get_admin_list(self):
cache_key = "admin_list"
cached = await cache.get(cache_key)
if cached:
return cached
# Загрузка из БД
admins = await self._load_from_db()
# Сохранение в кэш на 1 час
await cache.set(cache_key, admins, ttl=3600)
return admins
```
**Данные для кэширования:**
- Список администраторов
- Настройки пользователей (если редко меняются)
- Статистика (активные пользователи за день)
- Черный список (с коротким TTL)
**Файлы для изменения:**
- Создать `helper_bot/utils/cache.py`
- Добавить настройки Redis в `BaseDependencyFactory`
- Обновить репозитории для использования кэша
**Оценка:** Средняя сложность, требует настройки Redis инфраструктуры
---
### 9. Улучшение Type Hints
**Статус:**
**Проблема:**
Некоторые методы возвращают `dict` без указания структуры, что затрудняет понимание API и использование IDE.
**Пример:**
```python
def get_settings(self):
return self.settings # Какой тип? Dict[str, Any]?
```
**Рекомендация:**
Использовать `TypedDict` для структурированных словарей:
```python
from typing import TypedDict, Dict, Any
class TelegramSettings(TypedDict):
bot_token: str
listen_bot_token: str
preview_link: bool
main_public: str
group_for_posts: int
# ...
class SettingsDict(TypedDict):
Telegram: TelegramSettings
Settings: Dict[str, bool]
Metrics: Dict[str, Any]
S3: Dict[str, Any]
class BaseDependencyFactory:
def get_settings(self) -> SettingsDict:
return self.settings
```
**Файлы для изменения:**
- `helper_bot/utils/base_dependency_factory.py`
- Создать `helper_bot/utils/types.py` для типов
**Оценка:** Средняя сложность, требует обновления всех мест использования
---
### 10. Расширение тестового покрытия
**Статус:**
**Проблема:**
Некоторые компоненты не покрыты тестами или имеют недостаточное покрытие.
**Рекомендация:**
Добавить тесты для:
1. **Middleware:**
- `DependenciesMiddleware` - проверка внедрения зависимостей
- `BlacklistMiddleware` - проверка блокировки пользователей
- `RateLimitMiddleware` - проверка ограничений
2. **BaseDependencyFactory:**
- Инициализация с валидными настройками
- Инициализация с невалидными настройками
- Получение зависимостей
3. **Интеграционные тесты:**
- Полные сценарии обработки сообщений
- Сценарии с ошибками
- Сценарии с rate limiting
**Файлы для создания:**
- `tests/test_dependencies_middleware.py`
- `tests/test_base_dependency_factory.py`
- `tests/test_integration_handlers.py`
**Оценка:** Высокая сложность, требует времени на написание тестов
---
### 11. Улучшение логирования
**Статус:**
**Проблема:**
В коде много `logger.info()` там, где можно использовать `logger.debug()` для детальной отладки. Это приводит к засорению логов в production.
**Рекомендация:**
Пересмотреть уровни логирования:
- `logger.debug()` - детальная отладочная информация (шаги выполнения, промежуточные значения)
- `logger.info()` - важные события (старт/остановка бота, критические действия пользователей)
- `logger.warning()` - предупреждения (нестандартные ситуации, которые не критичны)
- `logger.error()` - ошибки (исключения, сбои)
**Примеры для изменения:**
```python
# Было
logger.info(f"DependenciesMiddleware: внедрены зависимости для {type(event).__name__}")
# Стало
logger.debug(f"DependenciesMiddleware: внедрены зависимости для {type(event).__name__}")
```
**Файлы для изменения:**
- Все файлы с избыточным `logger.info()`
**Оценка:** Низкая сложность, но требует времени на ревью всех логов
---
### 12. Документация проекта
**Статус:**
**Проблема:**
Отсутствует общая документация проекта, что затрудняет onboarding новых разработчиков.
**Рекомендация:**
Создать следующие документы:
1. **README.md** (в корне проекта):
- Описание проекта
- Требования
- Установка и настройка
- Запуск
- Структура проекта
2. **docs/ARCHITECTURE.md**:
- Детальное описание архитектуры
- Диаграммы компонентов
- Паттерны проектирования
3. **docs/DEPLOYMENT.md**:
- Инструкции по развертыванию
- Настройка окружения
- Мониторинг
4. **docs/DEVELOPMENT.md**:
- Руководство для разработчиков
- Процесс разработки
- Code style guide (ссылка на .cursor/rules)
**Оценка:** Средняя сложность, требует времени на написание
---
## 📊 Статистика
- **Всего задач:** 12
- **Высокий приоритет:** 3
- **Средний приоритет:** 4
- **Низкий приоритет:** 5
## 📝 Заметки
- Большинство задач высокого приоритета связаны между собой (стандартизация DI решит несколько TODO)
- Задачи среднего приоритета улучшают производительность и качество кода
- Задачи низкого приоритета улучшают developer experience и поддерживаемость
## 🔄 Обновления
- **2026-01-25:** Создан первоначальный список улучшений на основе анализа кодовой базы
- **2026-01-25:** Добавлена задача #8 по кэшированию (Redis)
- **2026-01-25:** Создан документ `PYTHON_VERSION_MANAGEMENT.md` с рекомендациями по унификации версий Python

309
docs/OPERATIONS.md Normal file
View File

@@ -0,0 +1,309 @@
# Операционные команды для управления ботом
> **⚠️ ВАЖНО:** Все команды выполняются из корневой директории проекта
## 🔧 Основные команды
### Запуск и остановка
```bash
# Запустить всю инфраструктуру (Prometheus + Бот)
docker-compose up -d
# Запустить только бота
docker-compose up -d telegram-bot
# Запустить только Prometheus
docker-compose up -d prometheus
# Остановить все сервисы
docker-compose down
# Остановить только бота
docker-compose stop telegram-bot
# Остановить только Prometheus
docker-compose stop prometheus
```
### Сборка
```bash
# Собрать все контейнеры
docker-compose build
# Собрать только бота
docker-compose build telegram-bot
# Собрать только Prometheus
docker-compose build prometheus
# Пересобрать и запустить все
docker-compose up -d --build
# Пересобрать и запустить только бота
docker-compose up -d --build telegram-bot
```
## 📊 Мониторинг и логи
### Просмотр логов
```bash
# Логи всех сервисов
docker-compose logs -f
# Логи только бота
docker-compose logs -f telegram-bot
# Логи Prometheus
docker-compose logs -f prometheus
# Логи в реальном времени (последние 100 строк)
docker-compose logs -f --tail=100
```
### Статус и здоровье
```bash
# Статус всех контейнеров
docker-compose ps
# Проверить здоровье всех сервисов
docker-compose ps | grep -E "(unhealthy|starting)"
# Проверить здоровье бота
curl -f http://localhost:8080/health || echo "❌ Бот недоступен"
# Проверить здоровье Prometheus
curl -f http://localhost:9090/-/healthy || echo "❌ Prometheus недоступен"
```
## 🔄 Управление сервисами
### Перезапуск
```bash
# Перезапустить все сервисы
docker-compose restart
# Перезапустить только бота
docker-compose restart telegram-bot
# Перезапустить только Prometheus
docker-compose restart prometheus
```
### Обновление
```bash
# Обновить код и перезапустить
git pull origin main && docker-compose up -d --build
# Обновить только бота
git pull origin main && docker-compose up -d --build telegram-bot
# Обновить только Prometheus
docker-compose pull prometheus && docker-compose up -d prometheus
```
## 🧪 Тестирование
### Запуск тестов
```bash
# Запустить все тесты
docker-compose run --rm telegram-bot sh -c "pip install --no-cache-dir -r requirements-dev.txt && pytest"
# Тесты с покрытием
docker-compose run --rm telegram-bot sh -c "pip install --no-cache-dir -r requirements-dev.txt && pytest --cov=helper_bot --cov-report=term-missing"
# Тесты только бота
docker-compose run --rm telegram-bot sh -c "pip install --no-cache-dir -r requirements-dev.txt && pytest helper_bot/"
# Тесты с HTML отчетом
docker-compose run --rm telegram-bot sh -c "pip install --no-cache-dir -r requirements-dev.txt && pytest --cov=helper_bot --cov-report=html"
# Тесты конкретного модуля
docker-compose run --rm telegram-bot sh -c "pip install --no-cache-dir -r requirements-dev.txt && pytest helper_bot/handlers/admin/"
```
## 🛠️ Разработка
### Отладка
```bash
# Проверить версию Python в контейнере
docker exec bots_telegram_bot python --version
# Открыть shell в контейнере бота
docker exec -it bots_telegram_bot sh
# Проверить установленные пакеты
docker exec bots_telegram_bot pip list
# Проверить переменные окружения
docker exec bots_telegram_bot env | grep TELEGRAM
# Проверить логи бота в реальном времени
docker exec bots_telegram_bot tail -f /app/logs/helper_bot_$(date +%Y-%m-%d).log
```
### База данных
```bash
# Создать backup базы
tar -czf "backup-$(date +%Y%m%d-%H%M%S).tar.gz" database/ logs/ .env
# Восстановить из backup
tar -xzf backup-20241231-120000.tar.gz
# Подключиться к базе данных
docker exec -it bots_telegram_bot sqlite3 /app/database/tg-bot-database.db
# Проверить размер базы данных
docker exec bots_telegram_bot ls -lh /app/database/tg-bot-database.db
# Очистить логи (⚠️ ОСТОРОЖНО!)
docker exec bots_telegram_bot find /app/logs -name "*.log" -mtime +7 -delete
```
## 🚨 Аварийные ситуации
### Диагностика
```bash
# Проверить использование ресурсов
docker stats --no-stream
# Проверить сетевые соединения
docker network ls
docker network inspect prod_bots_network
# Проверить логи ошибок
docker-compose logs | grep -i error
# Проверить использование диска
docker system df
# Проверить свободное место
docker exec bots_telegram_bot df -h
```
### Восстановление
```bash
# Принудительная перезагрузка всех сервисов
docker-compose down && docker-compose up -d
# Очистка всех контейнеров и образов
docker-compose down -v --rmi all
docker system prune -f
# Восстановление из последнего backup
ls -t backup-*.tar.gz | head -1 | xargs -I {} tar -xzf {}
# Принудительная перезагрузка только бота
docker-compose stop telegram-bot
docker-compose rm -f telegram-bot
docker-compose up -d --build telegram-bot
```
## 📱 Доступ к сервисам
### Веб-интерфейсы
- **Prometheus**: http://localhost:9090
- **Бот Health**: http://localhost:8080/health
- **Бот Metrics**: http://localhost:8080/metrics
### Полезные команды
```bash
# Открыть Prometheus в браузере
open http://localhost:9090
# Открыть метрики бота в браузере
open http://localhost:8080/metrics
# Открыть health check бота в браузере
open http://localhost:8080/health
# Проверить доступность сервисов
curl -s http://localhost:8080/health | jq . || echo "Бот недоступен"
curl -s http://localhost:9090/-/healthy || echo "Prometheus недоступен"
```
## 🔍 Отладка проблем
### Частые проблемы
```bash
# Бот не отвечает
docker-compose restart telegram-bot && docker-compose logs -f telegram-bot
# Prometheus недоступен
docker-compose restart prometheus && curl -f http://localhost:9090/-/healthy
# Проблемы с базой данных
docker exec bots_telegram_bot sqlite3 /app/database/tg-bot-database.db ".tables"
# Проблемы с сетью
docker network inspect prod_bots_network
# Проблемы с правами доступа
docker exec bots_telegram_bot ls -la /app/database/
docker exec bots_telegram_bot ls -la /app/logs/
```
### Полезные alias'ы для .bashrc/.zshrc
```bash
# Добавить в ~/.bashrc или ~/.zshrc
alias bot='cd /Users/andrejkatyhin/PycharmProjects/prod && docker-compose'
alias bot-logs='cd /Users/andrejkatyhin/PycharmProjects/prod && docker-compose logs -f telegram-bot'
alias bot-restart='cd /Users/andrejkatyhin/PycharmProjects/prod && docker-compose restart telegram-bot'
alias bot-status='cd /Users/andrejkatyhin/PycharmProjects/prod && docker-compose ps'
alias bot-shell='cd /Users/andrejkatyhin/PycharmProjects/prod && docker exec -it bots_telegram_bot sh'
alias prometheus='cd /Users/andrejkatyhin/PycharmProjects/prod && docker-compose logs -f prometheus'
```
## 📝 Примеры использования
### Типичный workflow разработки
```bash
# 1. Внести изменения в код
cd /Users/andrejkatyhin/PycharmProjects/prod/bots/telegram-helper-bot
# ... редактируем код ...
# 2. Пересобрать и перезапустить бота
cd /Users/andrejkatyhin/PycharmProjects/prod
docker-compose up -d --build telegram-bot
# 3. Проверить логи
docker-compose logs -f telegram-bot
# 4. Запустить тесты
docker-compose run --rm telegram-bot sh -c "pip install --no-cache-dir -r requirements-dev.txt && pytest helper_bot/"
```
### Мониторинг в продакшене
```bash
# Проверить здоровье всех сервисов
docker-compose ps | grep -E "(unhealthy|starting)"
# Посмотреть статус
docker-compose ps
# Проверить логи ошибок
docker-compose logs | grep -i error
# Создать backup
tar -czf "backup-$(date +%Y%m%d-%H%M%S).tar.gz" database/ logs/ .env
# Проверить метрики
curl -s http://localhost:8080/metrics | head -20
```
### Отладка проблем
```bash
# Бот не запускается
docker-compose logs telegram-bot
# Проверить конфигурацию
docker exec bots_telegram_bot cat /app/.env
# Проверить права доступа к файлам
docker exec bots_telegram_bot ls -la /app/
# Проверить сетевые соединения
docker exec bots_telegram_bot netstat -tulpn
# Проверить процессы
docker exec bots_telegram_bot ps aux
```

View File

@@ -2,7 +2,7 @@
name = "telegram-helper-bot" name = "telegram-helper-bot"
version = "1.0.0" version = "1.0.0"
description = "Telegram bot with monitoring and metrics" description = "Telegram bot with monitoring and metrics"
requires-python = ">=3.9" requires-python = ">=3.11"
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]

View File

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

View File

@@ -239,7 +239,10 @@ class TestBlacklistRepository:
blacklist_repository._execute_query_with_result.assert_called_once() blacklist_repository._execute_query_with_result.assert_called_once()
call_args = blacklist_repository._execute_query_with_result.call_args 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) assert call_args[0][1] == (0, 10)
# Проверяем логирование # Проверяем логирование
@@ -250,10 +253,10 @@ class TestBlacklistRepository:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_all_users_no_limit(self, blacklist_repository): async def test_get_all_users_no_limit(self, blacklist_repository):
"""Тест получения всех пользователей без лимитов""" """Тест получения всех пользователей без лимитов"""
# Симулируем результат запроса # Симулируем результат запроса (теперь включает ban_author)
mock_rows = [ mock_rows = [
(12345, "Нарушение правил", int(time.time()) + 86400, int(time.time())), (12345, "Нарушение правил", int(time.time()) + 86400, int(time.time()), 999),
(67890, "Постоянный бан", None, int(time.time()) - 86400) (67890, "Постоянный бан", None, int(time.time()) - 86400, None)
] ]
blacklist_repository._execute_query_with_result.return_value = mock_rows 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() blacklist_repository._execute_query_with_result.assert_called_once()
call_args = blacklist_repository._execute_query_with_result.call_args 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 запрос, без параметров assert len(call_args[0]) == 1 # Только SQL запрос, без параметров

View File

@@ -258,16 +258,15 @@ class TestSendMediaGroupMessageToPrivateChat:
# Мокаем БД # Мокаем БД
mock_db = AsyncMock() mock_db = AsyncMock()
mock_db.add_post = AsyncMock()
with patch('helper_bot.utils.helper_func.add_in_db_media_mediagroup', return_value=True): with patch('helper_bot.utils.helper_func.add_in_db_media_mediagroup', return_value=True):
result = await send_media_group_message_to_private_chat( with patch('asyncio.create_task'): # Мокаем create_task, чтобы фоновая задача не выполнялась
100, mock_message, [], mock_db, main_post_id=789 result = await send_media_group_message_to_private_chat(
) 100, mock_message, [], mock_db, main_post_id=789
)
assert result == 456 assert result == [456] # Функция возвращает список message_id
mock_message.bot.send_media_group.assert_called_once() mock_message.bot.send_media_group.assert_called_once()
mock_db.add_post.assert_called_once()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_media_group_message_media_processing_fails(self): async def test_send_media_group_message_media_processing_fails(self):
@@ -285,16 +284,15 @@ class TestSendMediaGroupMessageToPrivateChat:
# Мокаем БД # Мокаем БД
mock_db = AsyncMock() mock_db = AsyncMock()
mock_db.add_post = AsyncMock()
with patch('helper_bot.utils.helper_func.add_in_db_media_mediagroup', return_value=False): with patch('helper_bot.utils.helper_func.add_in_db_media_mediagroup', return_value=False):
result = await send_media_group_message_to_private_chat( with patch('asyncio.create_task'): # Мокаем create_task, чтобы фоновая задача не выполнялась
100, mock_message, [], mock_db, main_post_id=789 result = await send_media_group_message_to_private_chat(
) 100, mock_message, [], mock_db, main_post_id=789
)
assert result == 456 # Функция все равно возвращает message_id assert result == [456] # Функция возвращает список message_id
mock_message.bot.send_media_group.assert_called_once() mock_message.bot.send_media_group.assert_called_once()
mock_db.add_post.assert_called_once()
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -62,33 +62,38 @@ class TestPostRepository:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_tables(self, post_repository): 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 = AsyncMock()
post_repository._execute_query_with_result = AsyncMock(return_value=[]) # Для проверки столбца
await post_repository.create_tables() await post_repository.create_tables()
# Проверяем, что create_tables вызвался 3 раза (для каждой таблицы) # Проверяем, что create_tables вызвался минимум 3 раза (для каждой таблицы)
assert post_repository._execute_query.call_count == 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_queries = [q for q in all_queries if "CREATE TABLE IF NOT EXISTS post_from_telegram_suggest" in q]
post_table_call = calls[0][0][0] assert len(post_table_queries) > 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_queries[0]
assert "message_id INTEGER NOT NULL PRIMARY KEY" in post_table_call assert "created_at INTEGER NOT NULL" in post_table_queries[0]
assert "created_at INTEGER NOT NULL" in post_table_call assert "status TEXT NOT NULL DEFAULT 'suggest'" in post_table_queries[0]
assert "status TEXT NOT NULL DEFAULT 'suggest'" in post_table_call assert "is_anonymous INTEGER" in post_table_queries[0]
assert "is_anonymous INTEGER" in post_table_call assert "FOREIGN KEY (author_id) REFERENCES our_users (user_id) ON DELETE CASCADE" in post_table_queries[0]
assert "FOREIGN KEY (author_id) REFERENCES our_users (user_id) ON DELETE CASCADE" in post_table_call
# Проверяем создание таблицы контента # Проверяем создание таблицы контента
content_table_call = calls[1][0][0] content_table_queries = [q for q in all_queries if "CREATE TABLE IF NOT EXISTS content_post_from_telegram" in q]
assert "CREATE TABLE IF NOT EXISTS content_post_from_telegram" in content_table_call assert len(content_table_queries) > 0
assert "PRIMARY KEY (message_id, content_name)" in content_table_call assert "PRIMARY KEY (message_id, content_name)" in content_table_queries[0]
# Проверяем создание таблицы связей # Проверяем создание таблицы связей
link_table_call = calls[2][0][0] link_table_queries = [q for q in all_queries if "CREATE TABLE IF NOT EXISTS message_link_to_content" in q]
assert "CREATE TABLE IF NOT EXISTS message_link_to_content" in link_table_call assert len(link_table_queries) > 0
assert "PRIMARY KEY (post_id, message_id)" in link_table_call assert "PRIMARY KEY (post_id, message_id)" in link_table_queries[0]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_post_with_date(self, post_repository, sample_post): async def test_add_post_with_date(self, post_repository, sample_post):
@@ -103,7 +108,7 @@ class TestPostRepository:
query = call_args[0][0] query = call_args[0][0]
params = call_args[0][1] 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 "status" in query
assert "is_anonymous" in query assert "is_anonymous" in query
assert "VALUES (?, ?, ?, ?, ?, ?)" in query assert "VALUES (?, ?, ?, ?, ?, ?)" in query
@@ -148,9 +153,11 @@ class TestPostRepository:
await post_repository.add_post(sample_post) 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 @pytest.mark.asyncio
async def test_update_helper_message(self, post_repository): async def test_update_helper_message(self, post_repository):
@@ -174,30 +181,62 @@ class TestPostRepository:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_status_by_message_id(self, post_repository): async def test_update_status_by_message_id(self, post_repository):
"""Тест обновления статуса поста по message_id.""" """Тест обновления статуса поста по message_id."""
# Создаем таблицы перед тестом
post_repository._execute_query = AsyncMock() 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() 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 message_id = 12345
status = "approved" status = "approved"
await post_repository.update_status_by_message_id(message_id, status) await post_repository.update_status_by_message_id(message_id, status)
post_repository._execute_query.assert_called_once() # Проверяем, что conn.execute был вызван с правильными параметрами
call_args = post_repository._execute_query.call_args assert mock_conn.execute.call_count >= 1
query = call_args[0][0] update_call = mock_conn.execute.call_args_list[0]
params = call_args[0][1] query = update_call[0][0]
params = update_call[0][1]
assert "UPDATE post_from_telegram_suggest" in query assert "UPDATE post_from_telegram_suggest" in query
assert "SET status = ? WHERE message_id = ?" in query assert "SET status = ? WHERE message_id = ?" in query
assert params == (status, message_id) 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 @pytest.mark.asyncio
async def test_update_status_for_media_group_by_helper_id(self, post_repository): async def test_update_status_for_media_group_by_helper_id(self, post_repository):
"""Тест обновления статуса медиагруппы по helper_message_id.""" """Тест обновления статуса медиагруппы по helper_message_id."""
# Создаем таблицы перед тестом
post_repository._execute_query = AsyncMock() 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() 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 helper_message_id = 99999
status = "declined" status = "declined"
@@ -205,16 +244,19 @@ class TestPostRepository:
helper_message_id, status helper_message_id, status
) )
post_repository._execute_query.assert_called_once() # Проверяем, что conn.execute был вызван с правильными параметрами
call_args = post_repository._execute_query.call_args assert mock_conn.execute.call_count >= 1
query = call_args[0][0] update_call = mock_conn.execute.call_args_list[0]
params = call_args[0][1] query = update_call[0][0]
params = update_call[0][1]
assert "UPDATE post_from_telegram_suggest" in query assert "UPDATE post_from_telegram_suggest" in query
assert "SET status = ?" in query assert "SET status = ?" in query
assert "message_id = ? OR helper_text_message_id = ?" in query assert "message_id = ? OR helper_text_message_id = ?" in query
assert params == (status, helper_message_id, helper_message_id) 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 @pytest.mark.asyncio
async def test_add_post_content_success(self, post_repository): async def test_add_post_content_success(self, post_repository):
@@ -648,10 +690,12 @@ class TestPostRepository:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_tables_logs_success(self, post_repository): 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 = AsyncMock()
post_repository._execute_query_with_result = AsyncMock(return_value=[]) # Для проверки столбца
post_repository.logger = MagicMock() post_repository.logger = MagicMock()
await post_repository.create_tables() await post_repository.create_tables()
post_repository.logger.info.assert_called_once_with("Таблицы для постов созданы") # Проверяем, что финальное сообщение о создании таблиц было вызвано
post_repository.logger.info.assert_any_call("Таблицы для постов созданы")

View File

@@ -19,6 +19,7 @@ class TestPostService:
db.add_post = AsyncMock() db.add_post = AsyncMock()
db.update_helper_message = AsyncMock() db.update_helper_message = AsyncMock()
db.get_user_by_id = AsyncMock() db.get_user_by_id = AsyncMock()
db.add_message_link = AsyncMock()
return db return db
@pytest.fixture @pytest.fixture
@@ -60,8 +61,11 @@ class TestPostService:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_handle_text_post_saves_raw_text(self, post_service, mock_message, mock_db): 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""" """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.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_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.get_reply_keyboard_for_post', return_value=None):
with patch('helper_bot.handlers.private.services.determine_anonymity', return_value=False): 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): async def test_handle_text_post_determines_anonymity(self, post_service, mock_message, mock_db):
"""Test that handle_text_post determines anonymity correctly""" """Test that handle_text_post determines anonymity correctly"""
mock_message.text = "Тестовый пост анон" 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.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_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.get_reply_keyboard_for_post', return_value=None):
with patch('helper_bot.handlers.private.services.determine_anonymity', return_value=True): with patch('helper_bot.handlers.private.services.determine_anonymity', return_value=True):
@@ -241,14 +247,17 @@ class TestPostService:
album = [Mock()] album = [Mock()]
album[0].caption = "Медиагруппа подпись" 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.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.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_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.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('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") await post_service.handle_media_group_post(mock_message, album, "Test")
@@ -257,7 +266,7 @@ class TestPostService:
main_post = calls[0][0][0] main_post = calls[0][0][0]
assert main_post.text == "Медиагруппа подпись" # Raw caption 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 assert main_post.is_anonymous is True
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -269,14 +278,17 @@ class TestPostService:
album = [Mock()] album = [Mock()]
album[0].caption = None 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.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.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_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.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('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") await post_service.handle_media_group_post(mock_message, album, "Test")

View File

@@ -172,7 +172,7 @@ class TestAdminService:
# Act & Assert # Act & Assert
with pytest.raises(UserAlreadyBannedError, match=f"Пользователь {user_id} уже заблокирован"): 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 @pytest.mark.asyncio
async def test_ban_user_permanent(self): async def test_ban_user_permanent(self):

View File

@@ -89,7 +89,6 @@ class TestHelperFunctions:
"""Тест функции get_text_message с is_anonymous=True""" """Тест функции get_text_message с is_anonymous=True"""
text = "Тестовый пост" text = "Тестовый пост"
result = get_text_message(text, "Test", "testuser", is_anonymous=True) result = get_text_message(text, "Test", "testuser", is_anonymous=True)
assert "Пост из ТГ:" in result
assert "Тестовый пост" in result assert "Тестовый пост" in result
assert "Пост опубликован анонимно" in result assert "Пост опубликован анонимно" in result
assert "Автор поста" not in result assert "Автор поста" not in result
@@ -98,7 +97,6 @@ class TestHelperFunctions:
"""Тест функции get_text_message с is_anonymous=False""" """Тест функции get_text_message с is_anonymous=False"""
text = "Тестовый пост" text = "Тестовый пост"
result = get_text_message(text, "Test", "testuser", is_anonymous=False) result = get_text_message(text, "Test", "testuser", is_anonymous=False)
assert "Пост из ТГ:" in result
assert "Тестовый пост" in result assert "Тестовый пост" in result
assert "Автор поста" in result assert "Автор поста" in result
assert "Test" in result assert "Test" in result
@@ -110,14 +108,12 @@ class TestHelperFunctions:
# Тест с "анон" в тексте # Тест с "анон" в тексте
text = "Тестовый пост анон" text = "Тестовый пост анон"
result = get_text_message(text, "Test", "testuser", is_anonymous=None) result = get_text_message(text, "Test", "testuser", is_anonymous=None)
assert "Пост из ТГ:" in result
assert "Тестовый пост анон" in result assert "Тестовый пост анон" in result
assert "Пост опубликован анонимно" in result assert "Пост опубликован анонимно" in result
# Тест с "неанон" в тексте # Тест с "неанон" в тексте
text = "Тестовый пост неанон" text = "Тестовый пост неанон"
result = get_text_message(text, "Test", "testuser", is_anonymous=None) result = get_text_message(text, "Test", "testuser", is_anonymous=None)
assert "Пост из ТГ:" in result
assert "Тестовый пост неанон" in result assert "Тестовый пост неанон" in result
assert "Автор поста" in result assert "Автор поста" in result
@@ -579,13 +575,14 @@ class TestSendMessageFunctions:
mock_sent_message.message_id = 456 mock_sent_message.message_id = 456
mock_message.bot.send_message.return_value = mock_sent_message mock_message.bot.send_message.return_value = mock_sent_message
result = await send_text_message(123, mock_message, "Тестовое сообщение") # Мокаем 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
assert result == 456 result = await send_text_message(123, mock_message, "Тестовое сообщение")
mock_message.bot.send_message.assert_called_once_with(
chat_id=123, assert result == mock_sent_message
text="Тестовое сообщение" assert result.message_id == 456
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_text_message_with_markup(self): async def test_send_text_message_with_markup(self):
@@ -599,14 +596,14 @@ class TestSendMessageFunctions:
mock_sent_message.message_id = 456 mock_sent_message.message_id = 456
mock_message.bot.send_message.return_value = mock_sent_message mock_message.bot.send_message.return_value = mock_sent_message
result = await send_text_message(123, mock_message, "Тестовое сообщение", 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
assert result == 456 result = await send_text_message(123, mock_message, "Тестовое сообщение", mock_markup)
mock_message.bot.send_message.assert_called_once_with(
chat_id=123, assert result == mock_sent_message
text="Тестовое сообщение", assert result.message_id == 456
reply_markup=mock_markup
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_photo_message(self): async def test_send_photo_message(self):