Dev 11 #13

Merged
KerradKerridi merged 5 commits from dev-11 into master 2026-01-25 13:22:31 +00:00
37 changed files with 3722 additions and 665 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()`** для локальной регистрации на уровне модуля

View File

@@ -0,0 +1,124 @@
# Инструкция по оформлению Release Notes
## Назначение
Этот документ описывает структуру и формат для создания файлов Release Notes (например, `docs/RELEASE_NOTES_DEV-XX.md`).
## Структура документа
### 1. Заголовок
```markdown
# Release Notes: [название-ветки]
```
### 2. Обзор
Краткий абзац (1-2 предложения), описывающий:
- Количество коммитов в ветке
- Основные направления изменений
**Формат:**
```markdown
## Обзор
Ветка [название] содержит [N] коммитов с ключевыми улучшениями: [краткое перечисление основных изменений].
```
### 3. Ключевые изменения
Основной раздел с пронумерованными подразделами для каждого значимого изменения.
**Структура каждого подраздела:**
```markdown
### [Номер]. [Название изменения]
**Коммит:** `[hash]`
**Что сделано:**
- [Краткое описание изменения 1]
- [Краткое описание изменения 2]
- [Краткое описание изменения 3]
```
**Правила:**
- Каждое изменение = отдельный подраздел
- Название должно быть кратким и понятным
- В разделе "Что сделано" используй маркированные списки
- НЕ перечисляй затронутые файлы
- НЕ указывай статистику строк кода
- Фокусируйся на сути изменений, а не на технических деталях
- Разделяй подразделы горизонтальной линией `---`
### 4. Основные достижения
Раздел с чекбоксами, подводящий итоги релиза.
**Формат:**
```markdown
## 🎯 Основные достижения
✅ [Достижение 1]
✅ [Достижение 2]
✅ [Достижение 3]
```
**Правила:**
- Используй эмодзи ✅ для каждого достижения
- Каждое достижение на отдельной строке
- Краткие формулировки (3-5 слов)
- Фокусируйся на ключевых фичах и улучшениях
### 5. Временная шкала разработки
Раздел с информацией о сроках разработки.
**Формат:**
```markdown
## 📅 Временная шкала разработки
**Последние изменения:** [дата]
**Основная разработка:** [период]
**Предыдущие улучшения:** [контекст предыдущих веток/изменений]
**Хронология коммитов:**
- `[hash]` - [дата и время] - [краткое описание]
- `[hash]` - [дата и время] - [краткое описание]
```
**Правила:**
- Используй реальные даты из коммитов
- Формат даты: "DD месяц YYYY" (например, "25 января 2026")
- Для времени используй формат "HH:MM"
- Хронология должна быть в хронологическом порядке (от старых к новым)
## Стиль написания
### Общие правила:
- **Краткость**: Фокусируйся на сути, избегай избыточных деталей
- **Ясность**: Используй простые и понятные формулировки
- **Структурированность**: Информация должна быть легко читаемой и сканируемой
- **Без технических деталей**: Не перечисляй файлы, классы, методы (только если это ключевая фича)
- **Без статистики**: Не указывай количество строк кода, файлов и т.д.
### Язык:
- Используй прошедшее время для описания изменений ("Добавлена", "Реализована", "Обновлена")
- Избегай технического жаргона, если это не необходимо
- Используй активный залог
### Эмодзи:
- 🔥 для раздела "Ключевые изменения"
- 🎯 для раздела "Основные достижения"
- 📅 для раздела "Временная шкала разработки"
- ✅ для чекбоксов достижений
## Пример использования
При создании Release Notes для новой ветки:
1. Получи список коммитов: `git log [base-branch]..[target-branch] --oneline`
2. Для каждого значимого коммита создай подраздел в "Ключевые изменения"
3. Собери основные достижения в раздел "Основные достижения"
4. Добавь временную шкалу с реальными датами коммитов
5. Проверь, что документ следует структуре и стилю
## Важные замечания
- **НЕ включай** информацию о коммитах, которые уже были в базовой ветке (master/main)
- **НЕ перечисляй** все файлы, которые были изменены
- **НЕ указывай** статистику строк кода
- **Фокусируйся** на функциональных изменениях, а не на технических деталях реализации
- Используй **реальные даты** из коммитов, а не предполагаемые

197
.cursor/rules/testing.md Normal file
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 тестирование разных конфигураций
- Интеграция с системой алертов

View File

@@ -139,6 +139,10 @@ class AsyncBotDB:
"""Добавление контента поста.""" """Добавление контента поста."""
return await self.factory.posts.add_post_content(post_id, message_id, content_name, content_type) return await self.factory.posts.add_post_content(post_id, message_id, content_name, content_type)
async def add_message_link(self, post_id: int, message_id: int) -> bool:
"""Добавляет связь между post_id и message_id в таблицу message_link_to_content."""
return await self.factory.posts.add_message_link(post_id, message_id)
async def get_post_content_from_telegram_by_last_id(self, last_post_id: int) -> List[Tuple[str, str]]: async def get_post_content_from_telegram_by_last_id(self, last_post_id: int) -> List[Tuple[str, str]]:
"""Получает контент поста по helper_text_message_id.""" """Получает контент поста по helper_text_message_id."""
return await self.factory.posts.get_post_content_by_helper_id(last_post_id) return await self.factory.posts.get_post_content_by_helper_id(last_post_id)
@@ -147,6 +151,22 @@ class AsyncBotDB:
"""Алиас для get_post_content_from_telegram_by_last_id (используется callback-сервисом).""" """Алиас для get_post_content_from_telegram_by_last_id (используется callback-сервисом)."""
return await self.get_post_content_from_telegram_by_last_id(helper_message_id) return await self.get_post_content_from_telegram_by_last_id(helper_message_id)
async def get_post_content_by_message_id(self, message_id: int) -> List[Tuple[str, str]]:
"""Получает контент одиночного поста по message_id."""
return await self.factory.posts.get_post_content_by_message_id(message_id)
async def update_published_message_id(self, original_message_id: int, published_message_id: int):
"""Обновляет published_message_id для опубликованного поста."""
await self.factory.posts.update_published_message_id(original_message_id, published_message_id)
async def add_published_post_content(self, published_message_id: int, content_path: str, content_type: str):
"""Добавляет контент опубликованного поста."""
return await self.factory.posts.add_published_post_content(published_message_id, content_path, content_type)
async def get_published_post_content(self, published_message_id: int) -> List[Tuple[str, str]]:
"""Получает контент опубликованного поста."""
return await self.factory.posts.get_published_post_content(published_message_id)
async def get_post_text_from_telegram_by_last_id(self, last_post_id: int) -> Optional[str]: async def get_post_text_from_telegram_by_last_id(self, last_post_id: int) -> Optional[str]:
"""Получает текст поста по helper_text_message_id.""" """Получает текст поста по helper_text_message_id."""
return await self.factory.posts.get_post_text_by_helper_id(last_post_id) return await self.factory.posts.get_post_text_by_helper_id(last_post_id)
@@ -159,6 +179,10 @@ class AsyncBotDB:
"""Получает ID сообщений по helper_text_message_id.""" """Получает ID сообщений по helper_text_message_id."""
return await self.factory.posts.get_post_ids_by_helper_id(last_post_id) return await self.factory.posts.get_post_ids_by_helper_id(last_post_id)
async def get_post_ids_by_helper_id(self, helper_message_id: int) -> List[int]:
"""Алиас для get_post_ids_from_telegram_by_last_id (используется callback-сервисом)."""
return await self.get_post_ids_from_telegram_by_last_id(helper_message_id)
async def get_author_id_by_message_id(self, message_id: int) -> Optional[int]: async def get_author_id_by_message_id(self, message_id: int) -> Optional[int]:
"""Получает ID автора по message_id.""" """Получает ID автора по message_id."""
return await self.factory.posts.get_author_id_by_message_id(message_id) return await self.factory.posts.get_author_id_by_message_id(message_id)

View File

@@ -19,11 +19,31 @@ class PostRepository(DatabaseConnection):
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'suggest', status TEXT NOT NULL DEFAULT 'suggest',
is_anonymous INTEGER, is_anonymous INTEGER,
published_message_id INTEGER,
FOREIGN KEY (author_id) REFERENCES our_users (user_id) ON DELETE CASCADE FOREIGN KEY (author_id) REFERENCES our_users (user_id) ON DELETE CASCADE
) )
''' '''
await self._execute_query(post_query) await self._execute_query(post_query)
# Добавляем поле published_message_id если его нет (для существующих БД)
try:
check_column_query = """
SELECT name FROM pragma_table_info('post_from_telegram_suggest')
WHERE name = 'published_message_id'
"""
existing_columns = await self._execute_query_with_result(check_column_query)
if not existing_columns:
await self._execute_query('ALTER TABLE post_from_telegram_suggest ADD COLUMN published_message_id INTEGER')
self.logger.info("Столбец published_message_id добавлен в post_from_telegram_suggest")
except Exception as e:
# Если проверка не удалась, пытаемся добавить столбец (может быть уже существует)
try:
await self._execute_query('ALTER TABLE post_from_telegram_suggest ADD COLUMN published_message_id INTEGER')
self.logger.info("Столбец published_message_id добавлен в post_from_telegram_suggest (fallback)")
except Exception:
# Столбец уже существует, игнорируем ошибку
pass
# Таблица контента постов # Таблица контента постов
content_query = ''' content_query = '''
CREATE TABLE IF NOT EXISTS content_post_from_telegram ( CREATE TABLE IF NOT EXISTS content_post_from_telegram (
@@ -47,6 +67,26 @@ class PostRepository(DatabaseConnection):
''' '''
await self._execute_query(link_query) await self._execute_query(link_query)
# Таблица контента опубликованных постов
published_content_query = '''
CREATE TABLE IF NOT EXISTS published_post_content (
published_message_id INTEGER NOT NULL,
content_name TEXT NOT NULL,
content_type TEXT,
published_at INTEGER NOT NULL,
PRIMARY KEY (published_message_id, content_name)
)
'''
await self._execute_query(published_content_query)
# Создаем индексы
try:
await self._execute_query('CREATE INDEX IF NOT EXISTS idx_published_post_content_message_id ON published_post_content(published_message_id)')
await self._execute_query('CREATE INDEX IF NOT EXISTS idx_post_from_telegram_suggest_published ON post_from_telegram_suggest(published_message_id)')
except Exception:
# Индексы уже существуют, игнорируем ошибку
pass
self.logger.info("Таблицы для постов созданы") self.logger.info("Таблицы для постов созданы")
async def add_post(self, post: TelegramPost) -> None: async def add_post(self, post: TelegramPost) -> None:
@@ -57,14 +97,15 @@ class PostRepository(DatabaseConnection):
# Преобразуем bool в int для SQLite (True -> 1, False -> 0, None -> None) # Преобразуем bool в int для SQLite (True -> 1, False -> 0, None -> None)
is_anonymous_int = None if post.is_anonymous is None else (1 if post.is_anonymous else 0) is_anonymous_int = None if post.is_anonymous is None else (1 if post.is_anonymous else 0)
# Используем INSERT OR IGNORE чтобы избежать ошибок при повторном создании
query = """ query = """
INSERT INTO post_from_telegram_suggest (message_id, text, author_id, created_at, status, is_anonymous) INSERT OR IGNORE INTO post_from_telegram_suggest (message_id, text, author_id, created_at, status, is_anonymous)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
""" """
params = (post.message_id, post.text, post.author_id, post.created_at, status, is_anonymous_int) params = (post.message_id, post.text, post.author_id, post.created_at, status, is_anonymous_int)
await self._execute_query(query, params) await self._execute_query(query, params)
self.logger.info(f"Пост добавлен: message_id={post.message_id}") self.logger.info(f"Пост добавлен (или уже существует): message_id={post.message_id}, text длина={len(post.text) if post.text else 0}, is_anonymous={is_anonymous_int}")
async def update_helper_message(self, message_id: int, helper_message_id: int) -> None: async def update_helper_message(self, message_id: int, helper_message_id: int) -> None:
"""Обновление helper сообщения.""" """Обновление helper сообщения."""
@@ -160,6 +201,18 @@ class PostRepository(DatabaseConnection):
self.logger.error(f"Ошибка при добавлении контента поста: {e}") self.logger.error(f"Ошибка при добавлении контента поста: {e}")
return False return False
async def add_message_link(self, post_id: int, message_id: int) -> bool:
"""Добавляет связь между post_id и message_id в таблицу message_link_to_content."""
try:
self.logger.info(f"Добавление связи: post_id={post_id}, message_id={message_id}")
link_query = "INSERT OR IGNORE INTO message_link_to_content (post_id, message_id) VALUES (?, ?)"
await self._execute_query(link_query, (post_id, message_id))
self.logger.info(f"Связь успешно добавлена: post_id={post_id}, message_id={message_id}")
return True
except Exception as e:
self.logger.error(f"Ошибка при добавлении связи post_id={post_id}, message_id={message_id}: {e}")
return False
async def get_post_content_by_helper_id(self, helper_message_id: int) -> List[Tuple[str, str]]: async def get_post_content_by_helper_id(self, helper_message_id: int) -> List[Tuple[str, str]]:
"""Получает контент поста по helper_text_message_id.""" """Получает контент поста по helper_text_message_id."""
query = """ query = """
@@ -174,6 +227,20 @@ class PostRepository(DatabaseConnection):
self.logger.info(f"Получен контент поста: {len(post_content)} элементов") self.logger.info(f"Получен контент поста: {len(post_content)} элементов")
return post_content return post_content
async def get_post_content_by_message_id(self, message_id: int) -> List[Tuple[str, str]]:
"""Получает контент одиночного поста по message_id."""
query = """
SELECT cpft.content_name, cpft.content_type
FROM post_from_telegram_suggest pft
JOIN message_link_to_content mltc ON pft.message_id = mltc.post_id
JOIN content_post_from_telegram cpft ON cpft.message_id = mltc.message_id
WHERE pft.message_id = ? AND pft.helper_text_message_id IS NULL
"""
post_content = await self._execute_query_with_result(query, (message_id,))
self.logger.info(f"Получен контент одиночного поста: {len(post_content)} элементов для message_id={message_id}")
return post_content
async def get_post_text_by_helper_id(self, helper_message_id: int) -> Optional[str]: async def get_post_text_by_helper_id(self, helper_message_id: int) -> Optional[str]:
"""Получает текст поста по helper_text_message_id.""" """Получает текст поста по helper_text_message_id."""
query = "SELECT text FROM post_from_telegram_suggest WHERE helper_text_message_id = ?" query = "SELECT text FROM post_from_telegram_suggest WHERE helper_text_message_id = ?"
@@ -252,3 +319,40 @@ class PostRepository(DatabaseConnection):
self.logger.info(f"Получены текст и is_anonymous для helper_message_id={helper_message_id}") self.logger.info(f"Получены текст и is_anonymous для helper_message_id={helper_message_id}")
return text, is_anonymous return text, is_anonymous
return None, None return None, None
async def update_published_message_id(self, original_message_id: int, published_message_id: int) -> None:
"""Обновляет published_message_id для опубликованного поста."""
query = "UPDATE post_from_telegram_suggest SET published_message_id = ? WHERE message_id = ?"
await self._execute_query(query, (published_message_id, original_message_id))
self.logger.info(f"Обновлен published_message_id: {original_message_id} -> {published_message_id}")
async def add_published_post_content(
self, published_message_id: int, content_path: str, content_type: str
) -> bool:
"""Добавляет контент опубликованного поста."""
try:
from datetime import datetime
published_at = int(datetime.now().timestamp())
query = """
INSERT OR IGNORE INTO published_post_content
(published_message_id, content_name, content_type, published_at)
VALUES (?, ?, ?, ?)
"""
await self._execute_query(query, (published_message_id, content_path, content_type, published_at))
self.logger.info(f"Добавлен контент опубликованного поста: published_message_id={published_message_id}, type={content_type}")
return True
except Exception as e:
self.logger.error(f"Ошибка при добавлении контента опубликованного поста: {e}")
return False
async def get_published_post_content(self, published_message_id: int) -> List[Tuple[str, str]]:
"""Получает контент опубликованного поста."""
query = """
SELECT content_name, content_type
FROM published_post_content
WHERE published_message_id = ?
"""
post_content = await self._execute_query_with_result(query, (published_message_id,))
self.logger.info(f"Получен контент опубликованного поста: {len(post_content)} элементов для published_message_id={published_message_id}")
return post_content

View File

@@ -73,6 +73,7 @@ CREATE TABLE IF NOT EXISTS post_from_telegram_suggest (
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'suggest', status TEXT NOT NULL DEFAULT 'suggest',
is_anonymous INTEGER, is_anonymous INTEGER,
published_message_id INTEGER,
FOREIGN KEY (author_id) REFERENCES our_users(user_id) ON DELETE CASCADE FOREIGN KEY (author_id) REFERENCES our_users(user_id) ON DELETE CASCADE
); );
@@ -93,6 +94,15 @@ CREATE TABLE IF NOT EXISTS content_post_from_telegram (
FOREIGN KEY (message_id) REFERENCES post_from_telegram_suggest(message_id) ON DELETE CASCADE FOREIGN KEY (message_id) REFERENCES post_from_telegram_suggest(message_id) ON DELETE CASCADE
); );
-- Content of published posts
CREATE TABLE IF NOT EXISTS published_post_content (
published_message_id INTEGER NOT NULL,
content_name TEXT NOT NULL,
content_type TEXT,
published_at INTEGER NOT NULL,
PRIMARY KEY (published_message_id, content_name)
);
-- Bot users information (user_id is now PRIMARY KEY) -- Bot users information (user_id is now PRIMARY KEY)
CREATE TABLE IF NOT EXISTS our_users ( CREATE TABLE IF NOT EXISTS our_users (
user_id INTEGER NOT NULL PRIMARY KEY, user_id INTEGER NOT NULL PRIMARY KEY,
@@ -130,3 +140,5 @@ CREATE INDEX IF NOT EXISTS idx_user_messages_date ON user_messages(date);
CREATE INDEX IF NOT EXISTS idx_audio_message_reference_date ON audio_message_reference(date_added); CREATE INDEX IF NOT EXISTS idx_audio_message_reference_date ON audio_message_reference(date_added);
CREATE INDEX IF NOT EXISTS idx_post_from_telegram_suggest_date ON post_from_telegram_suggest(created_at); CREATE INDEX IF NOT EXISTS idx_post_from_telegram_suggest_date ON post_from_telegram_suggest(created_at);
CREATE INDEX IF NOT EXISTS idx_our_users_date_changed ON our_users(date_changed); CREATE INDEX IF NOT EXISTS idx_our_users_date_changed ON our_users(date_changed);
CREATE INDEX IF NOT EXISTS idx_published_post_content_message_id ON published_post_content(published_message_id);
CREATE INDEX IF NOT EXISTS idx_post_from_telegram_suggest_published ON post_from_telegram_suggest(published_message_id);

710
docs/IMPROVEMENTS.md Normal file
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

@@ -12,6 +12,14 @@ IMPORTANT_LOGS=-1001234567890
ARCHIVE=-1001234567890 ARCHIVE=-1001234567890
TEST_GROUP=-1001234567890 TEST_GROUP=-1001234567890
# S3 Storage (для хранения медиафайлов опубликованных постов)
S3_ENABLED=false
S3_ENDPOINT_URL=https://api.s3.ru
S3_ACCESS_KEY=your_s3_access_key_here
S3_SECRET_KEY=your_s3_secret_key_here
S3_BUCKET_NAME=your_s3_bucket_name
S3_REGION=us-east-1
# Bot Settings # Bot Settings
PREVIEW_LINK=false PREVIEW_LINK=false
LOGS=false LOGS=false

View File

@@ -13,7 +13,8 @@ def get_post_publish_service() -> PostPublishService:
db = bdf.get_db() db = bdf.get_db()
settings = bdf.settings settings = bdf.settings
return PostPublishService(None, db, settings) s3_storage = bdf.get_s3_storage()
return PostPublishService(None, db, settings, s3_storage)
def get_ban_service() -> BanService: def get_ban_service() -> BanService:

View File

@@ -3,6 +3,7 @@ import html
from typing import Dict, Any from typing import Dict, Any
from aiogram import Bot from aiogram import Bot
from aiogram import types
from aiogram.types import CallbackQuery from aiogram.types import CallbackQuery
from helper_bot.utils.helper_func import ( from helper_bot.utils.helper_func import (
@@ -33,11 +34,12 @@ from helper_bot.utils.metrics import (
class PostPublishService: class PostPublishService:
def __init__(self, bot: Bot, db, settings: Dict[str, Any]): def __init__(self, bot: Bot, db, settings: Dict[str, Any], s3_storage=None):
# bot может быть None - в этом случае используем бота из контекста сообщения # bot может быть None - в этом случае используем бота из контекста сообщения
self.bot = bot self.bot = bot
self.db = db self.db = db
self.settings = settings self.settings = settings
self.s3_storage = s3_storage
self.group_for_posts = settings['Telegram']['group_for_posts'] self.group_for_posts = settings['Telegram']['group_for_posts']
self.main_public = settings['Telegram']['main_public'] self.main_public = settings['Telegram']['main_public']
self.important_logs = settings['Telegram']['important_logs'] self.important_logs = settings['Telegram']['important_logs']
@@ -52,7 +54,12 @@ class PostPublishService:
@track_errors("post_publish_service", "publish_post") @track_errors("post_publish_service", "publish_post")
async def publish_post(self, call: CallbackQuery) -> None: async def publish_post(self, call: CallbackQuery) -> None:
"""Основной метод публикации поста""" """Основной метод публикации поста"""
# Проверяем, является ли сообщение частью медиагруппы # Проверяем, является ли сообщение helper-сообщением медиагруппы
if call.message.text == CONTENT_TYPE_MEDIA_GROUP:
await self._publish_media_group(call)
return
# Проверяем, является ли сообщение частью медиагруппы (для обратной совместимости)
if call.message.media_group_id: if call.message.media_group_id:
await self._publish_media_group(call) await self._publish_media_group(call)
return return
@@ -98,9 +105,16 @@ class PostPublishService:
# Формируем финальный текст с учетом is_anonymous # Формируем финальный текст с учетом is_anonymous
formatted_text = get_text_message(raw_text, user.first_name, user.username, is_anonymous) formatted_text = get_text_message(raw_text, user.first_name, user.username, is_anonymous)
await send_text_message(self.main_public, call.message, formatted_text) sent_message = await send_text_message(self.main_public, call.message, formatted_text)
# Сохраняем published_message_id
await self.db.update_published_message_id(
original_message_id=call.message.message_id,
published_message_id=sent_message.message_id
)
await self._delete_post_and_notify_author(call, author_id) await self._delete_post_and_notify_author(call, author_id)
logger.info(f'Текст сообщения опубликован в канале {self.main_public}.') logger.info(f'Текст сообщение опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}.')
@track_time("_publish_photo_post", "post_publish_service") @track_time("_publish_photo_post", "post_publish_service")
@track_errors("post_publish_service", "_publish_photo_post") @track_errors("post_publish_service", "_publish_photo_post")
@@ -126,9 +140,19 @@ class PostPublishService:
# Формируем финальный текст с учетом is_anonymous # Формируем финальный текст с учетом is_anonymous
formatted_text = get_text_message(raw_text, user.first_name, user.username, is_anonymous) formatted_text = get_text_message(raw_text, user.first_name, user.username, is_anonymous)
await send_photo_message(self.main_public, call.message, call.message.photo[-1].file_id, formatted_text) sent_message = await send_photo_message(self.main_public, call.message, call.message.photo[-1].file_id, formatted_text)
# Сохраняем published_message_id
await self.db.update_published_message_id(
original_message_id=call.message.message_id,
published_message_id=sent_message.message_id
)
# Сохраняем медиафайл из опубликованного поста (используем уже сохраненный файл)
await self._save_published_post_content(sent_message, sent_message.message_id, call.message.message_id)
await self._delete_post_and_notify_author(call, author_id) await self._delete_post_and_notify_author(call, author_id)
logger.info(f'Пост с фото опубликован в канале {self.main_public}.') logger.info(f'Пост с фото опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}.')
@track_time("_publish_video_post", "post_publish_service") @track_time("_publish_video_post", "post_publish_service")
@track_errors("post_publish_service", "_publish_video_post") @track_errors("post_publish_service", "_publish_video_post")
@@ -154,9 +178,19 @@ class PostPublishService:
# Формируем финальный текст с учетом is_anonymous # Формируем финальный текст с учетом is_anonymous
formatted_text = get_text_message(raw_text, user.first_name, user.username, is_anonymous) formatted_text = get_text_message(raw_text, user.first_name, user.username, is_anonymous)
await send_video_message(self.main_public, call.message, call.message.video.file_id, formatted_text) sent_message = await send_video_message(self.main_public, call.message, call.message.video.file_id, formatted_text)
# Сохраняем published_message_id
await self.db.update_published_message_id(
original_message_id=call.message.message_id,
published_message_id=sent_message.message_id
)
# Сохраняем медиафайл из опубликованного поста (используем уже сохраненный файл)
await self._save_published_post_content(sent_message, sent_message.message_id, call.message.message_id)
await self._delete_post_and_notify_author(call, author_id) await self._delete_post_and_notify_author(call, author_id)
logger.info(f'Пост с видео опубликован в канале {self.main_public}.') logger.info(f'Пост с видео опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}.')
@track_time("_publish_video_note_post", "post_publish_service") @track_time("_publish_video_note_post", "post_publish_service")
@track_errors("post_publish_service", "_publish_video_note_post") @track_errors("post_publish_service", "_publish_video_note_post")
@@ -169,9 +203,19 @@ class PostPublishService:
logger.error(f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'") logger.error(f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'")
raise PostNotFoundError(f"Пост с message_id={call.message.message_id} не найден в базе данных") raise PostNotFoundError(f"Пост с message_id={call.message.message_id} не найден в базе данных")
await send_video_note_message(self.main_public, call.message, call.message.video_note.file_id) sent_message = await send_video_note_message(self.main_public, call.message, call.message.video_note.file_id)
# Сохраняем published_message_id
await self.db.update_published_message_id(
original_message_id=call.message.message_id,
published_message_id=sent_message.message_id
)
# Сохраняем медиафайл из опубликованного поста (используем уже сохраненный файл)
await self._save_published_post_content(sent_message, sent_message.message_id, call.message.message_id)
await self._delete_post_and_notify_author(call, author_id) await self._delete_post_and_notify_author(call, author_id)
logger.info(f'Пост с кружком опубликован в канале {self.main_public}.') logger.info(f'Пост с кружком опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}.')
@track_time("_publish_audio_post", "post_publish_service") @track_time("_publish_audio_post", "post_publish_service")
@track_errors("post_publish_service", "_publish_audio_post") @track_errors("post_publish_service", "_publish_audio_post")
@@ -197,9 +241,19 @@ class PostPublishService:
# Формируем финальный текст с учетом is_anonymous # Формируем финальный текст с учетом is_anonymous
formatted_text = get_text_message(raw_text, user.first_name, user.username, is_anonymous) formatted_text = get_text_message(raw_text, user.first_name, user.username, is_anonymous)
await send_audio_message(self.main_public, call.message, call.message.audio.file_id, formatted_text) sent_message = await send_audio_message(self.main_public, call.message, call.message.audio.file_id, formatted_text)
# Сохраняем published_message_id
await self.db.update_published_message_id(
original_message_id=call.message.message_id,
published_message_id=sent_message.message_id
)
# Сохраняем медиафайл из опубликованного поста (используем уже сохраненный файл)
await self._save_published_post_content(sent_message, sent_message.message_id, call.message.message_id)
await self._delete_post_and_notify_author(call, author_id) await self._delete_post_and_notify_author(call, author_id)
logger.info(f'Пост с аудио опубликован в канале {self.main_public}.') logger.info(f'Пост с аудио опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}.')
@track_time("_publish_voice_post", "post_publish_service") @track_time("_publish_voice_post", "post_publish_service")
@track_errors("post_publish_service", "_publish_voice_post") @track_errors("post_publish_service", "_publish_voice_post")
@@ -212,67 +266,112 @@ class PostPublishService:
logger.error(f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'") logger.error(f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'")
raise PostNotFoundError(f"Пост с message_id={call.message.message_id} не найден в базе данных") raise PostNotFoundError(f"Пост с message_id={call.message.message_id} не найден в базе данных")
await send_voice_message(self.main_public, call.message, call.message.voice.file_id) sent_message = await send_voice_message(self.main_public, call.message, call.message.voice.file_id)
# Сохраняем published_message_id
await self.db.update_published_message_id(
original_message_id=call.message.message_id,
published_message_id=sent_message.message_id
)
# Сохраняем медиафайл из опубликованного поста (используем уже сохраненный файл)
await self._save_published_post_content(sent_message, sent_message.message_id, call.message.message_id)
await self._delete_post_and_notify_author(call, author_id) await self._delete_post_and_notify_author(call, author_id)
logger.info(f'Пост с войсом опубликован в канале {self.main_public}.') logger.info(f'Пост с войсом опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}.')
@track_time("_publish_media_group", "post_publish_service") @track_time("_publish_media_group", "post_publish_service")
@track_errors("post_publish_service", "_publish_media_group") @track_errors("post_publish_service", "_publish_media_group")
@track_media_processing("media_group") @track_media_processing("media_group")
async def _publish_media_group(self, call: CallbackQuery) -> None: async def _publish_media_group(self, call: CallbackQuery) -> None:
"""Публикация медиагруппы""" """Публикация медиагруппы"""
logger.info(f"Начинаю публикацию медиагруппы. Helper message ID: {call.message.message_id}")
try: try:
# call.message.message_id - это ID helper сообщения
helper_message_id = call.message.message_id helper_message_id = call.message.message_id
# Получаем контент медиагруппы по helper_message_id media_group_message_ids = await self.db.get_post_ids_by_helper_id(helper_message_id)
logger.debug(f"Получаю контент медиагруппы для helper_message_id: {helper_message_id}") if not media_group_message_ids:
logger.error(f"_publish_media_group: Не найдены message_id медиагруппы для helper_message_id={helper_message_id}")
raise PublishError("Не найдены message_id медиагруппы в базе данных")
post_content = await self.db.get_post_content_by_helper_id(helper_message_id) post_content = await self.db.get_post_content_by_helper_id(helper_message_id)
if not post_content: if not post_content:
logger.error(f"Контент медиагруппы не найден в базе данных для helper_message_id: {helper_message_id}") logger.error(f"_publish_media_group: Контент медиагруппы не найден в базе данных для helper_message_id={helper_message_id}")
raise PublishError("Контент медиагруппы не найден в базе данных") raise PublishError("Контент медиагруппы не найден в базе данных")
# Получаем сырой текст и is_anonymous по helper_message_id
logger.debug(f"Получаю текст и is_anonymous поста для helper_message_id: {helper_message_id}")
raw_text, is_anonymous = await self.db.get_post_text_and_anonymity_by_helper_id(helper_message_id) raw_text, is_anonymous = await self.db.get_post_text_and_anonymity_by_helper_id(helper_message_id)
if raw_text is None: if raw_text is None:
raw_text = "" raw_text = ""
logger.debug(f"Текст поста получен: {'пустой' if not raw_text else f'длина: {len(raw_text)} символов'}, is_anonymous={is_anonymous}")
# Получаем ID автора по helper_message_id
logger.debug(f"Получаю ID автора для helper_message_id: {helper_message_id}")
author_id = await self.db.get_author_id_by_helper_message_id(helper_message_id) author_id = await self.db.get_author_id_by_helper_message_id(helper_message_id)
if not author_id: if not author_id:
logger.error(f"Автор не найден для медиагруппы {helper_message_id}") logger.error(f"_publish_media_group: Автор не найден для медиагруппы helper_message_id={helper_message_id}")
raise PostNotFoundError(f"Автор не найден для медиагруппы {helper_message_id}") raise PostNotFoundError(f"Автор не найден для медиагруппы {helper_message_id}")
logger.debug(f"ID автора получен: {author_id}")
# Получаем данные автора
user = await self.db.get_user_by_id(author_id) user = await self.db.get_user_by_id(author_id)
if not user: if not user:
raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных") raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных")
# Формируем финальный текст с учетом is_anonymous
formatted_text = get_text_message(raw_text, user.first_name, user.username, is_anonymous) formatted_text = get_text_message(raw_text, user.first_name, user.username, is_anonymous)
logger.debug(f"Сформирован финальный текст: {'пустой' if not formatted_text else f'длина: {len(formatted_text)} символов'}")
# Отправляем медиагруппу в канал try:
logger.info(f"Отправляю медиагруппу в канал {self.main_public}") await self._get_bot(call.message).delete_messages(
await send_media_group_to_channel( chat_id=self.group_for_posts,
message_ids=media_group_message_ids
)
except Exception as e:
logger.warning(f"_publish_media_group: Ошибка при удалении медиагруппы из чата модерации: {e}")
sent_messages = await send_media_group_to_channel(
bot=self._get_bot(call.message), bot=self._get_bot(call.message),
chat_id=self.main_public, chat_id=self.main_public,
post_content=post_content, post_content=post_content,
post_text=formatted_text post_text=formatted_text,
s3_storage=self.s3_storage
) )
if len(sent_messages) == len(media_group_message_ids):
for i, original_message_id in enumerate(media_group_message_ids):
published_message_id = sent_messages[i].message_id
try:
await self.db.update_published_message_id(
original_message_id=original_message_id,
published_message_id=published_message_id
)
await self._save_published_post_content(sent_messages[i], published_message_id, original_message_id)
except Exception as e:
logger.warning(f"_publish_media_group: Ошибка при сохранении published_message_id для {original_message_id}: {e}")
else:
logger.warning(f"_publish_media_group: Количество опубликованных сообщений ({len(sent_messages)}) не совпадает с количеством оригинальных ({len(media_group_message_ids)})")
await self.db.update_status_for_media_group_by_helper_id(helper_message_id, "approved") await self.db.update_status_for_media_group_by_helper_id(helper_message_id, "approved")
logger.debug(f"Удаляю медиагруппу и уведомляю автора {author_id}")
await self._delete_media_group_and_notify_author(call, author_id) # Удаляем helper сообщение - это критично, делаем это всегда
logger.info(f'Медиагруппа опубликована в канале {self.main_public}.') try:
await self._get_bot(call.message).delete_message(
chat_id=self.group_for_posts,
message_id=helper_message_id
)
except Exception as e:
logger.warning(f"_publish_media_group: Ошибка при удалении helper сообщения: {e}")
try:
await send_text_message(author_id, call.message, MESSAGE_POST_PUBLISHED)
except Exception as e:
if str(e) == ERROR_BOT_BLOCKED:
logger.warning(f"_publish_media_group: Пользователь {author_id} заблокировал бота")
raise UserBlockedBotError("Пользователь заблокировал бота")
logger.error(f"_publish_media_group: Ошибка при отправке уведомления автору: {e}")
except Exception as e: except Exception as e:
logger.error(f"Ошибка при публикации медиагруппы: {e}") logger.error(f"_publish_media_group: Ошибка при публикации медиагруппы: {e}")
# Пытаемся удалить helper сообщение даже при ошибке
try:
await self._get_bot(call.message).delete_message(
chat_id=self.group_for_posts,
message_id=call.message.message_id
)
except Exception as delete_error:
logger.warning(f"_publish_media_group: Не удалось удалить helper сообщение при ошибке: {delete_error}")
raise PublishError(f"Не удалось опубликовать медиагруппу: {str(e)}") raise PublishError(f"Не удалось опубликовать медиагруппу: {str(e)}")
@track_time("decline_post", "post_publish_service") @track_time("decline_post", "post_publish_service")
@@ -321,27 +420,32 @@ class PostPublishService:
@track_media_processing("media_group") @track_media_processing("media_group")
async def _decline_media_group(self, call: CallbackQuery) -> None: async def _decline_media_group(self, call: CallbackQuery) -> None:
"""Отклонение медиагруппы""" """Отклонение медиагруппы"""
await self.db.update_status_for_media_group_by_helper_id(call.message.message_id, "declined") helper_message_id = call.message.message_id
post_ids = await self.db.get_post_ids_from_telegram_by_last_id(call.message.message_id) await self.db.update_status_for_media_group_by_helper_id(helper_message_id, "declined")
message_ids = post_ids.copy()
message_ids.append(call.message.message_id)
logger.debug(f"Получены ID сообщений для удаления: {message_ids}")
author_id = await self._get_author_id_for_media_group(call.message.message_id) media_group_message_ids = await self.db.get_post_ids_by_helper_id(helper_message_id)
logger.debug(f"ID автора медиагруппы получен: {author_id}")
logger.debug(f"Удаляю {len(message_ids)} сообщений из группы {self.group_for_posts}") message_ids_to_delete = media_group_message_ids.copy()
await self._get_bot(call.message).delete_messages(chat_id=self.group_for_posts, message_ids=message_ids) message_ids_to_delete.append(helper_message_id)
author_id = await self._get_author_id_for_media_group(helper_message_id)
try:
await self._get_bot(call.message).delete_messages(
chat_id=self.group_for_posts,
message_ids=message_ids_to_delete
)
except Exception as e:
logger.warning(f"_decline_media_group: Ошибка при удалении сообщений: {e}")
try: try:
logger.debug(f"Отправляю уведомление об отклонении автору медиагруппы {author_id}")
await send_text_message(author_id, call.message, MESSAGE_POST_DECLINED) await send_text_message(author_id, call.message, MESSAGE_POST_DECLINED)
except Exception as e: except Exception as e:
if str(e) == ERROR_BOT_BLOCKED: if str(e) == ERROR_BOT_BLOCKED:
logger.warning(f"Пользователь {author_id} заблокировал бота") logger.warning(f"_decline_media_group: Пользователь {author_id} заблокировал бота")
raise UserBlockedBotError("Пользователь заблокировал бота") raise UserBlockedBotError("Пользователь заблокировал бота")
logger.error(f"Ошибка при отправке уведомления автору медиагруппы {author_id}: {e}") logger.error(f"_decline_media_group: Ошибка при отправке уведомления автору {author_id}: {e}")
raise raise
@track_time("_get_author_id", "post_publish_service") @track_time("_get_author_id", "post_publish_service")
@@ -399,12 +503,15 @@ class PostPublishService:
@track_errors("post_publish_service", "_delete_media_group_and_notify_author") @track_errors("post_publish_service", "_delete_media_group_and_notify_author")
@track_media_processing("media_group") @track_media_processing("media_group")
async def _delete_media_group_and_notify_author(self, call: CallbackQuery, author_id: int) -> None: async def _delete_media_group_and_notify_author(self, call: CallbackQuery, author_id: int) -> None:
"""Удаление медиагруппы и уведомление автора""" """Удаление медиагруппы и уведомление автора (legacy метод, используется для обратной совместимости)"""
post_ids = await self.db.get_post_ids_from_telegram_by_last_id(call.message.message_id) helper_message_id = call.message.message_id
#message_ids = post_ids.copy() media_group_message_ids = await self.db.get_post_ids_by_helper_id(helper_message_id)
post_ids.append(call.message.message_id)
await self._get_bot(call.message).delete_messages(chat_id=self.group_for_posts, message_ids=post_ids) message_ids_to_delete = media_group_message_ids.copy()
message_ids_to_delete.append(helper_message_id)
await self._get_bot(call.message).delete_messages(chat_id=self.group_for_posts, message_ids=message_ids_to_delete)
try: try:
await send_text_message(author_id, call.message, MESSAGE_POST_PUBLISHED) await send_text_message(author_id, call.message, MESSAGE_POST_PUBLISHED)
except Exception as e: except Exception as e:
@@ -412,6 +519,34 @@ class PostPublishService:
raise UserBlockedBotError("Пользователь заблокировал бота") raise UserBlockedBotError("Пользователь заблокировал бота")
raise raise
@track_time("_save_published_post_content", "post_publish_service")
@track_errors("post_publish_service", "_save_published_post_content")
async def _save_published_post_content(self, published_message: types.Message, published_message_id: int, original_message_id: int) -> None:
"""Сохраняет ссылку на медиафайл из опубликованного поста (файл уже в S3 или на диске)."""
try:
# Получаем уже сохраненный путь/S3 ключ из оригинального поста
saved_content = await self.db.get_post_content_by_message_id(original_message_id)
if saved_content and len(saved_content) > 0:
# Копируем тот же путь/S3 ключ
file_path, content_type = saved_content[0]
logger.debug(f"Копируем путь/S3 ключ для опубликованного поста: {file_path}")
success = await self.db.add_published_post_content(
published_message_id=published_message_id,
content_path=file_path, # Тот же путь/S3 ключ
content_type=content_type
)
if success:
logger.info(f"Ссылка на файл сохранена для опубликованного поста: published_message_id={published_message_id}, path={file_path}")
else:
logger.warning(f"Не удалось сохранить ссылку на файл: published_message_id={published_message_id}")
else:
logger.warning(f"Контент не найден для оригинального поста message_id={original_message_id}")
except Exception as e:
logger.error(f"Ошибка при сохранении ссылки на контент опубликованного поста {published_message_id}: {e}")
# Не прерываем публикацию, если сохранение контента не удалось
class BanService: class BanService:
def __init__(self, bot: Bot, db, settings: Dict[str, Any]): def __init__(self, bot: Bot, db, settings: Dict[str, Any]):
@@ -432,7 +567,12 @@ class BanService:
@db_query_time("ban_user_from_post", "users", "mixed") @db_query_time("ban_user_from_post", "users", "mixed")
async def ban_user_from_post(self, call: CallbackQuery) -> None: async def ban_user_from_post(self, call: CallbackQuery) -> None:
"""Бан пользователя за спам""" """Бан пользователя за спам"""
# Если это helper-сообщение медиагруппы, используем специальный метод
if call.message.text == CONTENT_TYPE_MEDIA_GROUP:
author_id = await self.db.get_author_id_by_helper_message_id(call.message.message_id)
else:
author_id = await self.db.get_author_id_by_message_id(call.message.message_id) author_id = await self.db.get_author_id_by_message_id(call.message.message_id)
if not author_id: if not author_id:
raise UserNotFoundError(f"Автор не найден для сообщения {call.message.message_id}") raise UserNotFoundError(f"Автор не найден для сообщения {call.message.message_id}")

View File

@@ -44,16 +44,15 @@ sleep = asyncio.sleep
class PrivateHandlers: class PrivateHandlers:
"""Main handler class for private messages""" """Main handler class for private messages"""
def __init__(self, db: AsyncBotDB, settings: BotSettings): def __init__(self, db: AsyncBotDB, settings: BotSettings, s3_storage=None):
self.db = db self.db = db
self.settings = settings self.settings = settings
self.user_service = UserService(db, settings) self.user_service = UserService(db, settings)
self.post_service = PostService(db, settings) self.post_service = PostService(db, settings, s3_storage)
self.sticker_service = StickerService(settings) self.sticker_service = StickerService(settings)
# Create router
self.router = Router() self.router = Router()
self.router.message.middleware(AlbumMiddleware()) self.router.message.middleware(AlbumMiddleware(latency=5.0))
self.router.message.middleware(BlacklistMiddleware()) self.router.message.middleware(BlacklistMiddleware())
# Register handlers # Register handlers
@@ -158,12 +157,38 @@ class PrivateHandlers:
@track_time("suggest_router", "private_handlers") @track_time("suggest_router", "private_handlers")
async def suggest_router(self, message: types.Message, state: FSMContext, album: list = None, **kwargs): async def suggest_router(self, message: types.Message, state: FSMContext, album: list = None, **kwargs):
"""Handle post submission in suggest state""" """Handle post submission in suggest state"""
# Post service operations with metrics # Проверяем, есть ли механизм для получения полной медиагруппы (для медиагрупп)
album_getter = kwargs.get("album_getter")
if album_getter and message.media_group_id:
# Это медиагруппа - сразу отвечаем пользователю, обработку делаем в фоне
markup_for_user = await get_reply_keyboard(self.db, message.from_user.id)
success_send_message = messages.get_message(get_first_name(message), 'SUCCESS_SEND_MESSAGE')
await message.answer(success_send_message, reply_markup=markup_for_user)
await state.set_state(FSM_STATES["START"])
# В фоне ждем полную медиагруппу и обрабатываем пост
async def process_media_group_background():
try:
# Ждем полную медиагруппу
full_album = await album_getter.get_album(timeout=10.0)
if not full_album:
return
# Обрабатываем пост с полной медиагруппой
await self.user_service.update_user_activity(message.from_user.id) await self.user_service.update_user_activity(message.from_user.id)
await self.post_service.process_post(message, full_album)
except Exception as e:
from logs.custom_logger import logger
logger.error(f"Ошибка при фоновой обработке медиагруппы: {e}")
asyncio.create_task(process_media_group_background())
else:
# Обычное сообщение или медиагруппа уже собрана - обрабатываем синхронно
await self.user_service.update_user_activity(message.from_user.id)
if message.media_group_id is None:
await self.user_service.log_user_message(message) await self.user_service.log_user_message(message)
await self.post_service.process_post(message, album) await self.post_service.process_post(message, album)
# Send success message and return to start state
markup_for_user = await get_reply_keyboard(self.db, message.from_user.id) markup_for_user = await get_reply_keyboard(self.db, message.from_user.id)
success_send_message = messages.get_message(get_first_name(message), 'SUCCESS_SEND_MESSAGE') success_send_message = messages.get_message(get_first_name(message), 'SUCCESS_SEND_MESSAGE')
await message.answer(success_send_message, reply_markup=markup_for_user) await message.answer(success_send_message, reply_markup=markup_for_user)
@@ -224,9 +249,9 @@ class PrivateHandlers:
# Factory function to create handlers with dependencies # Factory function to create handlers with dependencies
def create_private_handlers(db: AsyncBotDB, settings: BotSettings) -> PrivateHandlers: def create_private_handlers(db: AsyncBotDB, settings: BotSettings, s3_storage=None) -> PrivateHandlers:
"""Create private handlers instance with dependencies""" """Create private handlers instance with dependencies"""
return PrivateHandlers(db, settings) return PrivateHandlers(db, settings, s3_storage)
# Legacy router for backward compatibility # Legacy router for backward compatibility
@@ -252,7 +277,8 @@ def init_legacy_router():
) )
db = bdf.get_db() db = bdf.get_db()
handlers = create_private_handlers(db, settings) s3_storage = bdf.get_s3_storage()
handlers = create_private_handlers(db, settings, s3_storage)
# Instead of trying to copy handlers, we'll use the new router directly # Instead of trying to copy handlers, we'll use the new router directly
# This maintains backward compatibility while using the new architecture # This maintains backward compatibility while using the new architecture

View File

@@ -143,9 +143,19 @@ class UserService:
class PostService: class PostService:
"""Service for post-related operations""" """Service for post-related operations"""
def __init__(self, db: DatabaseProtocol, settings: BotSettings) -> None: def __init__(self, db: DatabaseProtocol, settings: BotSettings, s3_storage=None) -> None:
self.db = db self.db = db
self.settings = settings self.settings = settings
self.s3_storage = s3_storage
async def _save_media_background(self, sent_message: types.Message, bot_db: Any, s3_storage) -> None:
"""Сохраняет медиа в фоне, чтобы не блокировать ответ пользователю"""
try:
success = await add_in_db_media(sent_message, bot_db, s3_storage)
if not success:
logger.warning(f"_save_media_background: Не удалось сохранить медиа для поста {sent_message.message_id}")
except Exception as e:
logger.error(f"_save_media_background: Ошибка при сохранении медиа для поста {sent_message.message_id}: {e}")
@track_time("handle_text_post", "post_service") @track_time("handle_text_post", "post_service")
@track_errors("post_service", "handle_text_post") @track_errors("post_service", "handle_text_post")
@@ -155,14 +165,14 @@ class PostService:
post_text = get_text_message(message.text.lower(), first_name, message.from_user.username) post_text = get_text_message(message.text.lower(), first_name, message.from_user.username)
markup = get_reply_keyboard_for_post() markup = get_reply_keyboard_for_post()
sent_message_id = await send_text_message(self.settings.group_for_posts, message, post_text, markup) sent_message = await send_text_message(self.settings.group_for_posts, message, post_text, markup)
# Сохраняем сырой текст и определяем анонимность # Сохраняем сырой текст и определяем анонимность
raw_text = message.text or "" raw_text = message.text or ""
is_anonymous = determine_anonymity(raw_text) is_anonymous = determine_anonymity(raw_text)
post = TelegramPost( post = TelegramPost(
message_id=sent_message_id, message_id=sent_message.message_id,
text=raw_text, text=raw_text,
author_id=message.from_user.id, author_id=message.from_user.id,
created_at=int(datetime.now().timestamp()), created_at=int(datetime.now().timestamp()),
@@ -196,9 +206,8 @@ class PostService:
is_anonymous=is_anonymous is_anonymous=is_anonymous
) )
await self.db.add_post(post) await self.db.add_post(post)
success = await add_in_db_media(sent_message, self.db) # Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю
if not success: asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage))
logger.warning(f"handle_photo_post: Не удалось сохранить медиа для поста {sent_message.message_id}")
@track_time("handle_video_post", "post_service") @track_time("handle_video_post", "post_service")
@track_errors("post_service", "handle_video_post") @track_errors("post_service", "handle_video_post")
@@ -226,9 +235,8 @@ class PostService:
is_anonymous=is_anonymous is_anonymous=is_anonymous
) )
await self.db.add_post(post) await self.db.add_post(post)
success = await add_in_db_media(sent_message, self.db) # Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю
if not success: asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage))
logger.warning(f"handle_photo_post: Не удалось сохранить медиа для поста {sent_message.message_id}")
@track_time("handle_video_note_post", "post_service") @track_time("handle_video_note_post", "post_service")
@track_errors("post_service", "handle_video_note_post") @track_errors("post_service", "handle_video_note_post")
@@ -252,9 +260,8 @@ class PostService:
is_anonymous=is_anonymous is_anonymous=is_anonymous
) )
await self.db.add_post(post) await self.db.add_post(post)
success = await add_in_db_media(sent_message, self.db) # Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю
if not success: asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage))
logger.warning(f"handle_photo_post: Не удалось сохранить медиа для поста {sent_message.message_id}")
@track_time("handle_audio_post", "post_service") @track_time("handle_audio_post", "post_service")
@track_errors("post_service", "handle_audio_post") @track_errors("post_service", "handle_audio_post")
@@ -282,9 +289,8 @@ class PostService:
is_anonymous=is_anonymous is_anonymous=is_anonymous
) )
await self.db.add_post(post) await self.db.add_post(post)
success = await add_in_db_media(sent_message, self.db) # Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю
if not success: asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage))
logger.warning(f"handle_photo_post: Не удалось сохранить медиа для поста {sent_message.message_id}")
@track_time("handle_voice_post", "post_service") @track_time("handle_voice_post", "post_service")
@track_errors("post_service", "handle_voice_post") @track_errors("post_service", "handle_voice_post")
@@ -308,9 +314,8 @@ class PostService:
is_anonymous=is_anonymous is_anonymous=is_anonymous
) )
await self.db.add_post(post) await self.db.add_post(post)
success = await add_in_db_media(sent_message, self.db) # Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю
if not success: asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage))
logger.warning(f"handle_photo_post: Не удалось сохранить медиа для поста {sent_message.message_id}")
@track_time("handle_media_group_post", "post_service") @track_time("handle_media_group_post", "post_service")
@track_errors("post_service", "handle_media_group_post") @track_errors("post_service", "handle_media_group_post")
@@ -318,7 +323,6 @@ class PostService:
@track_media_processing("media_group") @track_media_processing("media_group")
async def handle_media_group_post(self, message: types.Message, album: list, first_name: str) -> None: async def handle_media_group_post(self, message: types.Message, album: list, first_name: str) -> None:
"""Handle media group post submission""" """Handle media group post submission"""
#TODO: Мне кажется тут какая-то дичь с одинаковыми переменными, в которых post_caption никуда не ведет
post_caption = " " post_caption = " "
raw_caption = "" raw_caption = ""
@@ -326,12 +330,17 @@ class PostService:
raw_caption = album[0].caption or "" raw_caption = album[0].caption or ""
post_caption = get_text_message(album[0].caption.lower(), first_name, message.from_user.username) post_caption = get_text_message(album[0].caption.lower(), first_name, message.from_user.username)
# Определяем анонимность на основе сырого caption
is_anonymous = determine_anonymity(raw_caption) is_anonymous = determine_anonymity(raw_caption)
media_group = await prepare_media_group_from_middlewares(album, post_caption)
media_group_message_ids = await send_media_group_message_to_private_chat(
self.settings.group_for_posts, message, media_group, self.db, None, self.s3_storage
)
main_post_id = media_group_message_ids[-1]
# Создаем основной пост для медиагруппы
main_post = TelegramPost( main_post = TelegramPost(
message_id=message.message_id, # ID основного сообщения медиагруппы message_id=main_post_id,
text=raw_caption, text=raw_caption,
author_id=message.from_user.id, author_id=message.from_user.id,
created_at=int(datetime.now().timestamp()), created_at=int(datetime.now().timestamp()),
@@ -339,32 +348,32 @@ class PostService:
) )
await self.db.add_post(main_post) await self.db.add_post(main_post)
# Отправляем медиагруппу в группу для модерации for msg_id in media_group_message_ids:
media_group = await prepare_media_group_from_middlewares(album, post_caption) await self.db.add_message_link(main_post_id, msg_id)
media_group_message_id = await send_media_group_message_to_private_chat(
self.settings.group_for_posts, message, media_group, self.db, main_post.message_id
)
await asyncio.sleep(0.2) await asyncio.sleep(0.2)
# Создаем helper сообщение с кнопками
markup = get_reply_keyboard_for_post() markup = get_reply_keyboard_for_post()
help_message_id = await send_text_message(self.settings.group_for_posts, message, "ВРУЧНУЮ ВЫКЛАДЫВАТЬ, ПОСЛЕ ВЫКЛАДКИ УДАЛИТЬ ОБА ПОСТА") helper_message = await send_text_message(
self.settings.group_for_posts,
message,
"^",
markup
)
helper_message_id = helper_message.message_id
# Создаем helper пост и связываем его с основным
helper_post = TelegramPost( helper_post = TelegramPost(
message_id=help_message_id, # ID helper сообщения message_id=helper_message_id,
text="^", # Специальный маркер для медиагруппы text="^",
author_id=message.from_user.id, author_id=message.from_user.id,
helper_text_message_id=main_post.message_id, # Ссылка на основной пост helper_text_message_id=main_post_id,
created_at=int(datetime.now().timestamp()) created_at=int(datetime.now().timestamp())
) )
await self.db.add_post(helper_post) await self.db.add_post(helper_post)
# Обновляем основной пост, чтобы он ссылался на helper
await self.db.update_helper_message( await self.db.update_helper_message(
message_id=main_post.message_id, message_id=main_post_id,
helper_message_id=help_message_id helper_message_id=helper_message_id
) )
@track_time("process_post", "post_service") @track_time("process_post", "post_service")
@@ -373,13 +382,8 @@ class PostService:
async def process_post(self, message: types.Message, album: Union[list, None] = None) -> None: async def process_post(self, message: types.Message, album: Union[list, None] = None) -> None:
"""Process post based on content type""" """Process post based on content type"""
first_name = get_first_name(message) first_name = get_first_name(message)
# TODO: Бесит меня этот функционал
if message.media_group_id is not None: if message.media_group_id is not None:
safe_username = html.escape(message.from_user.username) if message.from_user.username else "Без никнейма"
await send_text_message(
self.settings.group_for_logs, message,
f'Закинул медиагруппу, пользователь: имя - {first_name}, ник - {safe_username}'
)
await self.handle_media_group_post(message, album, first_name) await self.handle_media_group_post(message, album, first_name)
return return

View File

@@ -1,17 +1,45 @@
import asyncio import asyncio
from typing import Any, Dict, Union, List from typing import Any, Dict, Union, List, Optional
from aiogram import BaseMiddleware from aiogram import BaseMiddleware
from aiogram.types import Message from aiogram.types import Message
class AlbumGetter:
"""Вспомогательный класс для получения полной медиагруппы из middleware"""
def __init__(self, album_data: Dict[str, Any], media_group_id: str, event: asyncio.Event):
self.album_data = album_data
self.media_group_id = media_group_id
self.event = event
async def get_album(self, timeout: float = 10.0) -> Optional[List[Message]]:
"""
Ждет полную медиагруппу и возвращает ее.
Args:
timeout: Максимальное время ожидания в секундах
Returns:
Список сообщений медиагруппы или None при таймауте
"""
try:
await asyncio.wait_for(self.event.wait(), timeout=timeout)
if self.media_group_id in self.album_data:
return self.album_data[self.media_group_id].get("collected_album")
return None
except asyncio.TimeoutError:
return None
class AlbumMiddleware(BaseMiddleware): class AlbumMiddleware(BaseMiddleware):
""" """
Middleware для обработки медиа групп в Telegram. Middleware для обработки медиа групп в Telegram.
Собирает все сообщения одной медиа группы и передает их как album в data. Собирает все сообщения одной медиа группы и передает их как album в data.
Не блокирует handler - сразу вызывает его, а полную медиагруппу передает через Event.
""" """
def __init__(self, latency: Union[int, float] = 0.01): def __init__(self, latency: Union[int, float] = 5.0):
""" """
Инициализация middleware. Инициализация middleware.
@@ -20,7 +48,8 @@ class AlbumMiddleware(BaseMiddleware):
""" """
super().__init__() super().__init__()
self.latency = latency self.latency = latency
self.album_data: Dict[str, Dict[str, List[Message]]] = {} # Храним данные медиагруппы: messages, event для уведомления, task для сбора
self.album_data: Dict[str, Dict[str, Any]] = {}
def collect_album_messages(self, event: Message) -> int: def collect_album_messages(self, event: Message) -> int:
""" """
@@ -41,10 +70,54 @@ class AlbumMiddleware(BaseMiddleware):
self.album_data[event.media_group_id]["messages"].append(event) self.album_data[event.media_group_id]["messages"].append(event)
return len(self.album_data[event.media_group_id]["messages"]) return len(self.album_data[event.media_group_id]["messages"])
async def _collect_album_background(self, media_group_id: str) -> None:
"""
Фоновая задача для сбора всех сообщений медиагруппы.
Args:
media_group_id: ID медиагруппы для сбора
"""
try:
await asyncio.sleep(self.latency)
if media_group_id not in self.album_data:
return
# Получаем текущий список сообщений
album_messages = self.album_data[media_group_id]["messages"].copy()
album_messages.sort(key=lambda x: x.message_id)
# Сохраняем собранную медиагруппу и уведомляем через Event
self.album_data[media_group_id]["collected_album"] = album_messages
self.album_data[media_group_id]["event"].set()
# Очищаем данные после небольшой задержки (чтобы handler успел получить album)
await asyncio.sleep(1.0)
if media_group_id in self.album_data:
task = self.album_data[media_group_id].get("task")
if task and not task.done():
task.cancel()
del self.album_data[media_group_id]
except Exception:
# В случае ошибки все равно уведомляем, чтобы handler не завис
if media_group_id in self.album_data:
self.album_data[media_group_id]["event"].set()
# Очищаем данные даже при ошибке
try:
task = self.album_data[media_group_id].get("task")
if task and not task.done():
task.cancel()
del self.album_data[media_group_id]
except Exception:
pass
async def __call__(self, handler, event: Message, data: Dict[str, Any]) -> Any: async def __call__(self, handler, event: Message, data: Dict[str, Any]) -> Any:
""" """
Основная логика middleware. Основная логика middleware.
Для медиагрупп: сразу вызывает handler, передавая Event для получения полной медиагруппы.
Для обычных сообщений: сразу вызывает handler.
Args: Args:
handler: Обработчик события handler: Обработчик события
event: Событие (сообщение) event: Событие (сообщение)
@@ -53,30 +126,42 @@ class AlbumMiddleware(BaseMiddleware):
Returns: Returns:
Результат выполнения обработчика Результат выполнения обработчика
""" """
# Если у события нет media_group_id, передаем его обработчику сразу
if not event.media_group_id: if not event.media_group_id:
return await handler(event, data) return await handler(event, data)
# Собираем сообщения одной медиа группы media_group_id = event.media_group_id
total_before = self.collect_album_messages(event) message_id = event.message_id
# Ждем указанный период для сбора всех сообщений # Если это первое сообщение медиагруппы - создаем структуру данных
await asyncio.sleep(self.latency) is_first_message = False
if media_group_id not in self.album_data:
is_first_message = True
album_event = asyncio.Event()
self.album_data[media_group_id] = {
"messages": [],
"event": album_event,
"task": None,
"first_message_id": message_id
}
# Запускаем фоновую задачу для сбора медиагруппы
task = asyncio.create_task(self._collect_album_background(media_group_id))
self.album_data[media_group_id]["task"] = task
# Проверяем количество сообщений после задержки # Добавляем сообщение в медиагруппу
total_after = len(self.album_data[event.media_group_id]["messages"]) self.album_data[media_group_id]["messages"].append(event)
# Если за время задержки добавились новые сообщения, выходим # Обрабатываем только первое сообщение медиагруппы
if total_before != total_after: if not is_first_message:
# Для остальных сообщений просто возвращаемся, не вызывая handler
return return
# Сортируем сообщения по message_id и добавляем в data # Передаем объект-геттер в data, чтобы handler мог получить полную медиагруппу
album_messages = self.album_data[event.media_group_id]["messages"] album_getter = AlbumGetter(
album_messages.sort(key=lambda x: x.message_id) self.album_data,
data["album"] = album_messages media_group_id,
self.album_data[media_group_id]["event"]
)
data["album_getter"] = album_getter
# Удаляем медиа группу из отслеживания для освобождения памяти # Сразу вызываем handler только для первого сообщения (не блокируем)
del self.album_data[event.media_group_id]
# Вызываем оригинальный обработчик события
return await handler(event, data) return await handler(event, data)

View File

@@ -1,8 +1,10 @@
import os import os
import sys import sys
from typing import Optional
from dotenv import load_dotenv from dotenv import load_dotenv
from database.async_db import AsyncBotDB from database.async_db import AsyncBotDB
from helper_bot.utils.s3_storage import S3StorageService
class BaseDependencyFactory: class BaseDependencyFactory:
@@ -21,6 +23,7 @@ class BaseDependencyFactory:
self.database = AsyncBotDB(database_path) self.database = AsyncBotDB(database_path)
self._load_settings_from_env() self._load_settings_from_env()
self._init_s3_storage()
def _load_settings_from_env(self): def _load_settings_from_env(self):
"""Загружает настройки из переменных окружения.""" """Загружает настройки из переменных окружения."""
@@ -48,6 +51,29 @@ class BaseDependencyFactory:
'port': self._parse_int(os.getenv('METRICS_PORT', '8080')) 'port': self._parse_int(os.getenv('METRICS_PORT', '8080'))
} }
self.settings['S3'] = {
'enabled': self._parse_bool(os.getenv('S3_ENABLED', 'false')),
'endpoint_url': os.getenv('S3_ENDPOINT_URL', ''),
'access_key': os.getenv('S3_ACCESS_KEY', ''),
'secret_key': os.getenv('S3_SECRET_KEY', ''),
'bucket_name': os.getenv('S3_BUCKET_NAME', ''),
'region': os.getenv('S3_REGION', 'us-east-1')
}
def _init_s3_storage(self):
"""Инициализирует S3StorageService если S3 включен."""
self.s3_storage = None
if self.settings['S3']['enabled']:
s3_config = self.settings['S3']
if s3_config['endpoint_url'] and s3_config['access_key'] and s3_config['secret_key'] and s3_config['bucket_name']:
self.s3_storage = S3StorageService(
endpoint_url=s3_config['endpoint_url'],
access_key=s3_config['access_key'],
secret_key=s3_config['secret_key'],
bucket_name=s3_config['bucket_name'],
region=s3_config['region']
)
def _parse_bool(self, value: str) -> bool: def _parse_bool(self, value: str) -> bool:
"""Парсит строковое значение в boolean.""" """Парсит строковое значение в boolean."""
return value.lower() in ('true', '1', 'yes', 'on') return value.lower() in ('true', '1', 'yes', 'on')
@@ -66,6 +92,10 @@ class BaseDependencyFactory:
"""Возвращает подключение к базе данных.""" """Возвращает подключение к базе данных."""
return self.database return self.database
def get_s3_storage(self) -> Optional[S3StorageService]:
"""Возвращает S3StorageService если S3 включен, иначе None."""
return self.s3_storage
_global_instance = None _global_instance = None

View File

@@ -2,6 +2,8 @@ import html
import os import os
import random import random
import time import time
import tempfile
import asyncio
from datetime import datetime, timedelta from datetime import datetime, timedelta
from time import sleep from time import sleep
from typing import List, Dict, Any, Optional, TYPE_CHECKING, Union from typing import List, Dict, Any, Optional, TYPE_CHECKING, Union
@@ -158,17 +160,19 @@ def get_text_message(post_text: str, first_name: str, username: str = None, is_a
@track_time("download_file", "helper_func") @track_time("download_file", "helper_func")
@track_errors("helper_func", "download_file") @track_errors("helper_func", "download_file")
@track_file_operations("unknown") @track_file_operations("unknown")
async def download_file(message: types.Message, file_id: str, content_type: str = None) -> Optional[str]: async def download_file(message: types.Message, file_id: str, content_type: str = None,
s3_storage = None) -> Optional[str]:
""" """
Скачивает файл по file_id из Telegram и сохраняет в соответствующую папку. Скачивает файл по file_id из Telegram и сохраняет в S3 или на локальный диск.
Args: Args:
message: сообщение message: сообщение
file_id: File ID файла file_id: File ID файла
content_type: тип контента (photo, video, audio, voice, video_note) content_type: тип контента (photo, video, audio, voice, video_note)
s3_storage: опциональный S3StorageService для сохранения в S3
Returns: Returns:
Путь к сохраненному файлу, если файл был скачан успешно, иначе None S3 ключ (если s3_storage указан) или локальный путь к файлу, иначе None
""" """
start_time = time.time() start_time = time.time()
@@ -178,6 +182,58 @@ async def download_file(message: types.Message, file_id: str, content_type: str
logger.error("download_file: Неверные параметры - file_id, message или bot отсутствуют") logger.error("download_file: Неверные параметры - file_id, message или bot отсутствуют")
return None return None
# Получаем информацию о файле
file = await message.bot.get_file(file_id)
if not file or not file.file_path:
logger.error(f"download_file: Не удалось получить информацию о файле {file_id}")
return None
# Определяем расширение
original_filename = os.path.basename(file.file_path)
file_extension = os.path.splitext(original_filename)[1] or '.bin'
if s3_storage:
# Сохраняем в S3
# Скачиваем во временный файл
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=file_extension)
temp_path = temp_file.name
temp_file.close()
try:
# Скачиваем из Telegram
await message.bot.download_file(file_path=file.file_path, destination=temp_path)
# Генерируем S3 ключ
s3_key = s3_storage.generate_s3_key(content_type, file_id)
# Загружаем в S3
success = await s3_storage.upload_file(temp_path, s3_key)
# Удаляем временный файл
try:
os.remove(temp_path)
except:
pass
if success:
file_size = file.file_size if hasattr(file, 'file_size') else 0
download_time = time.time() - start_time
logger.info(f"download_file: Файл загружен в S3 - {s3_key}, размер: {file_size} байт, время: {download_time:.2f}с")
return s3_key
else:
logger.error(f"download_file: Не удалось загрузить файл в S3: {s3_key}")
return None
except Exception as e:
# Удаляем временный файл при ошибке
try:
os.remove(temp_path)
except:
pass
download_time = time.time() - start_time
logger.error(f"download_file: Ошибка загрузки файла в S3 {file_id}: {e}, время: {download_time:.2f}с")
return None
else:
# Старая логика - сохраняем на локальный диск
# Определяем папку по типу контента # Определяем папку по типу контента
type_folders = { type_folders = {
'photo': 'photos', 'photo': 'photos',
@@ -197,15 +253,7 @@ async def download_file(message: types.Message, file_id: str, content_type: str
logger.debug(f"download_file: Начинаю скачивание файла {file_id} типа {content_type} в папку {folder}") logger.debug(f"download_file: Начинаю скачивание файла {file_id} типа {content_type} в папку {folder}")
# Получаем информацию о файле
file = await message.bot.get_file(file_id)
if not file or not file.file_path:
logger.error(f"download_file: Не удалось получить информацию о файле {file_id}")
return None
# Генерируем уникальное имя файла # Генерируем уникальное имя файла
original_filename = os.path.basename(file.file_path)
file_extension = os.path.splitext(original_filename)[1] or '.bin'
safe_filename = f"{file_id}{file_extension}" safe_filename = f"{file_id}{file_extension}"
file_path = os.path.join(full_folder_path, safe_filename) file_path = os.path.join(full_folder_path, safe_filename)
@@ -283,11 +331,21 @@ async def prepare_media_group_from_middlewares(album, post_caption: str = ''):
return media_group return media_group
async def _save_media_group_background(sent_message: List[types.Message], bot_db: Any, main_post_id: Optional[int], s3_storage) -> None:
"""Сохраняет медиагруппу в фоне, чтобы не блокировать ответ пользователю"""
try:
success = await add_in_db_media_mediagroup(sent_message, bot_db, main_post_id, s3_storage)
if not success:
logger.warning(f"_save_media_group_background: Не удалось сохранить медиа для медиагруппы {sent_message[-1].message_id}")
except Exception as e:
logger.error(f"_save_media_group_background: Ошибка при сохранении медиа для медиагруппы {sent_message[-1].message_id}: {e}")
@track_time("add_in_db_media_mediagroup", "helper_func") @track_time("add_in_db_media_mediagroup", "helper_func")
@track_errors("helper_func", "add_in_db_media_mediagroup") @track_errors("helper_func", "add_in_db_media_mediagroup")
@track_media_processing("media_group") @track_media_processing("media_group")
@db_query_time("add_in_db_media_mediagroup", "posts", "insert") @db_query_time("add_in_db_media_mediagroup", "posts", "insert")
async def add_in_db_media_mediagroup(sent_message: List[types.Message], bot_db: Any, main_post_id: Optional[int] = None) -> bool: async def add_in_db_media_mediagroup(sent_message: List[types.Message], bot_db: Any,
main_post_id: Optional[int] = None, s3_storage = None) -> bool:
""" """
Добавляет контент медиа-группы в базу данных Добавляет контент медиа-группы в базу данных
@@ -311,9 +369,7 @@ async def add_in_db_media_mediagroup(sent_message: List[types.Message], bot_db:
logger.warning("add_in_db_media_mediagroup: Пустая медиагруппа") logger.warning("add_in_db_media_mediagroup: Пустая медиагруппа")
return False return False
# Используем переданный main_post_id или ID последнего сообщения
post_id = main_post_id or sent_message[-1].message_id post_id = main_post_id or sent_message[-1].message_id
logger.debug(f"add_in_db_media_mediagroup: Обрабатываю медиагруппу из {len(sent_message)} сообщений, post_id: {post_id}")
processed_count = 0 processed_count = 0
failed_count = 0 failed_count = 0
@@ -323,7 +379,6 @@ async def add_in_db_media_mediagroup(sent_message: List[types.Message], bot_db:
content_type = None content_type = None
file_id = None file_id = None
# Определяем тип контента и file_id
if message.photo: if message.photo:
content_type = 'photo' content_type = 'photo'
file_id = message.photo[-1].file_id file_id = message.photo[-1].file_id
@@ -349,46 +404,40 @@ async def add_in_db_media_mediagroup(sent_message: List[types.Message], bot_db:
failed_count += 1 failed_count += 1
continue continue
logger.debug(f"add_in_db_media_mediagroup: Обрабатываю {content_type} в сообщении {i+1}/{len(sent_message)}") if s3_storage is None:
bdf = get_global_instance()
s3_storage = bdf.get_s3_storage()
# Скачиваем файл file_path = await download_file(message, file_id=file_id, content_type=content_type, s3_storage=s3_storage)
file_path = await download_file(message, file_id=file_id, content_type=content_type)
if not file_path: if not file_path:
logger.error(f"add_in_db_media_mediagroup: Не удалось скачать файл {file_id} в сообщении {i+1}/{len(sent_message)}") logger.error(f"add_in_db_media_mediagroup: Не удалось скачать файл {file_id} в сообщении {i+1}/{len(sent_message)}")
failed_count += 1 failed_count += 1
continue continue
# Добавляем в базу данных success = await bot_db.add_post_content(post_id, post_id, file_path, content_type)
success = await bot_db.add_post_content(post_id, message.message_id, file_path, content_type)
if not success: if not success:
logger.error(f"add_in_db_media_mediagroup: Не удалось добавить контент в БД для сообщения {i+1}/{len(sent_message)}") logger.error(f"add_in_db_media_mediagroup: Не удалось добавить контент в БД для сообщения {i+1}/{len(sent_message)}")
# Удаляем скачанный файл при ошибке БД if file_path.startswith('files/'):
try: try:
os.remove(file_path) os.remove(file_path)
logger.debug(f"add_in_db_media_mediagroup: Удален файл {file_path} после ошибки БД")
except Exception as e: except Exception as e:
logger.warning(f"add_in_db_media_mediagroup: Не удалось удалить файл {file_path}: {e}") logger.warning(f"add_in_db_media_mediagroup: Не удалось удалить файл {file_path}: {e}")
failed_count += 1 failed_count += 1
continue continue
processed_count += 1 processed_count += 1
logger.debug(f"add_in_db_media_mediagroup: Успешно обработано сообщение {i+1}/{len(sent_message)}")
except Exception as e: except Exception as e:
logger.error(f"add_in_db_media_mediagroup: Ошибка обработки сообщения {i+1}/{len(sent_message)}: {e}") logger.error(f"add_in_db_media_mediagroup: Ошибка обработки сообщения {i+1}/{len(sent_message)}: {e}")
failed_count += 1 failed_count += 1
continue continue
processing_time = time.time() - start_time
if processed_count == 0: if processed_count == 0:
logger.error(f"add_in_db_media_mediagroup: Не удалось обработать ни одного сообщения из медиагруппы {post_id}") logger.error(f"add_in_db_media_mediagroup: Не удалось обработать ни одного сообщения из медиагруппы {post_id}")
return False return False
if failed_count > 0: if failed_count > 0:
logger.warning(f"add_in_db_media_mediagroup: Обработано {processed_count}/{len(sent_message)} сообщений медиагруппы {post_id}, ошибок: {failed_count}") logger.warning(f"add_in_db_media_mediagroup: Обработано {processed_count}/{len(sent_message)} сообщений медиагруппы {post_id}, ошибок: {failed_count}")
else:
logger.info(f"add_in_db_media_mediagroup: Успешно обработана медиагруппа {post_id} - {processed_count} сообщений, время: {processing_time:.2f}с")
return failed_count == 0 return failed_count == 0
@@ -402,7 +451,7 @@ async def add_in_db_media_mediagroup(sent_message: List[types.Message], bot_db:
@track_media_processing("media_group") @track_media_processing("media_group")
@db_query_time("add_in_db_media", "posts", "insert") @db_query_time("add_in_db_media", "posts", "insert")
@track_file_operations("media") @track_file_operations("media")
async def add_in_db_media(sent_message: types.Message, bot_db: Any) -> bool: async def add_in_db_media(sent_message: types.Message, bot_db: Any, s3_storage = None) -> bool:
""" """
Добавляет контент одиночного сообщения в базу данных Добавляет контент одиночного сообщения в базу данных
@@ -451,8 +500,13 @@ async def add_in_db_media(sent_message: types.Message, bot_db: Any) -> bool:
logger.debug(f"add_in_db_media: Обрабатываю {content_type} для сообщения {post_id}") logger.debug(f"add_in_db_media: Обрабатываю {content_type} для сообщения {post_id}")
# Скачиваем файл # Получаем s3_storage если не передан
file_path = await download_file(sent_message, file_id=file_id, content_type=content_type) if s3_storage is None:
bdf = get_global_instance()
s3_storage = bdf.get_s3_storage()
# Скачиваем файл (в S3 или на локальный диск)
file_path = await download_file(sent_message, file_id=file_id, content_type=content_type, s3_storage=s3_storage)
if not file_path: if not file_path:
logger.error(f"add_in_db_media: Не удалось скачать файл {file_id} для сообщения {post_id}") logger.error(f"add_in_db_media: Не удалось скачать файл {file_id} для сообщения {post_id}")
return False return False
@@ -461,7 +515,8 @@ async def add_in_db_media(sent_message: types.Message, bot_db: Any) -> bool:
success = await bot_db.add_post_content(post_id, sent_message.message_id, file_path, content_type) success = await bot_db.add_post_content(post_id, sent_message.message_id, file_path, content_type)
if not success: if not success:
logger.error(f"add_in_db_media: Не удалось добавить контент в БД для сообщения {post_id}") logger.error(f"add_in_db_media: Не удалось добавить контент в БД для сообщения {post_id}")
# Удаляем скачанный файл при ошибке БД # Удаляем скачанный файл при ошибке БД (только если это локальный файл, не S3)
if file_path.startswith('files/'):
try: try:
os.remove(file_path) os.remove(file_path)
logger.debug(f"add_in_db_media: Удален файл {file_path} после ошибки БД") logger.debug(f"add_in_db_media: Удален файл {file_path} после ошибки БД")
@@ -484,58 +539,91 @@ async def add_in_db_media(sent_message: types.Message, bot_db: Any) -> bool:
@track_media_processing("media_group") @track_media_processing("media_group")
@db_query_time("send_media_group_message_to_private_chat", "posts", "insert") @db_query_time("send_media_group_message_to_private_chat", "posts", "insert")
async def send_media_group_message_to_private_chat(chat_id: int, message: types.Message, async def send_media_group_message_to_private_chat(chat_id: int, message: types.Message,
media_group: List, bot_db: Any, main_post_id: Optional[int] = None) -> int: media_group: List, bot_db: Any, main_post_id: Optional[int] = None, s3_storage=None) -> List[int]:
sent_message = await message.bot.send_media_group( """
Отправляет медиагруппу в чат и возвращает все message_id отправленных сообщений.
Args:
chat_id: ID чата для отправки
message: Оригинальное сообщение от пользователя
media_group: Список InputMedia объектов
bot_db: Экземпляр базы данных
main_post_id: ID основного поста в БД (опционально)
s3_storage: S3StorageService для сохранения медиа
Returns:
List[int]: Список всех message_id отправленных сообщений медиагруппы
"""
sent_messages = await message.bot.send_media_group(
chat_id=chat_id, chat_id=chat_id,
media=media_group, media=media_group,
) )
post = TelegramPost(
message_id=sent_message[-1].message_id, sent_message_ids = [msg.message_id for msg in sent_messages]
text=sent_message[-1].caption or "", main_message_id = sent_message_ids[-1]
author_id=message.from_user.id,
created_at=int(datetime.now().timestamp()) asyncio.create_task(_save_media_group_background(sent_messages, bot_db, main_message_id, s3_storage))
)
await bot_db.add_post(post) return sent_message_ids
success = await add_in_db_media_mediagroup(sent_message, bot_db, main_post_id)
if not success:
logger.warning(f"send_media_group_message_to_private_chat: Не удалось сохранить медиа для медиагруппы {sent_message[-1].message_id}")
message_id = sent_message[-1].message_id
return message_id
@track_time("send_media_group_to_channel", "helper_func") @track_time("send_media_group_to_channel", "helper_func")
@track_errors("helper_func", "send_media_group_to_channel") @track_errors("helper_func", "send_media_group_to_channel")
@track_media_processing("media_group") @track_media_processing("media_group")
async def send_media_group_to_channel(bot, chat_id: int, post_content: List, post_text: str): async def send_media_group_to_channel(bot, chat_id: int, post_content: List, post_text: str, s3_storage = None):
""" """
Отправляет медиа-группу с подписью к последнему файлу. Отправляет медиа-группу с подписью к последнему файлу.
Args: Args:
bot: Экземпляр бота aiogram. bot: Экземпляр бота aiogram.
chat_id: ID чата для отправки. chat_id: ID чата для отправки.
post_content: Список кортежей с путями к файлам. post_content: Список кортежей с путями к файлам (локальные пути или S3 ключи).
post_text: Текст подписи. post_text: Текст подписи.
s3_storage: опциональный S3StorageService для работы с S3.
""" """
logger.info(f"Начинаю отправку медиа-группы в чат {chat_id}, количество файлов: {len(post_content)}") logger.info(f"Начинаю отправку медиа-группы в чат {chat_id}, количество файлов: {len(post_content)}")
media = [] # Получаем s3_storage если не передан
for i, file_path in enumerate(post_content): if s3_storage is None:
try: bdf = get_global_instance()
file = FSInputFile(path=file_path[0]) s3_storage = bdf.get_s3_storage()
type = file_path[1]
logger.debug(f"Обрабатываю файл {i+1}/{len(post_content)}: {file_path[0]} (тип: {type})")
if type == 'video': media = []
temp_files = [] # Для хранения путей к временным файлам
try:
for i, file_path_tuple in enumerate(post_content):
try:
file_path, content_type = file_path_tuple
logger.debug(f"Обрабатываю файл {i+1}/{len(post_content)}: {file_path} (тип: {content_type})")
# Проверяем, это S3 ключ или локальный путь
actual_path = file_path
if s3_storage and not file_path.startswith('files/') and not os.path.exists(file_path):
# Это S3 ключ, скачиваем во временный файл
temp_path = await s3_storage.download_to_temp(file_path)
if not temp_path:
logger.error(f"Не удалось скачать файл из S3: {file_path}")
continue
temp_files.append(temp_path)
actual_path = temp_path
elif not os.path.exists(file_path):
logger.error(f"Файл не найден: {file_path}")
continue
file = FSInputFile(path=actual_path)
if content_type == 'video':
media.append(types.InputMediaVideo(media=file)) media.append(types.InputMediaVideo(media=file))
elif type == 'photo': elif content_type == 'photo':
media.append(types.InputMediaPhoto(media=file)) media.append(types.InputMediaPhoto(media=file))
else: else:
logger.warning(f"Неизвестный тип файла: {type} для {file_path[0]}") logger.warning(f"Неизвестный тип файла: {content_type} для {file_path}")
except FileNotFoundError: except FileNotFoundError:
logger.error(f"Файл не найден: {file_path[0]}") logger.error(f"Файл не найден: {file_path_tuple[0]}")
return continue
except Exception as e: except Exception as e:
logger.error(f"Ошибка при обработке файла {file_path[0]}: {e}") logger.error(f"Ошибка при обработке файла {file_path_tuple[0]}: {e}")
return continue
logger.info(f"Подготовлено {len(media)} медиа-файлов для отправки") logger.info(f"Подготовлено {len(media)} медиа-файлов для отправки")
@@ -547,11 +635,19 @@ async def send_media_group_to_channel(bot, chat_id: int, post_content: List, pos
logger.debug(f"Добавлена подпись к последнему файлу: {safe_post_text[:50]}{'...' if len(safe_post_text) > 50 else ''}") logger.debug(f"Добавлена подпись к последнему файлу: {safe_post_text[:50]}{'...' if len(safe_post_text) > 50 else ''}")
try: try:
await bot.send_media_group(chat_id=chat_id, media=media) sent_messages = await bot.send_media_group(chat_id=chat_id, media=media)
logger.info(f"Медиа-группа успешно отправлена в чат {chat_id}") logger.info(f"Медиа-группа успешно отправлена в чат {chat_id}, количество сообщений: {len(sent_messages)}")
return sent_messages
except Exception as e: except Exception as e:
logger.error(f"Ошибка при отправке медиа-группы в чат {chat_id}: {e}") logger.error(f"Ошибка при отправке медиа-группы в чат {chat_id}: {e}")
raise raise
finally:
# Удаляем временные файлы
for temp_file in temp_files:
try:
os.remove(temp_file)
except:
pass
@track_time("send_text_message", "helper_func") @track_time("send_text_message", "helper_func")
@track_errors("helper_func", "send_text_message") @track_errors("helper_func", "send_text_message")
@@ -575,7 +671,7 @@ async def send_text_message(chat_id, message: types.Message, post_text: str, mar
) )
sent_message = await send_with_rate_limit(_send_message, chat_id) sent_message = await send_with_rate_limit(_send_message, chat_id)
return sent_message.message_id return sent_message
@track_time("send_photo_message", "helper_func") @track_time("send_photo_message", "helper_func")
@track_errors("helper_func", "send_photo_message") @track_errors("helper_func", "send_photo_message")

View File

@@ -0,0 +1,175 @@
"""
Сервис для работы с S3 хранилищем.
"""
import aioboto3
import os
import tempfile
from typing import Optional
from pathlib import Path
from logs.custom_logger import logger
class S3StorageService:
"""Сервис для работы с S3 хранилищем."""
def __init__(self, endpoint_url: str, access_key: str, secret_key: str,
bucket_name: str, region: str = "us-east-1"):
self.endpoint_url = endpoint_url
self.access_key = access_key
self.secret_key = secret_key
self.bucket_name = bucket_name
self.region = region
self.session = aioboto3.Session()
async def upload_file(self, file_path: str, s3_key: str,
content_type: Optional[str] = None) -> bool:
"""Загружает файл в S3."""
try:
async with self.session.client(
's3',
endpoint_url=self.endpoint_url,
aws_access_key_id=self.access_key,
aws_secret_access_key=self.secret_key,
region_name=self.region
) as s3:
extra_args = {}
if content_type:
extra_args['ContentType'] = content_type
await s3.upload_file(
file_path,
self.bucket_name,
s3_key,
ExtraArgs=extra_args
)
logger.info(f"Файл загружен в S3: {s3_key}")
return True
except Exception as e:
logger.error(f"Ошибка загрузки файла в S3 {s3_key}: {e}")
return False
async def upload_fileobj(self, file_obj, s3_key: str,
content_type: Optional[str] = None) -> bool:
"""Загружает файл из объекта в S3."""
try:
async with self.session.client(
's3',
endpoint_url=self.endpoint_url,
aws_access_key_id=self.access_key,
aws_secret_access_key=self.secret_key,
region_name=self.region
) as s3:
extra_args = {}
if content_type:
extra_args['ContentType'] = content_type
await s3.upload_fileobj(
file_obj,
self.bucket_name,
s3_key,
ExtraArgs=extra_args
)
logger.info(f"Файл загружен в S3 из объекта: {s3_key}")
return True
except Exception as e:
logger.error(f"Ошибка загрузки файла в S3 из объекта {s3_key}: {e}")
return False
async def download_file(self, s3_key: str, local_path: str) -> bool:
"""Скачивает файл из S3 на локальный диск."""
try:
async with self.session.client(
's3',
endpoint_url=self.endpoint_url,
aws_access_key_id=self.access_key,
aws_secret_access_key=self.secret_key,
region_name=self.region
) as s3:
# Создаем директорию если её нет
os.makedirs(os.path.dirname(local_path), exist_ok=True)
await s3.download_file(
self.bucket_name,
s3_key,
local_path
)
logger.info(f"Файл скачан из S3: {s3_key} -> {local_path}")
return True
except Exception as e:
logger.error(f"Ошибка скачивания файла из S3 {s3_key}: {e}")
return False
async def download_to_temp(self, s3_key: str) -> Optional[str]:
"""Скачивает файл из S3 во временный файл. Возвращает путь к временному файлу."""
try:
# Определяем расширение из ключа
ext = Path(s3_key).suffix or '.bin'
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=ext)
temp_path = temp_file.name
temp_file.close()
success = await self.download_file(s3_key, temp_path)
if success:
return temp_path
else:
# Удаляем временный файл при ошибке
try:
os.remove(temp_path)
except:
pass
return None
except Exception as e:
logger.error(f"Ошибка скачивания файла из S3 во временный файл {s3_key}: {e}")
return None
async def file_exists(self, s3_key: str) -> bool:
"""Проверяет существование файла в S3."""
try:
async with self.session.client(
's3',
endpoint_url=self.endpoint_url,
aws_access_key_id=self.access_key,
aws_secret_access_key=self.secret_key,
region_name=self.region
) as s3:
await s3.head_object(Bucket=self.bucket_name, Key=s3_key)
return True
except:
return False
async def delete_file(self, s3_key: str) -> bool:
"""Удаляет файл из S3."""
try:
async with self.session.client(
's3',
endpoint_url=self.endpoint_url,
aws_access_key_id=self.access_key,
aws_secret_access_key=self.secret_key,
region_name=self.region
) as s3:
await s3.delete_object(Bucket=self.bucket_name, Key=s3_key)
logger.info(f"Файл удален из S3: {s3_key}")
return True
except Exception as e:
logger.error(f"Ошибка удаления файла из S3 {s3_key}: {e}")
return False
def generate_s3_key(self, content_type: str, file_id: str) -> str:
"""Генерирует S3 ключ для файла. Один и тот же для всех постов с этим file_id."""
type_folders = {
'photo': 'photos',
'video': 'videos',
'audio': 'music',
'voice': 'voice',
'video_note': 'video_notes'
}
folder = type_folders.get(content_type, 'other')
# Определяем расширение из file_id или используем дефолтное
ext = '.jpg' if content_type == 'photo' else \
'.mp4' if content_type == 'video' else \
'.mp3' if content_type == 'audio' else \
'.ogg' if content_type == 'voice' else \
'.mp4' if content_type == 'video_note' else '.bin'
return f"{folder}/{file_id}{ext}"

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

@@ -28,3 +28,6 @@ pluggy==1.5.0
attrs~=23.2.0 attrs~=23.2.0
typing_extensions~=4.12.2 typing_extensions~=4.12.2
emoji~=2.8.0 emoji~=2.8.0
# S3 Storage (для хранения медиафайлов опубликованных постов)
aioboto3>=12.0.0

View File

@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""
Скрипт миграции для добавления поддержки опубликованных постов:
1. Добавляет колонку published_message_id в таблицу post_from_telegram_suggest
2. Создает таблицу published_post_content для хранения медиафайлов опубликованных постов
3. Создает индексы для производительности
"""
import argparse
import asyncio
import os
import sys
from pathlib import Path
project_root = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(project_root))
import aiosqlite
from logs.custom_logger import logger
DEFAULT_DB_PATH = "database/tg-bot-database.db"
def _column_exists(rows: list, name: str) -> bool:
"""Проверяет существование колонки в таблице.
PRAGMA table_info returns (cid, name, type, notnull, dflt_value, pk)."""
for row in rows:
if row[1] == name:
return True
return False
async def main(db_path: str, dry_run: bool = False) -> None:
"""Выполняет миграцию БД для поддержки опубликованных постов."""
db_path = os.path.abspath(db_path)
if not os.path.exists(db_path):
logger.error("База данных не найдена: %s", db_path)
print(f"Ошибка: база данных не найдена: {db_path}")
return
async with aiosqlite.connect(db_path) as conn:
await conn.execute("PRAGMA foreign_keys = ON")
changes_made = []
# 1. Проверяем и добавляем колонку published_message_id
cursor = await conn.execute(
"PRAGMA table_info(post_from_telegram_suggest)"
)
rows = await cursor.fetchall()
await cursor.close()
if not _column_exists(rows, "published_message_id"):
if dry_run:
print("DRY RUN: Будет добавлена колонка published_message_id в post_from_telegram_suggest")
changes_made.append("Добавление колонки published_message_id")
else:
logger.info("Добавление колонки published_message_id в post_from_telegram_suggest")
await conn.execute(
"ALTER TABLE post_from_telegram_suggest "
"ADD COLUMN published_message_id INTEGER"
)
await conn.commit()
print("✓ Колонка published_message_id добавлена в post_from_telegram_suggest")
changes_made.append("Добавлена колонка published_message_id")
else:
print("✓ Колонка published_message_id уже существует в post_from_telegram_suggest")
# 2. Проверяем и создаем таблицу published_post_content
cursor = await conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='published_post_content'"
)
table_exists = await cursor.fetchone()
await cursor.close()
if not table_exists:
if dry_run:
print("DRY RUN: Будет создана таблица published_post_content")
changes_made.append("Создание таблицы published_post_content")
else:
logger.info("Создание таблицы published_post_content")
await conn.execute("""
CREATE TABLE IF NOT EXISTS published_post_content (
published_message_id INTEGER NOT NULL,
content_name TEXT NOT NULL,
content_type TEXT,
published_at INTEGER NOT NULL,
PRIMARY KEY (published_message_id, content_name)
)
""")
await conn.commit()
print("✓ Таблица published_post_content создана")
changes_made.append("Создана таблица published_post_content")
else:
print("✓ Таблица published_post_content уже существует")
# 3. Проверяем и создаем индексы
indexes = [
("idx_published_post_content_message_id",
"CREATE INDEX IF NOT EXISTS idx_published_post_content_message_id "
"ON published_post_content(published_message_id)"),
("idx_post_from_telegram_suggest_published",
"CREATE INDEX IF NOT EXISTS idx_post_from_telegram_suggest_published "
"ON post_from_telegram_suggest(published_message_id)")
]
for index_name, index_sql in indexes:
cursor = await conn.execute(
"SELECT name FROM sqlite_master WHERE type='index' AND name=?",
(index_name,)
)
index_exists = await cursor.fetchone()
await cursor.close()
if not index_exists:
if dry_run:
print(f"DRY RUN: Будет создан индекс {index_name}")
changes_made.append(f"Создание индекса {index_name}")
else:
logger.info(f"Создание индекса {index_name}")
await conn.execute(index_sql)
await conn.commit()
print(f"✓ Индекс {index_name} создан")
changes_made.append(f"Создан индекс {index_name}")
else:
print(f"✓ Индекс {index_name} уже существует")
# Финальная статистика
if dry_run:
if changes_made:
print("\n" + "="*60)
print("DRY RUN: Следующие изменения будут выполнены:")
for change in changes_made:
print(f" - {change}")
print("="*60)
else:
print("\nВсе необходимые изменения уже применены. Ничего делать не нужно.")
else:
if changes_made:
logger.info(f"Миграция завершена. Выполнено изменений: {len(changes_made)}")
print(f"\n✓ Миграция завершена успешно!")
print(f"Выполнено изменений: {len(changes_made)}")
for change in changes_made:
print(f" - {change}")
else:
print("\nВсе необходимые изменения уже применены. Ничего делать не нужно.")
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Добавление поддержки опубликованных постов в БД"
)
parser.add_argument(
"--db",
default=os.environ.get("DB_PATH", DEFAULT_DB_PATH),
help="Путь к БД (или переменная окружения DB_PATH)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Показать что будет сделано без выполнения изменений",
)
args = parser.parse_args()
asyncio.run(main(args.db, dry_run=args.dry_run))

144
scripts/test_s3_connection.py Executable file
View File

@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""
Скрипт для проверки подключения к S3 хранилищу.
Читает настройки из .env файла или переменных окружения.
"""
import asyncio
import os
import sys
from pathlib import Path
project_root = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(project_root))
# Загружаем .env файл
from dotenv import load_dotenv
env_path = os.path.join(project_root, '.env')
if os.path.exists(env_path):
load_dotenv(env_path)
try:
import aioboto3
except ImportError:
print("❌ Библиотека aioboto3 не установлена.")
print("Установите её командой: pip install aioboto3")
sys.exit(1)
# Данные для подключения из .env или переменных окружения
S3_ACCESS_KEY = os.getenv('S3_ACCESS_KEY', 'j3tears100@gmail.com')
S3_SECRET_KEY = os.getenv('S3_SECRET_KEY', 'wQ1-6sZEPs92sbZTSf96')
S3_ENDPOINT_URL = os.getenv('S3_ENDPOINT_URL', 'https://api.s3.miran.ru:443')
S3_BUCKET_NAME = os.getenv('S3_BUCKET_NAME', 'telegram-helper-bot')
S3_REGION = os.getenv('S3_REGION', 'us-east-1')
async def test_s3_connection():
"""Тестирует подключение к S3 хранилищу."""
print("🔍 Тестирование подключения к S3 хранилищу...")
print(f"Endpoint: {S3_ENDPOINT_URL}")
print(f"Bucket: {S3_BUCKET_NAME}")
print(f"Region: {S3_REGION}")
print(f"Access Key: {S3_ACCESS_KEY}")
print()
session = aioboto3.Session()
try:
async with session.client(
's3',
endpoint_url=S3_ENDPOINT_URL,
aws_access_key_id=S3_ACCESS_KEY,
aws_secret_access_key=S3_SECRET_KEY,
region_name=S3_REGION
) as s3:
# Пытаемся получить список бакетов (может не иметь прав, пропускаем если ошибка)
print("📦 Получение списка бакетов...")
try:
response = await s3.list_buckets()
buckets = response.get('Buckets', [])
print(f"✅ Подключение успешно! Найдено бакетов: {len(buckets)}")
if buckets:
print("\n📋 Список бакетов:")
for bucket in buckets:
print(f" - {bucket['Name']} (создан: {bucket.get('CreationDate', 'неизвестно')})")
else:
print("\n⚠️ Бакеты не найдены.")
except Exception as list_error:
print(f"⚠️ Не удалось получить список бакетов: {list_error}")
print(" Это нормально, если нет прав на list_buckets")
print(" Продолжаем тестирование с указанным бакетом...")
# Пытаемся создать тестовый файл в указанном бакете
print("\n🧪 Тестирование записи файла...")
# Используем первый найденный бакет, если указанный не найден
test_bucket = S3_BUCKET_NAME
if buckets:
# Проверяем, есть ли указанный бакет в списке
bucket_names = [b['Name'] for b in buckets]
if test_bucket not in bucket_names:
print(f"⚠️ Бакет '{test_bucket}' не найден в списке.")
print(f" Используем первый найденный бакет: '{buckets[0]['Name']}'")
test_bucket = buckets[0]['Name']
test_key = 'test-connection.txt'
test_content = b'Test connection to S3 storage'
try:
# Проверяем существование бакета
try:
await s3.head_bucket(Bucket=test_bucket)
print(f"✅ Бакет '{test_bucket}' существует и доступен")
except Exception as head_error:
print(f"❌ Бакет '{test_bucket}' недоступен: {head_error}")
print(" Проверьте права доступа к бакету")
return False
await s3.put_object(
Bucket=test_bucket,
Key=test_key,
Body=test_content
)
print(f"✅ Файл успешно записан в бакет '{test_bucket}' с ключом '{test_key}'")
# Пытаемся прочитать файл
print("🧪 Тестирование чтения файла...")
response = await s3.get_object(Bucket=test_bucket, Key=test_key)
content = await response['Body'].read()
if content == test_content:
print("✅ Файл успешно прочитан, содержимое совпадает")
else:
print("⚠️ Файл прочитан, но содержимое не совпадает")
# Удаляем тестовый файл
print("🧹 Удаление тестового файла...")
await s3.delete_object(Bucket=test_bucket, Key=test_key)
print("✅ Тестовый файл удален")
except Exception as e:
print(f"❌ Ошибка при тестировании записи/чтения: {e}")
print(f" Тип ошибки: {type(e).__name__}")
import traceback
print(f" Полный traceback:")
traceback.print_exc()
print("\nВозможные причины:")
print(" 1. Неверное имя бакета")
print(" 2. Нет прав на запись в бакет")
print(" 3. Неверный endpoint URL или регион")
print(" 4. Проблемы с форматом endpoint (попробуйте без :443)")
return True
except Exception as e:
print(f"❌ Ошибка подключения к S3: {e}")
print("\nВозможные причины:")
print(" 1. Неверные credentials (Access Key / Secret Key)")
print(" 2. Неверный endpoint URL")
print(" 3. Проблемы с сетью")
print(" 4. Неверный регион (попробуйте изменить region_name)")
return False
if __name__ == "__main__":
result = asyncio.run(test_s3_connection())
sys.exit(0 if result else 1)

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):
with patch('asyncio.create_task'): # Мокаем create_task, чтобы фоновая задача не выполнялась
result = await send_media_group_message_to_private_chat( result = await send_media_group_message_to_private_chat(
100, mock_message, [], mock_db, main_post_id=789 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):
with patch('asyncio.create_task'): # Мокаем create_task, чтобы фоновая задача не выполнялась
result = await send_media_group_message_to_private_chat( result = await send_media_group_message_to_private_chat(
100, mock_message, [], mock_db, main_post_id=789 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
# Мокаем rate_limiter (он импортируется внутри функции)
with patch('helper_bot.utils.rate_limiter.send_with_rate_limit', new_callable=AsyncMock) as mock_rate_limit:
mock_rate_limit.return_value = mock_sent_message
result = await send_text_message(123, mock_message, "Тестовое сообщение") result = await send_text_message(123, mock_message, "Тестовое сообщение")
assert result == 456 assert result == mock_sent_message
mock_message.bot.send_message.assert_called_once_with( assert result.message_id == 456
chat_id=123,
text="Тестовое сообщение"
)
@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
# Мокаем rate_limiter (он импортируется внутри функции)
with patch('helper_bot.utils.rate_limiter.send_with_rate_limit', new_callable=AsyncMock) as mock_rate_limit:
mock_rate_limit.return_value = mock_sent_message
result = await send_text_message(123, mock_message, "Тестовое сообщение", mock_markup) result = await send_text_message(123, mock_message, "Тестовое сообщение", mock_markup)
assert result == 456 assert result == mock_sent_message
mock_message.bot.send_message.assert_called_once_with( assert result.message_id == 456
chat_id=123,
text="Тестовое сообщение",
reply_markup=mock_markup
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_photo_message(self): async def test_send_photo_message(self):