Merge pull request #13 from KerradKerridi/dev-11

Dev 11
This commit was merged in pull request #13.
This commit is contained in:
ANDREY KATYKHIN
2026-01-25 16:22:31 +03:00
committed by GitHub
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)
###########################################
FROM python:3.9-alpine as builder
FROM python:3.11.9-alpine as builder
# Устанавливаем инструменты для компиляции + linux-headers для psutil
RUN apk add --no-cache \
@@ -21,7 +21,7 @@ RUN pip install --no-cache-dir --target /install -r requirements.txt
###########################################
# Этап 2: Финальный образ (Runtime)
###########################################
FROM python:3.9-alpine as runtime
FROM python:3.11.9-alpine as runtime
# Минимальные рантайм-зависимости
RUN apk add --no-cache \
@@ -34,7 +34,7 @@ RUN addgroup -g 1001 deploy && adduser -D -u 1001 -G deploy deploy
WORKDIR /app
# Копируем зависимости
COPY --from=builder --chown=1001:1001 /install /usr/local/lib/python3.9/site-packages
COPY --from=builder --chown=1001:1001 /install /usr/local/lib/python3.11/site-packages
# Создаем структуру папок
RUN mkdir -p database logs voice_users && \

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

View File

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

View File

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

710
docs/IMPROVEMENTS.md Normal file
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
TEST_GROUP=-1001234567890
# S3 Storage (для хранения медиафайлов опубликованных постов)
S3_ENABLED=false
S3_ENDPOINT_URL=https://api.s3.ru
S3_ACCESS_KEY=your_s3_access_key_here
S3_SECRET_KEY=your_s3_secret_key_here
S3_BUCKET_NAME=your_s3_bucket_name
S3_REGION=us-east-1
# Bot Settings
PREVIEW_LINK=false
LOGS=false

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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"
version = "1.0.0"
description = "Telegram bot with monitoring and metrics"
requires-python = ">=3.9"
requires-python = ">=3.11"
[tool.pytest.ini_options]
testpaths = ["tests"]

View File

@@ -28,3 +28,6 @@ pluggy==1.5.0
attrs~=23.2.0
typing_extensions~=4.12.2
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()
call_args = blacklist_repository._execute_query_with_result.call_args
assert call_args[0][0] == "SELECT user_id, message_for_user, date_to_unban, created_at FROM blacklist LIMIT ?, ?"
# Нормализуем SQL запрос (убираем лишние пробелы и переносы строк)
actual_query = ' '.join(call_args[0][0].split())
expected_query = "SELECT user_id, message_for_user, date_to_unban, created_at, ban_author FROM blacklist LIMIT ?, ?"
assert actual_query == expected_query
assert call_args[0][1] == (0, 10)
# Проверяем логирование
@@ -250,10 +253,10 @@ class TestBlacklistRepository:
@pytest.mark.asyncio
async def test_get_all_users_no_limit(self, blacklist_repository):
"""Тест получения всех пользователей без лимитов"""
# Симулируем результат запроса
# Симулируем результат запроса (теперь включает ban_author)
mock_rows = [
(12345, "Нарушение правил", int(time.time()) + 86400, int(time.time())),
(67890, "Постоянный бан", None, int(time.time()) - 86400)
(12345, "Нарушение правил", int(time.time()) + 86400, int(time.time()), 999),
(67890, "Постоянный бан", None, int(time.time()) - 86400, None)
]
blacklist_repository._execute_query_with_result.return_value = mock_rows
@@ -266,7 +269,10 @@ class TestBlacklistRepository:
blacklist_repository._execute_query_with_result.assert_called_once()
call_args = blacklist_repository._execute_query_with_result.call_args
assert call_args[0][0] == "SELECT user_id, message_for_user, date_to_unban, created_at FROM blacklist"
# Нормализуем SQL запрос (убираем лишние пробелы и переносы строк)
actual_query = ' '.join(call_args[0][0].split())
expected_query = "SELECT user_id, message_for_user, date_to_unban, created_at, ban_author FROM blacklist"
assert actual_query == expected_query
# Проверяем, что параметры пустые (без лимитов)
assert len(call_args[0]) == 1 # Только SQL запрос, без параметров

View File

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

View File

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

View File

@@ -19,6 +19,7 @@ class TestPostService:
db.add_post = AsyncMock()
db.update_helper_message = AsyncMock()
db.get_user_by_id = AsyncMock()
db.add_message_link = AsyncMock()
return db
@pytest.fixture
@@ -60,8 +61,11 @@ class TestPostService:
@pytest.mark.asyncio
async def test_handle_text_post_saves_raw_text(self, post_service, mock_message, mock_db):
"""Test that handle_text_post saves raw text to database"""
mock_sent_message = Mock()
mock_sent_message.message_id = 200
with patch('helper_bot.handlers.private.services.get_text_message', return_value="Formatted text"):
with patch('helper_bot.handlers.private.services.send_text_message', return_value=200):
with patch('helper_bot.handlers.private.services.send_text_message', return_value=mock_sent_message):
with patch('helper_bot.handlers.private.services.get_first_name', return_value="Test"):
with patch('helper_bot.handlers.private.services.get_reply_keyboard_for_post', return_value=None):
with patch('helper_bot.handlers.private.services.determine_anonymity', return_value=False):
@@ -83,9 +87,11 @@ class TestPostService:
async def test_handle_text_post_determines_anonymity(self, post_service, mock_message, mock_db):
"""Test that handle_text_post determines anonymity correctly"""
mock_message.text = "Тестовый пост анон"
mock_sent_message = Mock()
mock_sent_message.message_id = 200
with patch('helper_bot.handlers.private.services.get_text_message', return_value="Formatted text"):
with patch('helper_bot.handlers.private.services.send_text_message', return_value=200):
with patch('helper_bot.handlers.private.services.send_text_message', return_value=mock_sent_message):
with patch('helper_bot.handlers.private.services.get_first_name', return_value="Test"):
with patch('helper_bot.handlers.private.services.get_reply_keyboard_for_post', return_value=None):
with patch('helper_bot.handlers.private.services.determine_anonymity', return_value=True):
@@ -241,14 +247,17 @@ class TestPostService:
album = [Mock()]
album[0].caption = "Медиагруппа подпись"
mock_helper_message = Mock()
mock_helper_message.message_id = 302
with patch('helper_bot.handlers.private.services.get_text_message', return_value="Formatted"):
with patch('helper_bot.handlers.private.services.prepare_media_group_from_middlewares', return_value=[]):
with patch('helper_bot.handlers.private.services.send_media_group_message_to_private_chat', return_value=301):
with patch('helper_bot.handlers.private.services.send_media_group_message_to_private_chat', return_value=[301]):
with patch('helper_bot.handlers.private.services.get_first_name', return_value="Test"):
with patch('helper_bot.handlers.private.services.get_reply_keyboard_for_post', return_value=None):
with patch('helper_bot.handlers.private.services.send_text_message', return_value=302):
with patch('helper_bot.handlers.private.services.send_text_message', return_value=mock_helper_message):
with patch('helper_bot.handlers.private.services.determine_anonymity', return_value=True):
with patch('asyncio.sleep', return_value=None):
with patch('asyncio.sleep', new_callable=AsyncMock):
await post_service.handle_media_group_post(mock_message, album, "Test")
@@ -257,7 +266,7 @@ class TestPostService:
main_post = calls[0][0][0]
assert main_post.text == "Медиагруппа подпись" # Raw caption
assert main_post.message_id == 300
assert main_post.message_id == 301 # Последний message_id из списка
assert main_post.is_anonymous is True
@pytest.mark.asyncio
@@ -269,14 +278,17 @@ class TestPostService:
album = [Mock()]
album[0].caption = None
mock_helper_message = Mock()
mock_helper_message.message_id = 303
with patch('helper_bot.handlers.private.services.get_text_message', return_value=" "):
with patch('helper_bot.handlers.private.services.prepare_media_group_from_middlewares', return_value=[]):
with patch('helper_bot.handlers.private.services.send_media_group_message_to_private_chat', return_value=302):
with patch('helper_bot.handlers.private.services.send_media_group_message_to_private_chat', return_value=[302]):
with patch('helper_bot.handlers.private.services.get_first_name', return_value="Test"):
with patch('helper_bot.handlers.private.services.get_reply_keyboard_for_post', return_value=None):
with patch('helper_bot.handlers.private.services.send_text_message', return_value=303):
with patch('helper_bot.handlers.private.services.send_text_message', return_value=mock_helper_message):
with patch('helper_bot.handlers.private.services.determine_anonymity', return_value=False):
with patch('asyncio.sleep', return_value=None):
with patch('asyncio.sleep', new_callable=AsyncMock):
await post_service.handle_media_group_post(mock_message, album, "Test")

View File

@@ -172,7 +172,7 @@ class TestAdminService:
# Act & Assert
with pytest.raises(UserAlreadyBannedError, match=f"Пользователь {user_id} уже заблокирован"):
await self.admin_service.ban_user(user_id, username, reason, ban_days)
await self.admin_service.ban_user(user_id, username, reason, ban_days, ban_author_id=999)
@pytest.mark.asyncio
async def test_ban_user_permanent(self):

View File

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