diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..03bf20c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,73 @@ +# Git +.git +.gitignore + +# Python +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +env +pip-log.txt +pip-delete-this-directory.txt +.tox +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.log +.git +.mypy_cache +.pytest_cache +.hypothesis + +# Virtual environments +venv/ +env/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Project specific +.env +*.db +*.sqlite +*.sqlite3 +logs/ +*.log + +# Documentation +README.md +*.md +docs/ + +# Examples +examples/ + +# Tests +tests/ +test_*.py +*_test.py + +# Temporary files +*.tmp +*.temp diff --git a/.env_example b/.env_example new file mode 100644 index 0000000..11d6004 --- /dev/null +++ b/.env_example @@ -0,0 +1,63 @@ +# Пример файла .env для настройки бота +# Скопируйте этот файл в .env и заполните своими данными + +# Токен бота от @BotFather +BOT_TOKEN=your_bot_token_here + +# ID администраторов через запятую (можно получить у @userinfobot) +ADMINS=123456789,987654321 + +# Путь к базе данных SQLite +DATABASE_PATH=database/anon_qna.db + +# Режим отладки (true/false) +DEBUG=false + +# Максимальная длина вопроса +MAX_QUESTION_LENGTH=1000 + +# Максимальная длина ответа +MAX_ANSWER_LENGTH=2000 + +# =========================================== +# Настройки Rate Limiting +# =========================================== + +# Окружение для rate limiting (development/production/strict) +RATE_LIMIT_ENV=production + +# Основные настройки rate limiting (основаны на лимитах Telegram API) +# Telegram API лимиты: 1 сообщение/сек в личных чатах, 20 сообщений/мин в группах, 30 запросов/сек глобально +RATE_LIMIT_MESSAGES_PER_SECOND=0.5 +RATE_LIMIT_BURST_LIMIT=2 +RATE_LIMIT_RETRY_MULTIPLIER=1.5 +RATE_LIMIT_MAX_RETRY_DELAY=30.0 +RATE_LIMIT_MAX_RETRIES=3 + +# Задержки для разных типов сообщений +RATE_LIMIT_VOICE_DELAY=2.5 +RATE_LIMIT_MEDIA_DELAY=2.0 +RATE_LIMIT_TEXT_DELAY=1.5 + +# Множители для разных типов чатов +RATE_LIMIT_PRIVATE_MULTIPLIER=1.0 +RATE_LIMIT_GROUP_MULTIPLIER=0.8 +RATE_LIMIT_CHANNEL_MULTIPLIER=0.6 + +# Глобальные ограничения (консервативные настройки) +RATE_LIMIT_GLOBAL_MESSAGES_PER_SECOND=20.0 +RATE_LIMIT_GLOBAL_BURST_LIMIT=15 + +# =========================================== +# Переменные окружения для Docker +# =========================================== + +# Python настройки +PYTHONPATH=/app +PYTHONUNBUFFERED=1 + +# Путь к базе данных в Docker контейнере +DATABASE_PATH_DOCKER=/app/database/anon_qna.db + +# Путь к логам в Docker контейнере +LOGS_PATH_DOCKER=/app/logs diff --git a/.gitignore b/.gitignore index 15201ac..9a6c459 100644 --- a/.gitignore +++ b/.gitignore @@ -55,21 +55,6 @@ cover/ *.mo *.pot -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ # PyBuilder .pybuilder/ @@ -169,3 +154,16 @@ cython_debug/ # PyPI configuration file .pypirc + + +# Database files +*.db +*.db-shm +*.db-wal +database/*.db +database/*.db-shm +database/*.db-wal + +# Logs +logs/ +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0dfdd48 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +# Используем официальный Python образ +FROM python:3.9-slim + +# Устанавливаем рабочую директорию +WORKDIR /app + +# Устанавливаем системные зависимости +RUN apt-get update && apt-get install -y \ + gcc \ + g++ \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Копируем файл зависимостей +COPY requirements.txt . + +# Устанавливаем Python зависимости +RUN pip install --no-cache-dir -r requirements.txt + +# Копируем исходный код приложения +COPY . . + +# Создаем директории для данных +RUN mkdir -p database logs + +# Создаем пользователя для безопасности +RUN groupadd --gid 1001 app && \ + useradd --create-home --shell /bin/bash --uid 1001 --gid 1001 app && \ + chown -R 1001:1001 /app +USER 1001:1001 + +# Открываем порты +EXPOSE 8081 + +# Устанавливаем переменные окружения +ENV PYTHONPATH=/app +ENV PYTHONUNBUFFERED=1 + +# Добавляем healthcheck +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD curl -f http://localhost:8081/health || exit 1 + +# Команда по умолчанию +CMD ["python", "main.py"] diff --git a/README.md b/README.md index 37fe6b1..074ff92 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,1728 @@ -# AnonBot -Бот для анонимных вопросов ТГ +# 🤖 AnonBot - Telegram бот для анонимных вопросов + +Telegram-бот для приема и обработки анонимных вопросов с использованием aiogram 3.x. + +## ✨ Возможности + +- 🔗 **Персональные ссылки** - каждый пользователь получает уникальную ссылку для приема вопросов +- 👤 **Анонимность** - вопросы отправляются анонимно, личность отправителя скрыта +- 💬 **Интерактивные ответы** - удобный интерфейс для ответов на вопросы через inline кнопки +- 🔢 **Локальная нумерация** - каждый пользователь видит свои вопросы с номерами #1, #2, #3... вместо глобальных ID +- 📊 **Статистика** - подробная статистика для администраторов +- 🗄️ **База данных** - SQLite для хранения пользователей и вопросов с автоматической нумерацией +- 👑 **Админ панель** - управление ботом для администраторов +- 🔍 **Суперпользователи** - расширенные права для модерации с отображением информации об авторах вопросов +- 🏗️ **Современная архитектура** - система инъекции зависимостей для лучшей тестируемости +- 📝 **Продвинутое логирование** - автоматические декораторы, контекстное логирование, FSM отслеживание +- 🛡️ **Безопасность** - валидация данных, обработка ошибок, система ролей +- 🔍 **Централизованная валидация** - автоматическая валидация всех входных данных с санитизацией +- 🔐 **Система разрешений** - гибкая система разрешений с соблюдением принципа OCP +- 📈 **Prometheus метрики** - полный мониторинг производительности и состояния бота + +## 🚀 Быстрый старт + +### 1. Установка зависимостей + +```bash +pip install -r requirements.txt +pip install dependency-injector # Для системы инъекции зависимостей +``` + +### 2. Настройка конфигурации + +Создайте файл `.env` в корневой директории проекта: + +```env +# Токен бота от @BotFather +BOT_TOKEN=your_bot_token_here + +# ID администраторов через запятую (можно получить у @userinfobot) +ADMINS=123456789,987654321 + +# Путь к базе данных SQLite +DATABASE_PATH=database/anon_qna.db + +# Режим отладки (true/false) +DEBUG=false + + +# Максимальная длина вопроса +MAX_QUESTION_LENGTH=1000 + +# Максимальная длина ответа +MAX_ANSWER_LENGTH=2000 +``` + +### 3. Запуск бота + +#### Локальный запуск + +```bash +python main.py +``` + +или + +```bash +python bot.py +``` + +#### Запуск в Docker + +1. Соберите Docker образ: +```bash +docker build -t anon-bot . +``` + +2. Запустите контейнер: +```bash +docker run -d \ + --name anon-bot \ + --restart unless-stopped \ + -p 8081:8081 \ + -v $(pwd)/database:/app/database \ + -v $(pwd)/logs:/app/logs \ + -e BOT_TOKEN=your_bot_token_here \ + -e ADMINS=123456789,987654321 \ + -e DEBUG=false \ + anon-bot +``` + +3. Проверьте статус: +```bash +docker logs anon-bot +``` + +4. Проверьте метрики: +```bash +curl http://localhost:8081/health +``` + +## 📄 PID файл и мониторинг процесса + +AnonBot автоматически создает PID файл для отслеживания процесса и предоставляет детальную информацию о состоянии через HTTP эндпоинты. + +### PID файл + +- **Расположение**: `/tmp/anon_bot.pid` +- **Содержимое**: PID процесса бота +- **Автоматическое управление**: создается при запуске, удаляется при остановке +- **Проверка дублирования**: предотвращает запуск нескольких экземпляров + +### Эндпоинт /status + +Предоставляет детальную информацию о процессе: + +```bash +curl http://localhost:8081/status +``` + +**Пример ответа:** +```json +{ + "status": "running", + "pid": 12345, + "uptime": "2ч 15м", + "memory_usage_mb": 45.2, + "cpu_percent": 0.1, + "timestamp": 1705312200.5 +} +``` + +**Поля ответа:** +- `status` - статус процесса (running/stopped/not_found/error) +- `pid` - идентификатор процесса +- `uptime` - время работы в человекочитаемом формате +- `memory_usage_mb` - использование памяти в МБ +- `cpu_percent` - загрузка CPU в процентах +- `timestamp` - время ответа + +### Тестирование + +Для тестирования PID функционала можно использовать curl: + +```bash +# Проверка статуса процесса +curl http://localhost:8081/status + +# Проверка всех эндпоинтов +curl http://localhost:8081/ +``` + +## 📁 Структура проекта + +``` +AnonBot/ +├── main.py # Точка входа +├── bot.py # Основной файл бота +├── loader.py # Инициализация бота +├── dependencies.py # Система инъекции зависимостей +├── utils.py # Общие утилиты +├── requirements.txt # Зависимости +├── README.md # Документация +├── Dockerfile # Docker образ +├── .dockerignore # Исключения для Docker +├── .env_example # Пример переменных окружения +├── prometheus.yml # Конфигурация Prometheus +├── config/ # Конфигурация +│ ├── __init__.py +│ ├── config.py # Основная конфигурация +│ └── constants.py # Константы приложения +├── handlers/ # Обработчики сообщений +│ ├── __init__.py +│ ├── start.py # Команды /start, /help +│ ├── questions.py # Обработка анонимных вопросов +│ ├── answers.py # Обработка ответов +│ ├── admin.py # Админ функции +│ └── errors.py # Глобальная обработка ошибок +├── keyboards/ # Клавиатуры +│ ├── __init__.py +│ ├── inline.py # Inline клавиатуры +│ └── reply.py # Reply клавиатуры +├── models/ # Модели данных +│ ├── __init__.py +│ ├── user.py # Модель пользователя +│ ├── question.py # Модель вопроса +│ ├── user_block.py # Модель блокировки +│ └── user_settings.py # Модель настроек +├── services/ # Сервисы (реорганизованы по категориям) +│ ├── __init__.py +│ ├── utils.py # Общие утилиты +│ ├── auth/ # Авторизация и разрешения +│ │ ├── __init__.py +│ │ └── auth_new.py # Сервис авторизации с системой разрешений +│ ├── validation/ # Валидация входных данных +│ │ ├── __init__.py +│ │ └── input_validator.py # Централизованный валидатор +│ ├── business/ # Бизнес-логика +│ │ ├── __init__.py +│ │ ├── user_service.py # Сервис пользователей +│ │ ├── question_service.py # Сервис вопросов +│ │ ├── message_service.py # Сервис сообщений +│ │ └── pagination_service.py # Сервис пагинации +│ ├── infrastructure/ # Инфраструктурные сервисы +│ │ ├── __init__.py +│ │ ├── database.py # Работа с БД +│ │ ├── logger.py # Система логирования +│ │ ├── logging_decorators.py # Декораторы для автоматического логирования +│ │ ├── logging_utils.py # Утилиты для контекстного логирования +│ │ ├── metrics.py # Prometheus метрики +│ │ ├── http_server.py # HTTP сервер для метрик +│ │ └── pid_manager.py # Менеджер PID файлов +│ ├── rate_limiting/ # Rate limiting +│ │ ├── __init__.py +│ │ ├── rate_limit_config.py # Конфигурация rate limiting +│ │ ├── rate_limiter.py # Основной rate limiter +│ │ └── rate_limit_service.py # Сервис rate limiting +│ └── permissions/ # Система разрешений +│ ├── __init__.py +│ ├── base.py # Базовые классы +│ ├── registry.py # Реестр разрешений +│ ├── permissions.py # Стандартные разрешения +│ ├── decorators.py # Декораторы для проверки +│ └── init_permissions.py # Инициализация +├── examples/ # Примеры использования +│ └── dependency_injection_example.py +├── database/ # База данных +│ ├── __init__.py +│ ├── schema.sql # Схема БД +│ ├── crud.py # CRUD операции +│ └── examples.py # Примеры использования +├── middlewares/ # Middleware +│ ├── __init__.py +│ ├── rate_limit_middleware.py # Middleware для rate limiting +│ └── validation_middleware.py # Middleware для валидации данных +├── docs/ # Документация +└── logs/ # Логи приложения +``` + +### 🏗️ Архитектурные улучшения + +Проект был реорганизован для улучшения структуры и масштабируемости: + +#### 📁 Новая структура services + +**До реорганизации:** +- Все сервисы в корне `services/` +- Смешанные категории (бизнес-логика + инфраструктура) +- Дублирование функциональности + +**После реорганизации:** +- **`services/auth/`** - авторизация и разрешения +- **`services/business/`** - бизнес-логика (пользователи, вопросы, сообщения) +- **`services/infrastructure/`** - инфраструктурные сервисы (БД, логи, метрики) +- **`services/rate_limiting/`** - rate limiting компоненты +- **`services/permissions/`** - система разрешений (без изменений) + +#### 📁 Новая структура config + +**До реорганизации:** +- `config.py` и `constants.py` в корне проекта + +**После реорганизации:** +- **`config/`** - папка для всех конфигурационных файлов +- **`config/config.py`** - основная конфигурация +- **`config/constants.py`** - константы приложения + +#### ✅ Преимущества новой структуры + +1. **Логическая группировка** - связанные сервисы в одних папках +2. **Разделение ответственности** - бизнес-логика отдельно от инфраструктуры +3. **Масштабируемость** - легко добавлять новые сервисы в нужные категории +4. **Читаемость** - понятно, где что находится +5. **Обратная совместимость** - старые импорты продолжают работать + +#### 🔄 Обратная совместимость + +Все импорты обновлены, но старые импорты продолжают работать благодаря `__init__.py` файлам: + +```python +# Старый способ (все еще работает) +from services.database import DatabaseService +from config import config + +# Новый способ (рекомендуется) +from services.infrastructure.database import DatabaseService +from config import config +``` + +### 🔍 Система валидации входных данных + +Реализована централизованная система валидации всех входящих данных для обеспечения безопасности и стабильности: + +#### 📋 Что валидируется + +- **Telegram ID** - проверка диапазона и типа данных +- **Username** - валидация формата и допустимых символов +- **Текстовый контент** - проверка длины, HTML санитизация, защита от спама +- **Deep links** - валидация формата и длины +- **Callback data** - проверка безопасности и формата +- **Параметры пагинации** - валидация диапазонов + +#### 🛡️ Безопасность + +- **HTML санитизация** - автоматическое экранирование опасных тегов +- **Защита от спама** - проверка на повторяющиеся символы и слова +- **Валидация ID** - проверка корректности всех идентификаторов +- **Логирование** - полное логирование всех ошибок валидации с автоматическими декораторами + +#### 🏗️ Архитектура валидации + +```python +# Централизованный валидатор +from services.validation import InputValidator + +validator = InputValidator() + +# Валидация текста вопроса +result = validator.validate_question_text(text, max_length=1000) +if not result: + print(f"Ошибка: {result.error_message}") +else: + sanitized_text = result.sanitized_value + +# Валидация callback data +result = validator.validate_callback_data(callback_data) +``` + +#### 🔄 Middleware валидации + +Автоматическая валидация всех входящих данных через middleware: + +```python +# ValidationMiddleware автоматически валидирует: +# - Все callback queries +# - Все сообщения +# - Telegram ID пользователей +# - Username (если есть) +# - Chat ID +``` + +#### ✅ Преимущества + +1. **Безопасность** - защита от некорректных данных и атак +2. **Стабильность** - предотвращение ошибок от невалидных данных +3. **UX** - понятные сообщения об ошибках для пользователей +4. **Мониторинг** - полное логирование проблем валидации +5. **Централизация** - единая точка валидации для всего приложения + +## 🎯 Как это работает + +### Для пользователей: + +1. **Регистрация**: Пользователь запускает бота командой `/start` +2. **Получение ссылки**: Бот генерирует персональную ссылку формата `t.me/bot_username?start=ref_{anonymous_id}` +3. **Публикация ссылки**: Пользователь делится ссылкой в социальных сетях +4. **Получение вопросов**: Друзья переходят по ссылке и задают анонимные вопросы +5. **Ответы**: Пользователь получает уведомления и может отвечать на вопросы + +### Для отправителей вопросов: + +1. **Переход по ссылке**: Отправитель переходит по персональной ссылке пользователя +2. **Задание вопроса**: Отправляет вопрос боту +3. **Анонимность**: Вопрос передается получателю анонимно +4. **Получение ответа**: Если получатель ответит, ответ будет показан + +## 🔧 Команды бота + +### Основные команды: +- `/start` - Запуск бота и получение персональной ссылки +- `/help` - Справка по использованию бота + +### Админ команды: +- `/stats` - Показать статистику бота (только для админов) + + +## 👑 Админ панель + +Администраторы имеют доступ к дополнительным функциям: + +- 📊 **Статистика** - общая статистика бота, пользователей и вопросов +- 👥 **Пользователи** - список всех пользователей бота +- ❓ **Все вопросы** - статистика по всем вопросам +- 📢 **Рассылка** - функция рассылки (планируется) +- ⚙️ **Настройки** - просмотр текущих настроек бота + +## 🔍 Система ролей и суперпользователи + +Бот поддерживает трехуровневую систему ролей: + +### 👑 Администраторы (admin) +- Определяются в конфигурации (`ADMINS` в `.env`) +- Имеют все права доступа +- Не могут быть изменены через базу данных + +### 🔍 Суперпользователи (superuser) +- Определяются в базе данных (поле `is_superuser = TRUE`) +- Имеют расширенные права для модерации +- **Могут видеть информацию об авторах вопросов** + +#### Особенности для суперпользователей: + +**Отображение списка вопросов:** +``` +10. ✅ #2 Вопрос от @username FirstName LastName +``` + +**Уведомления о новых вопросах:** +``` +❓ Новый вопрос от @username FirstName LastName! + +📝 Вопрос: +Текст вопроса... + +📅 05.09.2025 23:27 +``` + +### 👤 Обычные пользователи (user) +- Стандартные права доступа +- Видят анонимные вопросы без информации об авторах + +### Назначение суперпользователя + +Суперпользователей можно назначать через базу данных: + +```sql +UPDATE users SET is_superuser = TRUE WHERE telegram_id = 123456789; +``` + +Или программно: + +```python +from services.infrastructure.database import DatabaseService + +async def make_superuser(telegram_id: int): + db_service = DatabaseService("database/anon_qna.db") + user = await db_service.get_user(telegram_id) + if user: + user.is_superuser = True + await db_service.update_user(user) +``` + +## 🔐 Система разрешений + +AnonBot использует современную систему разрешений, построенную с соблюдением принципа открытости/закрытости (OCP): + +### ✅ Преимущества новой системы + +- **Открытость для расширения** - новые разрешения добавляются без изменения существующего кода +- **Закрытость для модификации** - существующий код не изменяется при добавлении новых разрешений +- **Типобезопасность** - использование классов вместо строк +- **Единая точка входа** - все проверки разрешений через один интерфейс +- **Удобные декораторы** - простое применение проверок разрешений + +### 🎯 Стандартные разрешения + +| Разрешение | Описание | Доступ | +|------------|----------|--------| +| `admin` | Права администратора | Только администраторы | +| `superuser` | Права суперпользователя | Только суперпользователи | +| `view_stats` | Просмотр статистики | Администраторы + суперпользователи | +| `admin_panel` | Доступ к админ панели | Администраторы + суперпользователи | +| `manage_users` | Управление пользователями | Администраторы + суперпользователи | +| `broadcast` | Рассылка сообщений | Только администраторы | +| `view_questions` | Просмотр вопросов | Все активные пользователи | +| `ask_questions` | Задавание вопросов | Все активные незабаненные пользователи | +| `answer_questions` | Ответы на вопросы | Все активные незабаненные пользователи | + +### 🚀 Использование + +#### С декораторами (рекомендуется) + +```python +from services.permissions.decorators import require_permission + +@router.message(Command("my_command")) +@require_permission("view_stats", "❌ У вас нет прав для выполнения этой команды.") +async def my_command_handler(message: Message): + # Логика обработчика + await message.answer("Команда выполнена!") +``` + +#### Готовые декораторы + +```python +@require_permission("view_stats") # Конкретное разрешение +@require_admin() # Только администраторы +@require_superuser() # Только суперпользователи +@require_admin_or_superuser() # Администраторы или суперпользователи +@require_active_user() # Активные пользователи +@require_unbanned_user() # Незабаненные пользователи +``` + +#### Добавление нового разрешения + +```python +# 1. Создаем класс разрешения +class MyCustomPermission(Permission): + async def check(self, user_id: int, database: DatabaseService, config) -> bool: + return user_id in config.ADMINS + +# 2. Регистрируем разрешение +register_permission(MyCustomPermission()) + +# 3. Используем в обработчике +@require_permission("my_custom_permission") +async def my_handler(message: Message): + # Логика обработчика + pass +``` + +## 🛡️ Безопасность + +- **Валидация**: Проверка всех входящих данных +- **Обработка ошибок**: Глобальная обработка ошибок с уведомлениями админов +- **Логирование**: Автоматическое логирование с декораторами, контекстная информация, FSM отслеживание +- **Система ролей**: Трехуровневая система доступа (админ/суперпользователь/пользователь) +- **Система разрешений**: Гибкая система разрешений с соблюдением принципа OCP + +## 🏗️ Система инъекции зависимостей + +AnonBot использует современную систему инъекции зависимостей, построенную на основе **MagicData** из aiogram 3.x. Это обеспечивает: + +- **Тестируемость**: Легко мокать зависимости в тестах +- **Читаемость**: Явные зависимости в сигнатурах функций +- **Гибкость**: Легко заменять реализации сервисов +- **Обратная совместимость**: Старый код продолжает работать + +### Доступные сервисы + +1. **DatabaseService** - работа с базой данных, CRUD операции +2. **AuthService** - авторизация, проверка прав, управление ролями +3. **InputValidator** - централизованная валидация всех входных данных +4. **UtilsService** - форматирование данных, утилиты +5. **RateLimitService** - управление rate limiting, статистика +6. **Config** - доступ к конфигурации приложения + +### Быстрый старт с DI + +```python +# Подключение в loader.py +from dependencies import DependencyMiddleware, get_dependencies + +async def init_dispatcher() -> Dispatcher: + dp = Dispatcher(storage=storage) + + # Инициализируем зависимости + deps = get_dependencies() + await deps.init() + + # Добавляем middleware + dp.update.middleware(DependencyMiddleware(deps)) + + return dp + +# Использование в обработчиках +from dependencies import inject_start_services, inject_question_services, inject_answer_services +from services.infrastructure.database import DatabaseService +from services.auth.auth_new import AuthService +from services.validation import InputValidator + +@router.message(Command("start")) +@inject_start_services +async def cmd_start( + message: Message, + user_service: UserService, + auth: AuthService, + utils: UtilsService, + message_service: MessageService, + validator: InputValidator +): + # Валидируем входные данные + user_id_validation = validator.validate_telegram_id(message.from_user.id) + if not user_id_validation: + await message.answer("❌ Ошибка: недопустимый ID пользователя") + return + + # Используем сервисы без хардкода + is_admin = auth.is_admin(message.from_user.id) + user = await user_service.get_user_by_telegram_id(message.from_user.id) +``` + +### Способы инъекции + +1. **Специализированные декораторы** (рекомендуется): + - `@inject_question_services` - для обработки вопросов + - `@inject_answer_services` - для обработки ответов + - `@inject_start_services` - для команды /start + - `@inject_link_services` - для кнопки "Моя ссылка" + - `@inject_main_menu_services` - для кнопки "Главное меню" + +2. **Базовые декораторы**: + - `@inject_database` - только DatabaseService + - `@inject_auth` - только AuthService + - `@inject_utils` - только UtilsService + +3. **Автоматически**: aiogram инжектит зависимости из middleware + +4. **Обратная совместимость**: старые функции продолжают работать + +### Примеры использования специализированных декораторов + +#### Обработка вопросов +```python +@router.message(StateFilter(QuestionStates.waiting_for_question)) +@inject_question_services +async def process_anonymous_question( + message: Message, + state: FSMContext, + question_service: QuestionService, + user_service: UserService, + message_service: MessageService, + validator: InputValidator +): + # Валидируем и обрабатываем вопрос + validation_result = validator.validate_question_text(message.text) + if not validation_result: + await message_service.send_message(message, "❌ Неверный формат вопроса") + return + + # Создаем вопрос через сервис + question = await question_service.create_question( + message.from_user.id, + target_user_id, + validation_result.sanitized_value + ) +``` + +#### Обработка ответов +```python +@router.message(StateFilter(AnswerStates.waiting_for_answer)) +@inject_answer_services +async def process_new_answer( + message: Message, + state: FSMContext, + validator: InputValidator +): + # Валидируем ответ + validation_result = validator.validate_answer_text(message.text) + if not validation_result: + await message.answer("❌ Неверный формат ответа") + return + + # Сохраняем ответ + # ... логика сохранения +``` + +### Локальная нумерация вопросов + +#### Отображение вопросов с локальными номерами +```python +# В модели Question добавлен метод get_display_number() +def get_display_number(self) -> int: + """Получить номер вопроса для отображения (приоритет user_question_number)""" + return self.user_question_number if self.user_question_number is not None else self.id + +# В обработчиках используется локальная нумерация +questions_text += f"{i}. {emoji} #{question.get_display_number()}" +``` + +#### Автоматическая нумерация через триггеры БД +```sql +-- Триггер для автоматического вычисления номера при создании +CREATE TRIGGER calculate_user_question_number +AFTER INSERT ON questions +FOR EACH ROW +WHEN NEW.user_question_number IS NULL +BEGIN + UPDATE questions + SET user_question_number = ( + SELECT COALESCE(MAX(user_question_number), 0) + 1 + FROM questions q2 + WHERE q2.to_user_id = NEW.to_user_id + AND q2.status != 'deleted' + ) + WHERE id = NEW.id; +END; + +-- Триггер для пересчета номеров при удалении +CREATE TRIGGER recalculate_user_question_numbers_on_delete +AFTER UPDATE ON questions +FOR EACH ROW +WHEN NEW.status = 'deleted' AND OLD.status != 'deleted' +BEGIN + UPDATE questions + SET user_question_number = user_question_number - 1 + WHERE to_user_id = NEW.to_user_id + AND user_question_number > OLD.user_question_number + AND status != 'deleted'; + + UPDATE questions + SET user_question_number = NULL + WHERE id = NEW.id; +END; +``` + +#### Обработка команд +```python +@router.message(Command("start")) +@inject_start_services +async def cmd_start( + message: Message, + state: FSMContext, + user_service: UserService, + auth: AuthService, + utils: UtilsService, + message_service: MessageService, + validator: InputValidator +): + # Создаем или обновляем пользователя + user = await user_service.create_or_update_user(message.from_user, message.chat.id) + + # Проверяем права + is_admin = auth.is_admin(user.telegram_id) + + # Отправляем приветствие + await message_service.send_message(message, welcome_text, keyboard) +``` + +### Преимущества специализированных декораторов + +✅ **Нет проблем с `dispatcher`** - aiogram не передает лишние параметры +✅ **Меньше зависимостей** - инжектируются только нужные сервисы +✅ **Лучшая производительность** - меньше объектов создается +✅ **Более явный код** - видно, какие зависимости используются +✅ **Легче тестировать** - меньше моков нужно создавать + +### Тестирование + +Система DI значительно упрощает тестирование: + +```python +# Создание тестовых зависимостей +@pytest.fixture +async def test_dependencies(): + deps = Dependencies() + deps._database = AsyncMock() # Мокаем БД + deps._auth = MagicMock() # Мокаем авторизацию + return deps + +# Тестирование обработчиков +@pytest.mark.asyncio +async def test_cmd_start(test_dependencies): + # Настраиваем моки + test_dependencies._user_service.get_user_by_telegram_id.return_value = mock_user + test_dependencies._auth.is_admin.return_value = False + + # Тестируем обработчик + result = await cmd_start( + message, + state, + user_service=test_dependencies.user_service, + auth=test_dependencies.auth, + utils=test_dependencies.utils, + message_service=test_dependencies.message_service, + validator=test_dependencies.validator + ) + + # Проверяем результат + assert result is not None +``` + +Подробная документация: [DI_SETUP.md](DI_SETUP.md) + +## 📊 База данных + +Бот использует SQLite для хранения данных: + +### Таблицы: +- **users**: Информация о пользователях (ID, имя, ссылка, статус, права суперпользователя) +- **questions**: Вопросы и ответы (текст, статус, анонимность, локальная нумерация) +- **user_blocks**: Блокировки пользователей +- **user_settings**: Настройки пользователей (уведомления, язык) + +### Особенности: +- **Внешние ключи**: Связи между таблицами +- **Триггеры**: Автоматическое обновление timestamps и нумерация вопросов +- **Индексы**: Оптимизация запросов, включая индексы для локальной нумерации +- **CRUD операции**: Полный набор операций для каждой таблицы +- **Локальная нумерация**: Каждый пользователь видит свои вопросы с номерами #1, #2, #3... + +### Автоматическая нумерация вопросов: +- **Триггер `calculate_user_question_number`**: Автоматически присваивает номер при создании вопроса +- **Триггер `recalculate_user_question_numbers_on_delete`**: Пересчитывает номера при удалении вопроса +- **Удаленные вопросы**: Не участвуют в нумерации (status = 'deleted') +- **Уникальные номера**: Гарантируется уникальность номеров в рамках каждого пользователя + +### Схема: +Схема базы данных находится в файле `database/schema.sql` + +## 🔧 Настройка + +Все настройки находятся в файле `.env`: + +- `BOT_TOKEN` - токен бота (обязательно) +- `ADMINS` - список ID администраторов +- `DATABASE_PATH` - путь к файлу базы данных +- `DEBUG` - режим отладки +- `MAX_QUESTION_LENGTH` - максимальная длина вопроса +- `MAX_ANSWER_LENGTH` - максимальная длина ответа + +#### Настройки Rate Limiting: +- `RATE_LIMIT_ENV` - окружение (development/production/strict) +- `RATE_LIMIT_MESSAGES_PER_SECOND` - сообщений в секунду на чат +- `RATE_LIMIT_BURST_LIMIT` - максимум сообщений подряд +- `RATE_LIMIT_RETRY_MULTIPLIER` - множитель для задержки при retry +- `RATE_LIMIT_MAX_RETRY_DELAY` - максимальная задержка между попытками +- `RATE_LIMIT_MAX_RETRIES` - максимальное количество повторных попыток + +## 🚀 Развертывание + +### Локальный запуск: +```bash +python main.py +``` + +### Docker (планируется): +```bash +docker build -t anonbot . +docker run -d --name anonbot anonbot +``` + +### VPS/Сервер: +1. Загрузите код на сервер +2. Установите зависимости: `pip install -r requirements.txt` +3. Настройте `.env` файл +4. **Для обновления существующей базы данных** (если нужно добавить поле `is_superuser`): + ```bash + python3 -c " + import asyncio + import aiosqlite + + async def migrate(): + async with aiosqlite.connect('database/anon_qna.db') as conn: + await conn.execute('ALTER TABLE users ADD COLUMN is_superuser BOOLEAN DEFAULT FALSE') + await conn.execute('CREATE INDEX IF NOT EXISTS idx_users_is_superuser ON users(is_superuser)') + await conn.commit() + print('Миграция завершена!') + + asyncio.run(migrate()) + " + ``` +5. Запустите: `python main.py` +6. Рекомендуется использовать systemd или supervisor для автозапуска + +## 📝 Система логирования + +В проекте настроена продвинутая система логирования с использованием библиотеки **loguru** и автоматических декораторов. Логи выводятся в stderr для корректной работы в Docker контейнерах. + +### Основные компоненты + +- **services/infrastructure/logger.py** - основная настройка системы логирования +- **services/infrastructure/logging_decorators.py** - декораторы для автоматического логирования +- **services/infrastructure/logging_utils.py** - утилиты для контекстного логирования +- **loguru** - библиотека для логирования (уже добавлена в requirements.txt) + +### Уровни логирования + +- **INFO** - основная информация о работе бота +- **WARNING** - предупреждения о потенциальных проблемах +- **ERROR** - ошибки, требующие внимания + +### Формат логов + +``` +2024-01-15 10:30:45 | INFO | bot:main:25 - 🚀 Запуск бота в режиме polling +2024-01-15 10:30:45 | INFO | loader:init_bot:28 - 🤖 Инициализация Telegram бота +2024-01-15 10:30:45 | INFO | loader:init_bot:33 - ✅ Бот успешно инициализирован +``` + +### 🎯 Автоматические декораторы логирования + +Система включает в себя набор декораторов для автоматического логирования: + +#### 1. Основные декораторы + +**`@log_function_call`** - логирование входа/выхода из функций +```python +@log_function_call(log_params=True, log_result=True) +async def create_question(self, from_user_id: int, to_user_id: int, message_text: str): + # Автоматически логирует вход с параметрами и выход с результатом +``` + +**`@log_business_event`** - логирование бизнес-событий +```python +@log_business_event("create_question", log_params=True, log_result=True) +async def create_question(self, ...): + # Логирует бизнес-событие с контекстом +``` + +**`@log_fsm_transition`** - логирование FSM переходов +```python +@log_fsm_transition(to_state="waiting_for_answer") +async def answer_question_callback(callback: CallbackQuery, state: FSMContext): + # Логирует переходы между состояниями FSM +``` + +#### 2. Оптимизированные декораторы + +**`@log_middleware`** - тихое логирование для middleware +```python +@log_middleware(log_params=True, log_result=False) +async def __call__(self, handler, event, data): + # Логирует только ошибки, вход/выход в DEBUG режиме +``` + +**`@log_utility`** - декоратор для служебных функций +```python +@log_utility +def _has_attachments(message: Message) -> bool: + # Логирует только ошибки +``` + +#### 3. Контекстное логирование + +**`LoggingContext`** - контекстное логирование с дополнительной информацией +```python +context = get_logging_context(__name__) +context.add_context("user_id", user_id) +context.log_info("Пользователь выполнил действие") +``` + +**Специальные функции** для бизнес-событий: +```python +log_question_created(logger, question_id, from_user_id, to_user_id) +log_user_created(logger, user_id, username) +log_user_blocked(logger, user_id, reason) +``` + +### Покрытие логированием + +#### 1. Запуск и инициализация +- ✅ Инициализация бота +- ✅ Инициализация диспетчера +- ✅ Инициализация базы данных +- ✅ Регистрация обработчиков +- ✅ Уведомления администраторов + +#### 2. База данных (CRUD операции) +- ✅ Создание пользователей +- ✅ Создание вопросов +- ✅ Обновление вопросов +- ✅ Инициализация БД + +#### 3. Обработчики команд +- ✅ Команда /start +- ✅ Обработка deep links +- ✅ Создание/обновление пользователей + +#### 4. Бизнес-логика +- ✅ Отправка ответов авторам +- ✅ Обработка ошибок +- ✅ Валидация данных + +#### 5. Системные события +- ✅ Ошибки в обработчиках +- ✅ Очистка ресурсов +- ✅ Остановка бота + +#### 6. FSM состояния +- ✅ Переходы между состояниями +- ✅ Обработка FSM событий +- ✅ Отслеживание пользовательских сессий + +### Использование в коде + +#### Импорт логгера и декораторов + +```python +from services.infrastructure.logger import get_logger +from services.infrastructure.logging_decorators import ( + log_function_call, log_business_event, log_fsm_transition, + log_middleware, log_utility +) +from services.infrastructure.logging_utils import ( + log_user_action, log_business_operation, log_question_created +) + +logger = get_logger(__name__) +``` + +#### Примеры использования декораторов + +```python +# Бизнес-события +@log_business_event("create_question", log_params=True, log_result=True) +async def create_question(self, from_user_id: int, to_user_id: int, message_text: str): + # Автоматически логирует создание вопроса + pass + +# FSM переходы +@log_fsm_transition(to_state="waiting_for_answer") +async def answer_question_callback(callback: CallbackQuery, state: FSMContext): + # Логирует переход в состояние ответа + pass + +# Middleware (тихое логирование) +@log_middleware(log_params=True, log_result=False) +async def __call__(self, handler, event, data): + # Логирует только ошибки + pass + +# Служебные функции +@log_utility +def _has_attachments(message: Message) -> bool: + # Логирует только ошибки + pass +``` + +#### Контекстное логирование + +```python +# Создание контекста +context = get_logging_context(__name__) +context.add_context("user_id", user_id) +context.add_context("question_id", question_id) +context.log_info("Пользователь ответил на вопрос") + +# Специальные функции для бизнес-событий +log_question_created(logger, question_id, from_user_id, to_user_id) +log_user_created(logger, user_id, username) +log_user_blocked(logger, user_id, reason) +``` + +#### Традиционное логирование + +```python +# Информационные сообщения +logger.info("🚀 Запуск бота в режиме polling") +logger.info(f"👤 Создание пользователя: {user.telegram_id} ({user.first_name})") + +# Предупреждения +logger.warning("⚠️ Список администраторов пуст") +logger.warning(f"⚠️ Не удалось отправить уведомление админу {admin_id}: {e}") + +# Ошибки +logger.error(f"💥 Ошибка при запуске бота: {e}") +logger.error(f"💥 Ошибка в обработчике /start: {e}") +``` + +### Docker интеграция + +Логи настроены для вывода в stderr, что обеспечивает корректную работу в Docker: + +```dockerfile +# В Dockerfile +CMD ["python", "main.py"] +``` + +```bash +# Просмотр логов в Docker +docker logs +``` + +### Файловое логирование (DEBUG режим) + +В режиме отладки логи также сохраняются в файлы: + +- **logs/bot.log** - основные логи +- Ротация: 10 MB +- Хранение: 7 дней +- Сжатие: zip + +### ⚡ Производительность и оптимизация + +#### Тихие декораторы + +Для предотвращения избыточного логирования используются тихие декораторы: + +- **`@log_middleware`** - для middleware (логирует только ошибки) +- **`@log_utility`** - для служебных функций (логирует только ошибки) +- **`quiet=True`** - параметр для полного отключения логирования входа/выхода + +#### Уровни логирования + +- **INFO** - бизнес-события и важные операции +- **DEBUG** - детальная информация (только в DEBUG режиме) +- **WARNING** - предупреждения +- **ERROR** - ошибки (всегда логируются) + +#### Контекстная информация + +Декораторы автоматически извлекают контекстную информацию: +- `user_id` - ID пользователя +- `question_id` - ID вопроса +- `page` - номер страницы +- `status` - статус операции + +#### Примеры оптимизированного логирования + +```python +# Middleware - тихое логирование +@log_middleware(log_params=True, log_result=False) +async def __call__(self, handler, event, data): + # Логирует только ошибки, вход/выход в DEBUG режиме + pass + +# Служебные функции - только ошибки +@log_utility +def _has_attachments(message: Message) -> bool: + # Логирует только ошибки + pass + +# Бизнес-функции - полное логирование +@log_business_event("create_question", log_params=True, log_result=True) +async def create_question(self, ...): + # Полное логирование для важных операций + pass +``` + +### Мониторинг + +#### Ключевые метрики для мониторинга + +1. **Запуск/остановка бота** + - `🚀 Запуск бота в режиме polling` + - `🛑 Бот остановлен` + +2. **Пользователи** + - `👤 Создание пользователя` + - `👤 Обновление существующего пользователя` + +3. **Вопросы и ответы** + - `❓ Создание вопроса` + - `📝 Обновление вопроса` + - `📤 Отправка ответа` + +4. **Ошибки** + - `💥 Ошибка в обработчике` + - `⚠️ Предупреждения` + +#### Алерты + +Рекомендуется настроить алерты на: +- ERROR уровень логов +- Отсутствие логов более 5 минут +- Частые ошибки в обработчиках + +### Настройка уровней + +Уровень логирования настраивается через переменную окружения: + +```bash +# В .env файле +DEBUG=true # DEBUG уровень +DEBUG=false # INFO уровень (по умолчанию) +``` + +### Производительность + +- Логирование асинхронное +- Минимальное влияние на производительность +- Эмодзи в логах для быстрого визуального поиска +- Структурированный формат для парсинга + +## 🤝 Вклад в проект + +1. Fork репозитория +2. Создайте feature branch +3. Внесите изменения +4. Создайте Pull Request + +## 📄 Лицензия + +MIT License + +## 🆘 Поддержка + +Если у вас возникли проблемы: + +1. Проверьте логи бота +2. Убедитесь в правильности настройки `.env` +3. Проверьте права доступа к файлам +4. Обратитесь к администратору + +### Дополнительная документация: +- [Руководство по суперпользователям](SUPERUSER_DISPLAY_FEATURE.md) - подробное описание системы ролей и функционала для суперпользователей +- [Настройка Dependency Injection](DI_SETUP.md) - подробное руководство по системе инъекции зависимостей +- [Примеры использования DI](examples/dependency_injection_example.py) - практические примеры использования системы инъекции зависимостей + +## 📈 Prometheus метрики + +AnonBot поддерживает экспорт метрик в формате Prometheus для мониторинга и анализа производительности. + +### Эндпоинты + +- **http://localhost:8081/metrics** - экспорт метрик Prometheus +- **http://localhost:8081/health** - проверка здоровья бота +- **http://localhost:8081/ready** - готовность к работе (readiness probe) +- **http://localhost:8081/status** - информация о процессе (PID, uptime, использование ресурсов) +- **http://localhost:8081/** - информация о сервисе + +### Доступные метрики + +#### Информационные метрики +- `anon_bot_info` - Информация о боте (версия, сервис) + +#### Счетчики сообщений +- `anon_bot_messages_total` - Общее количество обработанных сообщений + - Метки: `message_type`, `status` + +#### Счетчики вопросов +- `anon_bot_questions_total` - Общее количество вопросов + - Метки: `status` (created, rejected, deleted) + +#### Счетчики ответов +- `anon_bot_answers_total` - Общее количество ответов + - Метки: `status` (sent, edited, delivered, delivery_failed) + +#### Счетчики пользователей +- `anon_bot_users_total` - Общее количество пользователей + - Метки: `action` (created, updated) + +#### Счетчики ошибок +- `anon_bot_errors_total` - Общее количество ошибок + - Метки: `error_type`, `component` + +#### HTTP метрики +- `anon_bot_http_requests_total` - Общее количество HTTP запросов + - Метки: `method`, `endpoint`, `status_code` +- `anon_bot_http_request_duration_seconds` - Время обработки HTTP запросов + - Метки: `method`, `endpoint` + +#### Время обработки +- `anon_bot_message_processing_seconds` - Время обработки сообщений + - Метки: `message_type` +- `anon_bot_question_processing_seconds` - Время обработки вопросов +- `anon_bot_answer_processing_seconds` - Время обработки ответов + +#### Gauge метрики +- `anon_bot_active_users` - Количество активных пользователей +- `anon_bot_active_questions` - Количество активных вопросов + +### Настройка Prometheus + +Добавьте в конфигурацию Prometheus (`prometheus.yml`): + +```yaml +scrape_configs: + - job_name: 'anon-bot' + static_configs: + - targets: ['localhost:8081'] + metrics_path: '/metrics' + scrape_interval: 30s +``` + +### Примеры запросов PromQL + +#### Количество сообщений в секунду +```promql +rate(anon_bot_messages_total[5m]) +``` + +#### Количество ошибок в секунду +```promql +rate(anon_bot_errors_total[5m]) +``` + +#### Время обработки сообщений (95-й процентиль) +```promql +histogram_quantile(0.95, rate(anon_bot_message_processing_seconds_bucket[5m])) +``` + +#### Количество активных пользователей +```promql +anon_bot_active_users +``` + +### Мониторинг в Docker + +При запуске в Docker убедитесь, что порт 8081 доступен: + +```yaml +ports: + - "8081:8081" +``` + +### Алерты + +Рекомендуется настроить алерты на: + +1. **Высокий уровень ошибок** + ```promql + rate(anon_bot_errors_total[5m]) > 0.1 + ``` + +2. **Медленную обработку** + ```promql + histogram_quantile(0.95, rate(anon_bot_message_processing_seconds_bucket[5m])) > 5 + ``` + +3. **Недоступность сервиса** + ```promql + up{job="anon-bot"} == 0 + ``` + +### Безопасность + +Эндпоинты метрик не требуют аутентификации. В продакшене рекомендуется: +- Ограничить доступ к эндпоинтам через firewall +- Использовать reverse proxy с аутентификацией +- Настроить TLS для HTTPS + +## 🐳 Docker + +### Сборка образа + +```bash +docker build -t anon-bot . +``` + +### Запуск контейнера + +```bash +docker run -d \ + --name anon-bot \ + --restart unless-stopped \ + -p 8081:8081 \ + -v $(pwd)/database:/app/database \ + -v $(pwd)/logs:/app/logs \ + -e BOT_TOKEN=your_bot_token_here \ + -e ADMINS=123456789,987654321 \ + -e DEBUG=false \ + anon-bot +``` + +### Управление контейнером + +```bash +# Просмотр логов +docker logs anon-bot + +# Остановка контейнера +docker stop anon-bot + +# Запуск контейнера +docker start anon-bot + +# Удаление контейнера +docker rm anon-bot + +# Перезапуск контейнера +docker restart anon-bot +``` + +### Переменные окружения + +Все переменные окружения из `.env_example` можно передать через `-e`: + +```bash +docker run -d \ + --name anon-bot \ + -p 8081:8081 \ + -e BOT_TOKEN=your_token \ + -e ADMINS=123456789 \ + -e DEBUG=false \ + -e MAX_QUESTION_LENGTH=1000 \ + -e MAX_ANSWER_LENGTH=2000 \ + anon-bot +``` + +### Volumes + +- `./database:/app/database` - персистентное хранение базы данных +- `./logs:/app/logs` - персистентное хранение логов + +### Порты + +- `8081:8081` - порт для метрик и health check + +### Health Check + +Контейнер включает встроенный health check: + +```bash +# Проверка статуса +docker inspect --format='{{.State.Health.Status}}' anon-bot + +# Просмотр деталей health check +docker inspect anon-bot | jq '.[0].State.Health' +``` + +**Параметры health check:** +- **Интервал проверки**: 30 секунд +- **Таймаут**: 10 секунд +- **Период запуска**: 60 секунд (время на инициализацию) +- **Количество попыток**: 3 +- **Команда проверки**: `curl -f http://localhost:8081/health` + +**Статусы health check:** +- `starting` - контейнер запускается +- `healthy` - контейнер здоров +- `unhealthy` - контейнер нездоров + +## 🚦 Rate Limiting + +AnonBot включает комплексную систему rate limiting для предотвращения ошибок Flood Control в Telegram Bot API. Система автоматически ограничивает скорость отправки сообщений и обрабатывает ошибки с повторными попытками. + +### Рекомендуемые настройки на основе лимитов Telegram API + +Система настроена с учетом официальных лимитов Telegram Bot API: +- **1 сообщение в секунду** в личных чатах +- **20 сообщений в минуту** в групповых чатах (0.33 в секунду) +- **30 запросов в секунду** глобально + +Настройки по умолчанию используют консервативные значения (50% от лимитов) для обеспечения стабильной работы. + +### Компоненты системы + +#### 1. Конфигурация (`services/rate_limit_config.py`) +- **RateLimitSettings**: Основные настройки rate limiting +- **Конфигурации для разных окружений**: development, production, strict +- **Адаптивная конфигурация**: Автоматическая настройка на основе уровня ошибок + +#### 2. Rate Limiter (`services/rate_limiter.py`) +- **ChatRateLimiter**: Ограничения для конкретного чата +- **GlobalRateLimiter**: Глобальные ограничения для всех чатов +- **RetryHandler**: Обработка повторных попыток с экспоненциальной задержкой +- **TelegramRateLimiter**: Основной класс, объединяющий все компоненты + +#### 3. Сервис (`services/rate_limit_service.py`) +- **RateLimitService**: Высокоуровневый сервис для управления rate limiting +- **Статистика**: Отслеживание успешных/неудачных запросов +- **Адаптация**: Автоматическая настройка конфигурации + +#### 4. Middleware (`middlewares/rate_limit_middleware.py`) +- **RateLimitMiddleware**: Автоматическое применение rate limiting ко всем сообщениям +- **Прозрачная интеграция**: Минимальные изменения в существующем коде + +#### 5. Validation Middleware (`middlewares/validation_middleware.py`) +- **ValidationMiddleware**: Автоматическая валидация всех входящих данных +- **Безопасность**: Защита от некорректных данных и атак +- **Логирование**: Оптимизированное логирование с тихими декораторами для middleware + +### Настройка + +#### Переменные окружения + +Добавьте следующие переменные в ваш `.env` файл: + +```env +# Окружение для rate limiting (development/production/strict) +RATE_LIMIT_ENV=production + +# Основные настройки rate limiting +RATE_LIMIT_MESSAGES_PER_SECOND=0.5 +RATE_LIMIT_BURST_LIMIT=2 +RATE_LIMIT_RETRY_MULTIPLIER=1.5 +RATE_LIMIT_MAX_RETRY_DELAY=30.0 +RATE_LIMIT_MAX_RETRIES=3 + +# Задержки для разных типов сообщений +RATE_LIMIT_VOICE_DELAY=2.0 +RATE_LIMIT_MEDIA_DELAY=1.5 +RATE_LIMIT_TEXT_DELAY=1.0 + +# Множители для разных типов чатов +RATE_LIMIT_PRIVATE_MULTIPLIER=1.0 +RATE_LIMIT_GROUP_MULTIPLIER=0.8 +RATE_LIMIT_CHANNEL_MULTIPLIER=0.6 + +# Глобальные ограничения +RATE_LIMIT_GLOBAL_MESSAGES_PER_SECOND=10.0 +RATE_LIMIT_GLOBAL_BURST_LIMIT=20 +``` + +#### Конфигурации по умолчанию + +**Production (по умолчанию)** +- 0.5 сообщений в секунду на чат (50% от лимита Telegram API) +- Максимум 2 сообщения подряд +- 3 повторные попытки при ошибках +- Максимальная задержка 30 секунд +- 20 глобальных запросов в секунду (из 30 доступных) + +**Development** +- 0.8 сообщений в секунду на чат (80% от лимита для тестирования) +- Максимум 3 сообщения подряд +- 2 повторные попытки при ошибках +- Максимальная задержка 15 секунд + +**Strict** +- 0.3 сообщений в секунду на чат (30% от лимита для максимальной стабильности) +- Максимум 1 сообщение подряд +- 5 повторных попыток при ошибках +- Максимальная задержка 60 секунд +- 10 глобальных запросов в секунду (консервативные настройки) + +### Использование + +#### Автоматическое использование (рекомендуется) + +Rate limiting и валидация автоматически применяются ко всем сообщениям через middleware. Никаких изменений в коде не требуется. + +**ValidationMiddleware** автоматически валидирует: +- Все callback queries +- Все сообщения +- Telegram ID пользователей +- Username (если есть) +- Chat ID + +#### Ручное использование + +```python +from services.rate_limiting.rate_limit_service import RateLimitService +from dependencies import get_rate_limit_service + +# Получение сервиса через DI +rate_limit_service = get_rate_limit_service() + +# Отправка сообщения с rate limiting +result = await rate_limit_service.send_with_rate_limit( + bot.send_message, + chat_id=user_id, + text="Привет!" +) +``` + +#### Прямое использование rate limiter + +```python +from services.rate_limiting.rate_limiter import send_with_rate_limit + +# Отправка с автоматическим rate limiting +result = await send_with_rate_limit( + bot.send_message, + chat_id=user_id, + text="Привет!" +) +``` + +#### Ручное использование валидатора + +```python +from services.validation import InputValidator +from dependencies import get_validator + +# Получение валидатора через DI +validator = get_validator() + +# Валидация текста вопроса +result = validator.validate_question_text(text, max_length=1000) +if not result: + print(f"Ошибка: {result.error_message}") +else: + sanitized_text = result.sanitized_value + +# Валидация callback data +result = validator.validate_callback_data(callback_data) +if not result: + await callback.answer("❌ Неверные данные", show_alert=True) + return +``` + +### Административные функции + +Доступ к управлению rate limiting осуществляется через админскую панель: + +1. **Перейти в админку**: `/admin` +2. **Нажать кнопку "🚦 Rate Limiting"** +3. **Выбрать нужное действие**: + - **📊 Статистика Rate Limiting** - показывает подробную статистику: + - Общее количество запросов + - Процент успеха/ошибок + - Количество RetryAfter ошибок + - Среднее время ожидания + - **🔄 Сбросить статистику** - сбрасывает всю статистику rate limiting + - **⚙️ Адаптировать конфигурацию** - адаптирует настройки на основе текущей производительности + +**Важно**: Доступ к функциям rate limiting имеют только администраторы бота (не суперпользователи). + +### Мониторинг + +#### Логирование + +Система логирует: +- Rate limiting события (DEBUG уровень) +- RetryAfter ошибки (WARNING уровень) +- Критические ошибки (ERROR уровень) + +#### Статистика + +RateLimitService отслеживает: +- Общее количество запросов +- Успешные/неудачные запросы +- Типы ошибок (RetryAfter, API ошибки) +- Время ожидания + +#### Адаптивная конфигурация + +Система автоматически адаптирует настройки: +- При >10% ошибок: ужесточает ограничения +- При <1% ошибок: ослабляет ограничения +- Требует минимум 100 запросов для адаптации + +### Интеграция с DI + +Rate limiting полностью интегрирован в систему инъекции зависимостей: + +```python +# В обработчиках +async def some_handler( + message: Message, + rate_limit_service: RateLimitService +): + # rate_limit_service автоматически инжектируется + pass +``` + +### Архитектурные особенности + +#### Соблюдение принципов + +1. **Single Responsibility**: Каждый компонент отвечает за свою задачу +2. **Open/Closed**: Легко расширяется новыми типами ограничений +3. **Dependency Inversion**: Зависит от абстракций, а не от конкретных реализаций + +#### Производительность + +- Минимальные накладные расходы +- Эффективное управление памятью +- Асинхронная обработка + +#### Надежность + +- Обработка всех типов ошибок Telegram API +- Экспоненциальная задержка при повторных попытках +- Автоматическое восстановление после ошибок + +### Устранение неполадок + +#### Высокий процент ошибок + +1. Проверьте статистику: `/ratelimit_stats` +2. Адаптируйте конфигурацию: `/adapt_ratelimit` +3. При необходимости ужесточите настройки в `.env` + +#### Медленная отправка сообщений + +1. Проверьте настройки `RATE_LIMIT_MESSAGES_PER_SECOND` +2. Увеличьте значение для более быстрой отправки +3. Убедитесь, что не превышаете лимиты Telegram API + +#### Проблемы с интеграцией + +1. Убедитесь, что middleware зарегистрирован в `loader.py` + +2. **Ошибка `TypeError: got an unexpected keyword argument 'dispatcher'`**: + - **Причина**: aiogram 3.x передает `dispatcher` во все обработчики сообщений, но `@inject_all` не обрабатывает его правильно + - **Решение**: Используйте специализированные декораторы вместо `@inject_all`: + ```python + # ❌ Неправильно + @inject_all + async def process_question(message: Message, dispatcher, ...): + + # ✅ Правильно + @inject_question_services + async def process_question(message: Message, question_service: QuestionService, ...): + ``` + - **Преимущества**: Нет проблем с `dispatcher`, меньше зависимостей, лучшая производительность + +3. Проверьте, что все зависимости корректно импортированы +4. Проверьте логи на наличие ошибок инициализации + +## 🔮 Планы развития + +- [x] **Система суперпользователей** - расширенные права для модерации с отображением информации об авторах +- [x] **Система инъекции зависимостей** - современная архитектура с DI для лучшей тестируемости +- [x] **Система разрешений** - гибкая система разрешений с соблюдением принципа OCP +- [x] **Prometheus метрики** - мониторинг производительности и ошибок +- [x] **Rate limiting** - защита от спама и DDoS с автоматической адаптацией +- [x] **Реорганизация структуры проекта** - улучшенная архитектура с логической группировкой сервисов +- [x] **Локальная нумерация вопросов** - каждый пользователь видит свои вопросы с номерами #1, #2, #3... вместо глобальных ID +- [ ] **Unit-тесты** - полное покрытие тестами с использованием DI +- [ ] **Кэширование Redis** - оптимизация производительности +- [ ] Рассылка сообщений +- [ ] Экспорт данных +- [ ] Веб-интерфейс для админов +- [ ] Поддержка медиафайлов в вопросах +- [ ] Категории вопросов +- [ ] Модерация контента +- [ ] Аналитика и отчеты + +## 📝 Changelog + +### v1.7.0 - Продвинутая система логирования (2025-01-27) + +#### ✨ Новые возможности +- **Автоматические декораторы логирования** - `@log_function_call`, `@log_business_event`, `@log_fsm_transition` +- **Контекстное логирование** - `LoggingContext` с дополнительной информацией +- **Оптимизированные декораторы** - `@log_middleware`, `@log_utility` для предотвращения избыточного логирования +- **FSM отслеживание** - автоматическое логирование переходов между состояниями +- **Специальные функции** - `log_question_created`, `log_user_created`, `log_user_blocked` +- **Тихие декораторы** - предотвращение рекурсивного логирования в middleware + +#### 🔧 Улучшения +- **100% покрытие логированием** - все функции теперь логируются автоматически +- **Улучшенная производительность** - оптимизированное логирование для middleware +- **Детальная диагностика** - полная трассировка выполнения функций +- **Контекстная информация** - автоматическое извлечение user_id, question_id и других параметров + +#### 📁 Новые файлы +- `services/infrastructure/logging_decorators.py` - декораторы для автоматического логирования +- `services/infrastructure/logging_utils.py` - утилиты для контекстного логирования + +### v1.6.0 - Локальная нумерация вопросов (2025-01-27) + +#### ✨ Новые возможности +- **Локальная нумерация вопросов**: Каждый пользователь теперь видит свои вопросы с номерами #1, #2, #3... вместо глобальных ID +- **Автоматическая нумерация**: Триггеры БД автоматически присваивают и пересчитывают номера вопросов +- **Умная нумерация**: Удаленные вопросы не участвуют в нумерации, номера автоматически пересчитываются при удалении + +#### 🔧 Технические изменения +- **Новое поле БД**: `user_question_number` в таблице `questions` +- **Триггеры БД**: + - `calculate_user_question_number` - автоматическое вычисление номера при создании + - `recalculate_user_question_numbers_on_delete` - пересчет номеров при удалении +- **Индексы**: Оптимизированные индексы для быстрого поиска по локальным номерам +- **Модель Question**: Новый метод `get_display_number()` для получения номера для отображения + +#### 🐛 Исправления +- **Отображение номеров**: Исправлена проблема с несоответствием номеров в тексте сообщений и на кнопках +- **Пагинация**: Обновлена для работы с локальными номерами +- **CRUD операции**: Обновлены для поддержки автоматической нумерации + +#### 📊 Улучшения UX +- **Интуитивная нумерация**: Пользователи видят последовательные номера своих вопросов +- **Консистентность**: Номера в тексте и на кнопках всегда совпадают +- **Автоматизация**: Никаких ручных действий для поддержания нумерации diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..87c45d7 --- /dev/null +++ b/bot.py @@ -0,0 +1,80 @@ +""" +Основной файл для запуска Telegram бота анонимных вопросов +""" +import asyncio +import sys +from pathlib import Path + +# Добавляем корневую директорию в путь для импортов +sys.path.append(str(Path(__file__).parent)) + +from config import config +from loader import loader +from services.infrastructure.http_server import start_http_server, stop_http_server +from services.infrastructure.logger import get_logger +from services.infrastructure.pid_manager import get_pid_manager, cleanup_pid_file +from config.constants import DEFAULT_HTTP_HOST, DEFAULT_HTTP_PORT + +# Настройка логирования +logger = get_logger(__name__) + + +async def main(): + """Главная функция для запуска бота""" + http_runner = None + pid_manager = None + + try: + logger.info("🤖 Запуск бота анонимных вопросов...") + logger.info(f"📊 Режим отладки: {'Включен' if config.DEBUG else 'Выключен'}") + logger.info(f"💾 База данных: {config.DATABASE_PATH}") + logger.info(f"👑 Администраторы: {config.ADMINS}") + + # Создаем PID файл для отслеживания процесса + logger.info("📄 Создание PID файла...") + pid_manager = get_pid_manager("anon_bot") + if not pid_manager.create_pid_file(): + logger.error("❌ Не удалось создать PID файл, завершаем работу") + return + logger.info(f"✅ PID файл создан: {pid_manager.get_pid_file_path()}") + + # Запускаем HTTP сервер для метрик и health check + logger.info("🌐 Запуск HTTP сервера для метрик...") + http_runner = await start_http_server(host=DEFAULT_HTTP_HOST, port=DEFAULT_HTTP_PORT) + + # Запускаем бота + await loader.start_polling() + + except KeyboardInterrupt: + logger.info("⏹️ Получен сигнал остановки (Ctrl+C)") + except Exception as e: + logger.error(f"💥 Критическая ошибка: {e}") + raise + finally: + # Останавливаем HTTP сервер + if http_runner: + logger.info("🛑 Остановка HTTP сервера...") + await stop_http_server(http_runner) + + # Очищаем PID файл + if pid_manager: + logger.info("📄 Очистка PID файла...") + pid_manager.cleanup_pid_file() + + logger.info("🛑 Бот остановлен") + + +if __name__ == "__main__": + try: + # Проверяем конфигурацию перед запуском + config.validate() + + # Запускаем бота + asyncio.run(main()) + + except ValueError as e: + logger.error(f"❌ Ошибка конфигурации: {e}") + sys.exit(1) + except Exception as e: + logger.error(f"💥 Неожиданная ошибка: {e}") + sys.exit(1) diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..2553e82 --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,8 @@ +""" +Конфигурационный модуль AnonBot +""" + +from .config import config, Config +from .constants import * + +__all__ = ['config', 'Config'] diff --git a/config/config.py b/config/config.py new file mode 100644 index 0000000..90c2087 --- /dev/null +++ b/config/config.py @@ -0,0 +1,59 @@ +""" +Конфигурация бота для анонимных вопросов +""" +import os +from typing import List + +from dotenv import load_dotenv + +# Загружаем переменные окружения из .env файла +load_dotenv() + + +class Config: + """Класс конфигурации бота""" + + # Токен бота (обязательно) + BOT_TOKEN: str = os.getenv('BOT_TOKEN', '') + + # Список ID администраторов (через запятую) + ADMINS: List[int] = [ + int(admin_id.strip()) + for admin_id in os.getenv('ADMINS', '').split(',') + if admin_id.strip() + ] + + # Путь к базе данных SQLite + DATABASE_PATH: str = os.getenv('DATABASE_PATH', 'database/anon_qna.db') + + # Режим отладки + DEBUG: bool = os.getenv('DEBUG', 'False').lower() == 'true' + + + # Максимальная длина вопроса + MAX_QUESTION_LENGTH: int = int(os.getenv('MAX_QUESTION_LENGTH', '1000')) + + # Максимальная длина ответа + MAX_ANSWER_LENGTH: int = int(os.getenv('MAX_ANSWER_LENGTH', '2000')) + + @classmethod + def validate(cls) -> bool: + """Проверка корректности конфигурации""" + if not cls.BOT_TOKEN: + raise ValueError("BOT_TOKEN не установлен в переменных окружения") + + if not cls.ADMINS: + print("Предупреждение: ADMINS не установлен") + + return True + + +# Создаем экземпляр конфигурации +config = Config() + +# Проверяем конфигурацию при импорте +try: + config.validate() +except ValueError as e: + print(f"Ошибка конфигурации: {e}") + exit(1) diff --git a/config/constants.py b/config/constants.py new file mode 100644 index 0000000..393d0e9 --- /dev/null +++ b/config/constants.py @@ -0,0 +1,133 @@ +""" +Константы для AnonBot +""" +from typing import Final + +# ============================================================================= +# ПАГИНАЦИЯ +# ============================================================================= + +# Количество элементов на странице по умолчанию +DEFAULT_PAGE_SIZE: Final[int] = 9 + +# Максимальное количество элементов на странице +MAX_PAGE_SIZE: Final[int] = 50 + +# Минимальный номер страницы +MIN_PAGE_NUMBER: Final[int] = 0 + +# ============================================================================= +# ТЕКСТОВЫЕ ОГРАНИЧЕНИЯ +# ============================================================================= + +# Минимальная длина вопроса +MIN_QUESTION_LENGTH: Final[int] = 5 + +# Минимальная длина ответа +MIN_ANSWER_LENGTH: Final[int] = 5 + +# Длина превью вопроса по умолчанию +DEFAULT_QUESTION_PREVIEW_LENGTH: Final[int] = 100 + +# Длина обрезки текста по умолчанию +DEFAULT_TEXT_TRUNCATE_LENGTH: Final[int] = 100 + +# ============================================================================= +# СТАТИСТИКА И МЕТРИКИ +# ============================================================================= + +# Минимальное количество запросов для адаптации rate limiting +MIN_REQUESTS_FOR_ADAPTATION: Final[int] = 100 + +# Высокий уровень ошибок для rate limiting (10%) +HIGH_ERROR_RATE_THRESHOLD: Final[float] = 0.1 + +# Низкий уровень ошибок для rate limiting (1%) +LOW_ERROR_RATE_THRESHOLD: Final[float] = 0.01 + +# ============================================================================= +# HTTP СЕРВЕР +# ============================================================================= + +# Порт HTTP сервера по умолчанию +DEFAULT_HTTP_PORT: Final[int] = 8081 + +# Хост HTTP сервера по умолчанию +DEFAULT_HTTP_HOST: Final[str] = "0.0.0.0" + +# Версия приложения +APP_VERSION: Final[str] = "1.0.0" + +# ============================================================================= +# БАЗА ДАННЫХ +# ============================================================================= + +# Размер пула соединений по умолчанию +DEFAULT_CONNECTION_POOL_SIZE: Final[int] = 5 + +# Timeout для соединения с БД (секунды) +DATABASE_TIMEOUT: Final[float] = 30.0 + +# Размер кэша SQLite +SQLITE_CACHE_SIZE: Final[int] = 10000 + +# ============================================================================= +# БЕЗОПАСНОСТЬ +# ============================================================================= + +# Длина токена для анонимных пользователей +ANONYMOUS_TOKEN_LENGTH: Final[int] = 8 + +# ============================================================================= +# ФОРМАТИРОВАНИЕ +# ============================================================================= + +# Количество символов в разделителе +SEPARATOR_LENGTH: Final[int] = 30 + +# Количество знаков после запятой для процентов +PERCENTAGE_DECIMAL_PLACES: Final[int] = 1 + +# Количество знаков после запятой для времени +TIME_DECIMAL_PLACES: Final[int] = 2 + +# ============================================================================= +# СТАТУСЫ HTTP +# ============================================================================= + +# HTTP статус коды +HTTP_STATUS_OK: Final[int] = 200 +HTTP_STATUS_SERVICE_UNAVAILABLE: Final[int] = 503 +HTTP_STATUS_INTERNAL_SERVER_ERROR: Final[int] = 500 + +# ============================================================================= +# ВРЕМЕННЫЕ ИНТЕРВАЛЫ +# ============================================================================= + +# Количество дней для статистики "за неделю" +WEEK_DAYS: Final[int] = 7 + +# Количество дней для статистики "за сегодня" +TODAY_DAYS: Final[int] = 1 + +# ============================================================================= +# СИМВОЛЫ И ЭМОДЗИ +# ============================================================================= + +# Эмодзи для статусов пользователей +SUPERUSER_EMOJI: Final[str] = "🔍" +REGULAR_USER_EMOJI: Final[str] = "👤" + +# Эмодзи для навигации +PREVIOUS_PAGE_EMOJI: Final[str] = "⬅️" +NEXT_PAGE_EMOJI: Final[str] = "➡️" + +# ============================================================================= +# МАССИВЫ И СПИСКИ +# ============================================================================= + +# Пустые значения для проверки +EMPTY_VALUES: Final[tuple] = ('0', '') + +# Разрешенные обновления для polling +ALLOWED_UPDATES: Final[list] = ["message", "callback_query"] diff --git a/database/crud.py b/database/crud.py index 0e63e83..4e349e2 100644 --- a/database/crud.py +++ b/database/crud.py @@ -331,21 +331,31 @@ class QuestionCRUD(BaseCRUD): """Создание нового вопроса""" logger.info(f"❓ Создание вопроса от {question.from_user_id} к {question.to_user_id}") async with self.get_connection() as conn: + # Вычисляем user_question_number для получателя + if question.user_question_number is None: + cursor = await conn.execute(""" + SELECT COALESCE(MAX(user_question_number), 0) + 1 + FROM questions + WHERE to_user_id = ? AND status != 'deleted' + """, (question.to_user_id,)) + result = await cursor.fetchone() + question.user_question_number = result[0] if result else 1 + cursor = await conn.execute(""" INSERT INTO questions (from_user_id, to_user_id, message_text, answer_text, is_anonymous, - message_id, created_at, answered_at, is_read, status) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + message_id, created_at, answered_at, is_read, status, user_question_number) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( question.from_user_id, question.to_user_id, question.message_text, question.answer_text, question.is_anonymous, question.message_id, question.created_at.isoformat() if question.created_at else None, question.answered_at.isoformat() if question.answered_at else None, - question.is_read, question.status.value + question.is_read, question.status.value, question.user_question_number )) question.id = cursor.lastrowid await conn.commit() - logger.info(f"✅ Вопрос создан с ID: {question.id}") + logger.info(f"✅ Вопрос создан с ID: {question.id}, номер для пользователя: {question.user_question_number}") return question async def create_batch(self, questions: List[Question]) -> List[Question]: @@ -356,6 +366,27 @@ class QuestionCRUD(BaseCRUD): logger.info(f"📦 Создание {len(questions)} вопросов batch операцией") async with self.get_connection() as conn: try: + # Группируем вопросы по получателям для вычисления user_question_number + questions_by_user = {} + for question in questions: + if question.to_user_id not in questions_by_user: + questions_by_user[question.to_user_id] = [] + questions_by_user[question.to_user_id].append(question) + + # Вычисляем user_question_number для каждого пользователя + for to_user_id, user_questions in questions_by_user.items(): + cursor = await conn.execute(""" + SELECT COALESCE(MAX(user_question_number), 0) + FROM questions + WHERE to_user_id = ? AND status != 'deleted' + """, (to_user_id,)) + result = await cursor.fetchone() + start_number = (result[0] if result else 0) + 1 + + for i, question in enumerate(user_questions): + if question.user_question_number is None: + question.user_question_number = start_number + i + # Подготавливаем данные для batch вставки batch_data = [] for question in questions: @@ -364,15 +395,15 @@ class QuestionCRUD(BaseCRUD): question.answer_text, question.is_anonymous, question.message_id, question.created_at.isoformat() if question.created_at else None, question.answered_at.isoformat() if question.answered_at else None, - question.is_read, question.status.value + question.is_read, question.status.value, question.user_question_number )) # Выполняем batch вставку cursor = await conn.executemany(""" INSERT INTO questions (from_user_id, to_user_id, message_text, answer_text, is_anonymous, - message_id, created_at, answered_at, is_read, status) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + message_id, created_at, answered_at, is_read, status, user_question_number) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, batch_data) # Обновляем ID для всех созданных вопросов @@ -393,7 +424,11 @@ class QuestionCRUD(BaseCRUD): """Получение вопроса по ID""" async with self.get_connection() as conn: async with conn.execute(""" - SELECT * FROM questions WHERE id = ? + SELECT + id, from_user_id, to_user_id, message_text, answer_text, + is_anonymous, message_id, created_at, answered_at, + is_read, status, user_question_number + FROM questions WHERE id = ? """, (question_id,)) as cursor: row = await cursor.fetchone() if row: @@ -408,7 +443,7 @@ class QuestionCRUD(BaseCRUD): SELECT q.id, q.from_user_id, q.to_user_id, q.message_text, q.answer_text, q.is_anonymous, q.message_id, q.created_at, q.answered_at, - q.is_read, q.status + q.is_read, q.status, q.user_question_number FROM questions q WHERE q.to_user_id = ? """ @@ -418,7 +453,7 @@ class QuestionCRUD(BaseCRUD): query += " AND q.status = ?" params.append(status.value) - query += " ORDER BY q.created_at DESC LIMIT ? OFFSET ?" + query += " ORDER BY q.user_question_number DESC LIMIT ? OFFSET ?" params.extend([limit, offset]) async with conn.execute(query, params) as cursor: @@ -455,7 +490,7 @@ class QuestionCRUD(BaseCRUD): query += " AND q.status = ?" params.append(status.value) - query += " ORDER BY q.created_at DESC LIMIT ? OFFSET ?" + query += " ORDER BY q.user_question_number DESC LIMIT ? OFFSET ?" params.extend([limit, offset]) async with conn.execute(query, params) as cursor: @@ -467,28 +502,28 @@ class QuestionCRUD(BaseCRUD): if question is None: print(f"Предупреждение: вопрос не создан для строки {row[:11]}") continue + + author = None + if row[11]: # Если есть author_id + author = User( + id=row[11], + telegram_id=row[12], + username=row[13], + first_name=row[14] or "", + last_name=row[15], + chat_id=row[16], + profile_link=row[17] or "", + is_active=bool(row[18]), + is_superuser=bool(row[19]), + created_at=self._parse_datetime(row[20]), + updated_at=self._parse_datetime(row[21]), + banned_until=self._parse_datetime(row[22]), + ban_reason=row[23] + ) + result.append((question, author)) except Exception as e: print(f"Ошибка при создании вопроса из строки {row[:11]}: {e}") continue - - author = None - if row[11]: # Если есть author_id - author = User( - id=row[11], - telegram_id=row[12], - username=row[13], - first_name=row[14] or "", - last_name=row[15], - chat_id=row[16], - profile_link=row[17] or "", - is_active=bool(row[18]), - is_superuser=bool(row[19]), - created_at=self._parse_datetime(row[20]), - updated_at=self._parse_datetime(row[21]), - banned_until=self._parse_datetime(row[22]), - ban_reason=row[23] - ) - result.append((question, author)) return result async def get_by_to_user_cursor( @@ -506,7 +541,7 @@ class QuestionCRUD(BaseCRUD): SELECT q.id, q.from_user_id, q.to_user_id, q.message_text, q.answer_text, q.is_anonymous, q.message_id, q.created_at, q.answered_at, - q.is_read, q.status + q.is_read, q.status, q.user_question_number FROM questions q WHERE q.to_user_id = ? AND (q.created_at < ? OR (q.created_at = ? AND q.id < ?)) @@ -519,7 +554,7 @@ class QuestionCRUD(BaseCRUD): SELECT q.id, q.from_user_id, q.to_user_id, q.message_text, q.answer_text, q.is_anonymous, q.message_id, q.created_at, q.answered_at, - q.is_read, q.status + q.is_read, q.status, q.user_question_number FROM questions q WHERE q.to_user_id = ? AND (q.created_at > ? OR (q.created_at = ? AND q.id > ?)) @@ -555,7 +590,7 @@ class QuestionCRUD(BaseCRUD): query += " AND q.status = ?" params.append(status.value) - query += " ORDER BY q.created_at ASC LIMIT ? OFFSET ?" + query += " ORDER BY q.user_question_number ASC LIMIT ? OFFSET ?" params.extend([limit, offset]) async with conn.execute(query, params) as cursor: @@ -566,27 +601,105 @@ class QuestionCRUD(BaseCRUD): """Обновление вопроса""" logger.info(f"📝 Обновление вопроса {question.id} (статус: {question.status.value})") async with self.get_connection() as conn: - await conn.execute(""" - UPDATE questions SET - answer_text = ?, status = ?, answered_at = ?, is_read = ? - WHERE id = ? - """, ( - question.answer_text, question.status.value, - question.answered_at.isoformat() if question.answered_at else None, - question.is_read, question.id - )) + # Если вопрос помечается как удаленный, нужно пересчитать номера + if question.status.value == 'deleted': + # Получаем текущий статус вопроса + cursor = await conn.execute(""" + SELECT status, to_user_id, user_question_number FROM questions WHERE id = ? + """, (question.id,)) + old_info = await cursor.fetchone() + + if old_info and old_info[0] != 'deleted': + # Вопрос переходит в статус 'deleted', пересчитываем номера + to_user_id, deleted_number = old_info[1], old_info[2] + + # Сначала обновляем вопрос, устанавливая user_question_number в NULL + await conn.execute(""" + UPDATE questions SET + answer_text = ?, status = ?, answered_at = ?, is_read = ?, + user_question_number = NULL + WHERE id = ? + """, ( + question.answer_text, question.status.value, + question.answered_at.isoformat() if question.answered_at else None, + question.is_read, question.id + )) + + # Обновляем объект question, устанавливая user_question_number в None + question.user_question_number = None + + # Пересчитываем номера для всех вопросов пользователя после удаленного + await conn.execute(""" + UPDATE questions + SET user_question_number = user_question_number - 1 + WHERE to_user_id = ? + AND user_question_number > ? + AND status != 'deleted' + AND id != ? + """, (to_user_id, deleted_number, question.id)) + + logger.info(f"🗑️ Вопрос {question.id} помечен как удаленный, пересчитаны номера для пользователя {to_user_id}") + else: + # Обычное обновление + await conn.execute(""" + UPDATE questions SET + answer_text = ?, status = ?, answered_at = ?, is_read = ? + WHERE id = ? + """, ( + question.answer_text, question.status.value, + question.answered_at.isoformat() if question.answered_at else None, + question.is_read, question.id + )) + else: + # Обычное обновление + await conn.execute(""" + UPDATE questions SET + answer_text = ?, status = ?, answered_at = ?, is_read = ? + WHERE id = ? + """, ( + question.answer_text, question.status.value, + question.answered_at.isoformat() if question.answered_at else None, + question.is_read, question.id + )) + await conn.commit() logger.info(f"✅ Вопрос {question.id} обновлен") return question async def delete(self, question_id: int) -> bool: - """Удаление вопроса""" + """Удаление вопроса с пересчетом user_question_number""" async with self.get_connection() as conn: + # Сначала получаем информацию о вопросе + cursor = await conn.execute(""" + SELECT to_user_id, user_question_number FROM questions WHERE id = ? + """, (question_id,)) + question_info = await cursor.fetchone() + + if not question_info: + return False + + to_user_id, deleted_number = question_info + + # Удаляем вопрос cursor = await conn.execute(""" DELETE FROM questions WHERE id = ? """, (question_id,)) + + if cursor.rowcount == 0: + return False + + # Пересчитываем номера для всех вопросов пользователя после удаленного + await conn.execute(""" + UPDATE questions + SET user_question_number = user_question_number - 1 + WHERE to_user_id = ? + AND user_question_number > ? + AND status != 'deleted' + """, (to_user_id, deleted_number)) + await conn.commit() - return cursor.rowcount > 0 + logger.info(f"🗑️ Вопрос {question_id} удален, пересчитаны номера для пользователя {to_user_id}") + return True async def get_unread_count(self, to_user_id: int) -> int: """Получение количества непрочитанных вопросов""" @@ -641,7 +754,7 @@ class QuestionCRUD(BaseCRUD): """Преобразование строки БД в объект Question""" # Проверяем, что все необходимые поля присутствуют if len(row) < 11: - raise ValueError(f"Недостаточно данных в строке БД: {len(row)} колонок, ожидается 11") + raise ValueError(f"Недостаточно данных в строке БД: {len(row)} колонок, ожидается минимум 11") # Проверяем статус status_value = row[10] @@ -665,7 +778,8 @@ class QuestionCRUD(BaseCRUD): created_at=self._parse_datetime(row[7]), answered_at=self._parse_datetime(row[8]), is_read=bool(row[9]), - status=status + status=status, + user_question_number=row[11] if len(row) > 11 else None ) return question diff --git a/database/schema.sql b/database/schema.sql index 21f0373..e79d744 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -31,6 +31,7 @@ CREATE TABLE questions ( answered_at DATETIME, is_read BOOLEAN DEFAULT FALSE, status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'answered', 'rejected', 'deleted')), + user_question_number INTEGER, -- Внешние ключи FOREIGN KEY (from_user_id) REFERENCES users(telegram_id) ON DELETE CASCADE, @@ -76,6 +77,8 @@ CREATE INDEX idx_questions_from_user_id ON questions(from_user_id); CREATE INDEX idx_questions_status ON questions(status); CREATE INDEX idx_questions_created_at ON questions(created_at); CREATE INDEX idx_questions_is_read ON questions(is_read); +CREATE INDEX idx_questions_user_question_number ON questions(to_user_id, user_question_number); +CREATE UNIQUE INDEX idx_questions_user_number_unique ON questions(to_user_id, user_question_number) WHERE status != 'deleted'; CREATE INDEX idx_user_blocks_blocker_id ON user_blocks(blocker_id); CREATE INDEX idx_user_blocks_blocked_id ON user_blocks(blocked_id); @@ -106,3 +109,39 @@ FOR EACH ROW BEGIN INSERT OR IGNORE INTO user_settings (user_id) VALUES (NEW.telegram_id); END; + +-- Триггер для автоматического вычисления user_question_number при вставке +CREATE TRIGGER calculate_user_question_number +AFTER INSERT ON questions +FOR EACH ROW +WHEN NEW.user_question_number IS NULL +BEGIN + UPDATE questions + SET user_question_number = ( + SELECT COALESCE(MAX(user_question_number), 0) + 1 + FROM questions q2 + WHERE q2.to_user_id = NEW.to_user_id + AND q2.status != 'deleted' + ) + WHERE id = NEW.id; +END; + +-- Триггер для пересчета номеров при удалении вопроса +CREATE TRIGGER recalculate_user_question_numbers_on_delete +AFTER UPDATE ON questions +FOR EACH ROW +WHEN NEW.status = 'deleted' AND OLD.status != 'deleted' +BEGIN + -- Устанавливаем user_question_number в NULL для удаленного вопроса + UPDATE questions + SET user_question_number = NULL + WHERE id = NEW.id; + + -- Пересчитываем номера для всех вопросов пользователя после удаленного + UPDATE questions + SET user_question_number = user_question_number - 1 + WHERE to_user_id = NEW.to_user_id + AND user_question_number > OLD.user_question_number + AND status != 'deleted' + AND id != NEW.id; +END; diff --git a/dependencies.py b/dependencies.py new file mode 100644 index 0000000..185cf63 --- /dev/null +++ b/dependencies.py @@ -0,0 +1,387 @@ +""" +Система инъекции зависимостей для AnonBot с использованием MagicData +""" +from typing import Any, Dict, Optional + +from aiogram import BaseMiddleware +from aiogram.fsm.context import FSMContext +from aiogram.fsm.storage.base import BaseStorage +from aiogram.types import TelegramObject + +from config import config +from services.auth.auth_new import AuthService +from services.infrastructure.database import DatabaseService +from services.infrastructure.logger import get_logger +from services.business.message_service import MessageService +from services.business.pagination_service import PaginationService +from services.permissions import get_permission_checker, init_permission_checker +from services.permissions.init_permissions import init_all_permissions +from services.business.question_service import QuestionService +from services.rate_limiting.rate_limit_service import RateLimitService +from services.business.user_service import UserService +from services.utils import UtilsService +from services.validation import InputValidator + +logger = get_logger(__name__) + + +class Dependencies: + """Контейнер зависимостей для инъекции в обработчики""" + + def __init__(self): + self._database: Optional[DatabaseService] = None + self._auth: Optional[AuthService] = None + self._utils: Optional[UtilsService] = None + self._user_service: Optional[UserService] = None + self._question_service: Optional[QuestionService] = None + self._message_service: Optional[MessageService] = None + self._pagination_service: Optional[PaginationService] = None + self._rate_limit_service: Optional[RateLimitService] = None + self._validator: Optional[InputValidator] = None + self._config = config + + @property + def database(self) -> DatabaseService: + """Получение экземпляра DatabaseService""" + if self._database is None: + self._database = DatabaseService(config.DATABASE_PATH) + return self._database + + @property + def auth(self) -> AuthService: + """Получение экземпляра AuthService (с системой разрешений)""" + if self._auth is None: + self._auth = AuthService(self.database, config) + return self._auth + + @property + def utils(self) -> UtilsService: + """Получение экземпляра UtilsService""" + if self._utils is None: + self._utils = UtilsService(self.database) + return self._utils + + @property + def user_service(self) -> UserService: + """Получение экземпляра UserService""" + if self._user_service is None: + self._user_service = UserService(self.database, self.utils) + return self._user_service + + @property + def question_service(self) -> QuestionService: + """Получение экземпляра QuestionService""" + if self._question_service is None: + self._question_service = QuestionService(self.database, self.utils) + return self._question_service + + @property + def message_service(self) -> MessageService: + """Получение экземпляра MessageService""" + if self._message_service is None: + # Убеждаемся, что rate_limit_service создан первым + rate_limit_service = self.rate_limit_service + self._message_service = MessageService(rate_limit_service) + return self._message_service + + @property + def pagination_service(self) -> PaginationService: + """Получение экземпляра PaginationService""" + if self._pagination_service is None: + self._pagination_service = PaginationService() + return self._pagination_service + + @property + def rate_limit_service(self) -> RateLimitService: + """Получение экземпляра RateLimitService""" + if self._rate_limit_service is None: + self._rate_limit_service = RateLimitService() + return self._rate_limit_service + + @property + def validator(self) -> InputValidator: + """Получение экземпляра InputValidator""" + if self._validator is None: + self._validator = InputValidator() + return self._validator + + @property + def config(self): + """Получение конфигурации""" + return self._config + + async def init(self): + """Инициализация всех сервисов""" + try: + await self.database.init() + + # Инициализируем систему разрешений + init_all_permissions() + init_permission_checker(self.database, self.config) + + logger.info("✅ Все зависимости инициализированы") + except Exception as e: + logger.error(f"❌ Ошибка инициализации зависимостей: {e}") + raise + + async def close(self): + """Закрытие всех соединений""" + try: + if self._database: + await self._database.close() + logger.info("✅ Все зависимости закрыты") + except Exception as e: + logger.error(f"❌ Ошибка закрытия зависимостей: {e}") + + +class DependencyMiddleware(BaseMiddleware): + """Middleware для инъекции зависимостей в обработчики""" + + def __init__(self, dependencies: Dependencies): + super().__init__() + self.dependencies = dependencies + + async def __call__( + self, + handler, + event: TelegramObject, + data: Dict[str, Any] + ) -> Any: + """Инъекция зависимостей в контекст обработчика""" + # Добавляем зависимости в контекст + data['deps'] = self.dependencies + data['database'] = self.dependencies.database + data['auth'] = self.dependencies.auth + data['utils'] = self.dependencies.utils + data['user_service'] = self.dependencies.user_service + data['question_service'] = self.dependencies.question_service + data['message_service'] = self.dependencies.message_service + data['pagination_service'] = self.dependencies.pagination_service + data['rate_limit_service'] = self.dependencies.rate_limit_service + data['validator'] = self.dependencies.validator + data['config'] = self.dependencies.config + data['permission_checker'] = get_permission_checker() + + return await handler(event, data) + + +# Глобальный экземпляр зависимостей +dependencies = Dependencies() + + +def get_dependencies() -> Dependencies: + """Получение глобального экземпляра зависимостей""" + return dependencies + + +def get_database() -> DatabaseService: + """Получение экземпляра DatabaseService (для обратной совместимости)""" + return dependencies.database + + +def get_auth() -> AuthService: + """Получение экземпляра AuthService (с системой разрешений)""" + return dependencies.auth + + +def get_utils() -> UtilsService: + """Получение экземпляра UtilsService""" + return dependencies.utils + + +# Декораторы для удобства использования +def inject_database(func): + """Декоратор для инъекции DatabaseService""" + async def wrapper(*args, **kwargs): + if 'database' not in kwargs: + kwargs['database'] = get_database() + return await func(*args, **kwargs) + return wrapper + + +def inject_auth(func): + """Декоратор для инъекции AuthService""" + async def wrapper(*args, **kwargs): + if 'auth' not in kwargs: + kwargs['auth'] = get_auth() + return await func(*args, **kwargs) + return wrapper + + +def inject_utils(func): + """Декоратор для инъекции UtilsService""" + async def wrapper(*args, **kwargs): + if 'utils' not in kwargs: + kwargs['utils'] = get_utils() + return await func(*args, **kwargs) + return wrapper + + +def get_user_service() -> UserService: + """Получение экземпляра UserService""" + return dependencies.user_service + + +def get_question_service() -> QuestionService: + """Получение экземпляра QuestionService""" + return dependencies.question_service + + +def get_message_service() -> MessageService: + """Получение экземпляра MessageService""" + return dependencies.message_service + + +def get_pagination_service() -> PaginationService: + """Получение экземпляра PaginationService""" + return dependencies.pagination_service + + +def get_rate_limit_service() -> RateLimitService: + """Получение экземпляра RateLimitService""" + return dependencies.rate_limit_service + + +def get_validator() -> InputValidator: + """Получение экземпляра InputValidator""" + return dependencies.validator + + +def inject_all(func): + """Декоратор для инъекции всех основных сервисов""" + async def wrapper(*args, **kwargs): + # Фильтруем kwargs, убираем лишние параметры + import inspect + sig = inspect.signature(func) + filtered_kwargs = {k: v for k, v in kwargs.items() if k in sig.parameters} + + # Добавляем только те сервисы, которые ожидает функция + if 'database' in sig.parameters and 'database' not in filtered_kwargs: + filtered_kwargs['database'] = get_database() + if 'auth' in sig.parameters and 'auth' not in filtered_kwargs: + filtered_kwargs['auth'] = get_auth() + if 'utils' in sig.parameters and 'utils' not in filtered_kwargs: + filtered_kwargs['utils'] = get_utils() + if 'user_service' in sig.parameters and 'user_service' not in filtered_kwargs: + filtered_kwargs['user_service'] = get_user_service() + if 'question_service' in sig.parameters and 'question_service' not in filtered_kwargs: + filtered_kwargs['question_service'] = get_question_service() + if 'message_service' in sig.parameters and 'message_service' not in filtered_kwargs: + filtered_kwargs['message_service'] = get_message_service() + if 'pagination_service' in sig.parameters and 'pagination_service' not in filtered_kwargs: + filtered_kwargs['pagination_service'] = get_pagination_service() + if 'rate_limit_service' in sig.parameters and 'rate_limit_service' not in filtered_kwargs: + filtered_kwargs['rate_limit_service'] = get_rate_limit_service() + if 'validator' in sig.parameters and 'validator' not in filtered_kwargs: + filtered_kwargs['validator'] = get_validator() + if 'config' in sig.parameters and 'config' not in filtered_kwargs: + filtered_kwargs['config'] = get_dependencies().config + return await func(*args, **filtered_kwargs) + return wrapper + + +def get_database_service() -> DatabaseService: + """Получить экземпляр DatabaseService""" + return get_dependencies().database + + +# Специализированные декораторы для конкретных случаев использования +def inject_question_services(func): + """Декоратор для инъекции сервисов, нужных для обработки вопросов""" + async def wrapper(*args, **kwargs): + if 'question_service' not in kwargs: + kwargs['question_service'] = get_question_service() + if 'user_service' not in kwargs: + kwargs['user_service'] = get_user_service() + if 'message_service' not in kwargs: + kwargs['message_service'] = get_message_service() + if 'validator' not in kwargs: + kwargs['validator'] = get_validator() + return await func(*args, **kwargs) + return wrapper + + +def inject_answer_services(func): + """Декоратор для инъекции сервисов, нужных для обработки ответов""" + async def wrapper(*args, **kwargs): + if 'validator' not in kwargs: + kwargs['validator'] = get_validator() + return await func(*args, **kwargs) + return wrapper + + +def inject_validator(func): + """Декоратор для инъекции только валидатора""" + async def wrapper(*args, **kwargs): + + if 'validator' not in kwargs: + kwargs['validator'] = get_validator() + return await func(*args, **kwargs) + return wrapper + + +def inject_message_services(func): + """Декоратор для инъекции сервисов, нужных для отправки сообщений""" + async def wrapper(*args, **kwargs): + + if 'message_service' not in kwargs: + kwargs['message_service'] = get_message_service() + if 'user_service' not in kwargs: + kwargs['user_service'] = get_user_service() + return await func(*args, **kwargs) + return wrapper + + +def inject_start_services(func): + """Декоратор для инъекции сервисов, нужных для команды /start""" + async def wrapper(*args, **kwargs): + + if 'user_service' not in kwargs: + kwargs['user_service'] = get_user_service() + if 'auth' not in kwargs: + kwargs['auth'] = get_auth() + if 'utils' not in kwargs: + kwargs['utils'] = get_utils() + if 'message_service' not in kwargs: + kwargs['message_service'] = get_message_service() + if 'validator' not in kwargs: + kwargs['validator'] = get_validator() + return await func(*args, **kwargs) + return wrapper + + +def inject_link_services(func): + """Декоратор для инъекции сервисов, нужных для кнопки 'Моя ссылка'""" + async def wrapper(*args, **kwargs): + + if 'user_service' not in kwargs: + kwargs['user_service'] = get_user_service() + if 'message_service' not in kwargs: + kwargs['message_service'] = get_message_service() + return await func(*args, **kwargs) + return wrapper + + +def inject_main_menu_services(func): + """Декоратор для инъекции сервисов, нужных для кнопки 'Главное меню'""" + async def wrapper(*args, **kwargs): + + if 'auth' not in kwargs: + kwargs['auth'] = get_auth() + if 'message_service' not in kwargs: + kwargs['message_service'] = get_message_service() + return await func(*args, **kwargs) + return wrapper + + +def inject_admin_services(func): + """Декоратор для инъекции сервисов, нужных для админ-панели""" + async def wrapper(*args, **kwargs): + if 'rate_limit_service' not in kwargs: + kwargs['rate_limit_service'] = get_rate_limit_service() + if 'message_service' not in kwargs: + kwargs['message_service'] = get_message_service() + if 'auth' not in kwargs: + kwargs['auth'] = get_auth() + return await func(*args, **kwargs) + return wrapper diff --git a/handlers/__init__.py b/handlers/__init__.py new file mode 100644 index 0000000..a3ef2d7 --- /dev/null +++ b/handlers/__init__.py @@ -0,0 +1,7 @@ +""" +Обработчики для бота анонимных вопросов +""" + +from . import start, questions, answers, admin, errors + +__all__ = ['start', 'questions', 'answers', 'admin', 'errors'] diff --git a/handlers/admin.py b/handlers/admin.py new file mode 100644 index 0000000..d74eae4 --- /dev/null +++ b/handlers/admin.py @@ -0,0 +1,660 @@ +""" +Обработчики для администраторов +""" +from datetime import datetime + +from aiogram import F, Router +from aiogram.filters import Command +from aiogram.types import CallbackQuery, Message + +from config import config +from config.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE, MIN_PAGE_NUMBER, PERCENTAGE_DECIMAL_PLACES, TIME_DECIMAL_PLACES +from keyboards.inline import ( + get_admin_keyboard, get_ban_confirm_keyboard, get_ban_duration_keyboard, + get_ban_user_keyboard, get_rate_limit_keyboard, get_stats_keyboard, + get_superuser_assignment_keyboard, get_superuser_confirm_keyboard, get_unban_keyboard +) +from services.auth.auth_new import AuthService +from services.infrastructure.database import DatabaseService +from services.infrastructure.logger import get_logger +from services.business.message_service import MessageService +from services.business.pagination_service import PaginationService +from services.permissions.decorators import require_admin_or_superuser, require_permission +from services.rate_limiting.rate_limit_service import RateLimitService +from services.business.user_service import UserService +from services.utils import format_stats +from dependencies import inject_admin_services + +logger = get_logger(__name__) +router = Router() + + +async def _format_users_list( + users: list, + page: int, + per_page: int, + pagination_service: PaginationService +) -> str: + """Форматирование списка пользователей для отображения""" + try: + # Рассчитываем пагинацию + page_users, total_users, current_page, total_pages = pagination_service.calculate_pagination( + users, page, per_page + ) + + # Формируем информацию о пагинации + start_idx = current_page * per_page + end_idx = min(start_idx + per_page, total_users) + pagination_info = pagination_service.format_pagination_info( + current_page, total_pages, start_idx, end_idx, total_users + ) + + # Формируем текст сообщения + users_text = f"🔍 Управление суперпользователями\n\n" + users_text += pagination_info + + # Добавляем информацию о пользователях + for i, user in enumerate(page_users, start_idx + 1): + status_emoji = "🔍" if user.is_superuser else "👤" + users_text += f"{i}. {status_emoji} {user.display_name}\n" + if user.username: + users_text += f" @{user.username}\n" + created_at_str = user.created_at.strftime('%d.%m.%Y') if user.created_at else 'Неизвестно' + users_text += f" 📅 {created_at_str}\n\n" + + users_text += "💡 Нажмите на пользователя для изменения его статуса." + + return users_text + + except Exception as e: + logger.error(f"Ошибка при форматировании списка пользователей: {e}") + return "❌ Ошибка при форматировании списка пользователей." + + +# Функция is_admin теперь импортируется из services.auth + + +@router.message(Command("stats")) +@require_permission("view_stats", "❌ У вас нет прав для выполнения этой команды.") +async def cmd_stats(message: Message, database: DatabaseService = None): + """Обработчик команды /stats""" + await show_stats(message, database) + + +@router.message(F.text == "👑 Админ панель") +@require_permission("admin_panel", "❌ У вас нет прав для доступа к админ панели.") +async def admin_panel_button(message: Message): + """Обработчик кнопки 'Админ панель'""" + admin_text = "👑 Админ панель\n\n" + admin_text += "Выберите действие:" + + await message.answer( + admin_text, + reply_markup=get_admin_keyboard(), + parse_mode="HTML" + ) + + +@router.message(F.text == "📊 Статистика") +@require_permission("view_stats", "❌ У вас нет прав для просмотра статистики.") +async def stats_button(message: Message, database: DatabaseService = None): + """Обработчик кнопки 'Статистика'""" + await show_stats(message, database) + + +async def show_stats(message: Message, database: DatabaseService = None): + """Показать статистику""" + try: + if not database: + from dependencies import get_database + database = get_database() + + # Получаем статистику + users_stats = await database.get_users_stats() + questions_stats = await database.get_questions_stats() + + # Объединяем статистику + all_stats = {**users_stats, **questions_stats} + + # Форматируем и отправляем + stats_text = format_stats(all_stats) + + await message.answer( + stats_text, + reply_markup=get_stats_keyboard(), + parse_mode="HTML" + ) + + + except Exception as e: + logger.error(f"Ошибка при получении статистики: {e}") + await message.answer( + "❌ Произошла ошибка при получении статистики. Попробуйте позже." + ) + + + + + + +@router.message(F.text == "📢 Рассылка") +@require_permission("broadcast", "❌ У вас нет прав для рассылки.") +async def broadcast_button(message: Message): + """Обработчик кнопки 'Рассылка'""" + await message.answer( + "📢 Рассылка\n\n" + "Функция рассылки будет добавлена в следующих версиях.\n\n" + "💡 Планируемые возможности:\n" + "• Рассылка сообщений всем пользователям\n" + "• Рассылка по группам пользователей\n" + "• Планирование рассылок\n" + "• Статистика доставки", + reply_markup=get_admin_keyboard(), + parse_mode="HTML" + ) + + +@router.message(F.text == "⚙️ Настройки") +@require_permission("admin_panel", "❌ У вас нет прав для изменения настроек.") +async def settings_button(message: Message): + """Обработчик кнопки 'Настройки'""" + settings_text = "⚙️ Настройки бота\n\n" + settings_text += f"🔧 Текущие настройки:\n" + settings_text += f"• Режим отладки: {'Включен' if config.DEBUG else 'Выключен'}\n" + settings_text += f"• Макс. длина вопроса: {config.MAX_QUESTION_LENGTH} символов\n" + settings_text += f"• Макс. длина ответа: {config.MAX_ANSWER_LENGTH} символов\n" + settings_text += f"• Путь к БД: {config.DATABASE_PATH}\n\n" + settings_text += f"👑 Администраторы:\n" + for admin_id in config.ADMINS: + settings_text += f"• {admin_id}\n" + + settings_text += "\n💡 Настройки изменяются в файле .env" + + await message.answer( + settings_text, + reply_markup=get_admin_keyboard(), + parse_mode="HTML" + ) + + +@router.callback_query(F.data == "admin_stats") +@require_permission("view_stats", "❌ У вас нет прав") +async def admin_stats_callback(callback: CallbackQuery, database: DatabaseService = None): + """Обработчик кнопки 'Статистика' в админ панели""" + await show_stats(callback.message, database) + await callback.answer() + + + + +@router.callback_query(F.data == "admin_broadcast") +@require_permission("broadcast", "❌ У вас нет прав") +async def admin_broadcast_callback(callback: CallbackQuery): + """Обработчик кнопки 'Рассылка' в админ панели""" + await callback.message.edit_text( + "📢 Рассылка\n\n" + "Функция рассылки будет добавлена в следующих версиях.\n\n" + "💡 Планируемые возможности:\n" + "• Рассылка сообщений всем пользователям\n" + "• Рассылка по группам пользователей\n" + "• Планирование рассылок\n" + "• Статистика доставки", + reply_markup=get_admin_keyboard(), + parse_mode="HTML" + ) + await callback.answer() + + +@router.callback_query(F.data == "stats_general") +@require_permission("view_stats", "❌ У вас нет прав") +async def stats_general_callback(callback: CallbackQuery, database: DatabaseService = None): + """Обработчик кнопки 'Общая статистика'""" + await show_stats(callback.message, database) + await callback.answer() + + + + +@router.callback_query(F.data == "back_to_admin") +@require_permission("admin_panel", "❌ У вас нет прав") +async def back_to_admin_callback(callback: CallbackQuery): + """Обработчик кнопки 'Назад' в админ панель""" + admin_text = "👑 Админ панель\n\n" + admin_text += "Выберите действие:" + + await callback.message.edit_text( + admin_text, + reply_markup=get_admin_keyboard(), + parse_mode="HTML" + ) + await callback.answer() + + +@router.callback_query(F.data == "admin_assign_superuser") +@require_permission("manage_users", "❌ У вас нет прав для управления пользователями.") +async def admin_assign_superuser_callback( + callback: CallbackQuery, + user_service: UserService, + message_service: MessageService +): + """Обработчик кнопки 'Назначить суперпользователя'""" + + try: + # Получаем пользователей с пагинацией (оптимизированный запрос) + users = await user_service.database.get_all_users(limit=MAX_PAGE_SIZE, offset=MIN_PAGE_NUMBER) + + if not users: + await message_service.edit_message( + callback, + "👥 Управление суперпользователями\n\n" + "❌ Пользователи не найдены.", + get_admin_keyboard() + ) + await message_service.send_callback_answer(callback) + return + + # Показываем первую страницу + await show_superuser_assignment_page( + callback, + users, + page=MIN_PAGE_NUMBER, + message_service=message_service + ) + await message_service.send_callback_answer(callback) + + except Exception as e: + logger.error(f"Ошибка при получении списка пользователей: {e}") + await message_service.edit_message( + callback, + "❌ Произошла ошибка при загрузке списка пользователей.", + get_admin_keyboard() + ) + await message_service.send_callback_answer(callback) + + +@router.callback_query(F.data.startswith("superuser_page_")) +@require_permission("manage_users", "❌ У вас нет прав для управления пользователями.") +async def superuser_page_callback(callback: CallbackQuery, database: DatabaseService = None): + """Обработчик пагинации списка пользователей для назначения суперпользователей""" + + try: + # Извлекаем номер страницы + page = int(callback.data.split("_")[-1]) + + if not database: + from dependencies import get_database + database = get_database() + + # Получаем всех пользователей + users = await database.get_all_users(limit=MAX_PAGE_SIZE, offset=MIN_PAGE_NUMBER) + + if not users: + await callback.answer("❌ Пользователи не найдены", show_alert=True) + return + + # Показываем нужную страницу + await show_superuser_assignment_page(callback, users, page) + await callback.answer() + + except ValueError: + await callback.answer("❌ Неверный номер страницы", show_alert=True) + except Exception as e: + logger.error(f"Ошибка при пагинации пользователей: {e}") + await callback.answer("❌ Ошибка при загрузке страницы", show_alert=True) + + +@router.callback_query(F.data.startswith("assign_superuser_")) +@require_permission("manage_users", "❌ У вас нет прав для управления пользователями.") +async def assign_superuser_callback(callback: CallbackQuery, database: DatabaseService = None, validator = None): + """Обработчик выбора пользователя для назначения суперпользователем""" + + try: + # Извлекаем ID пользователя + user_id_str = callback.data.split("_")[-1] + + # Валидируем callback data + if validator: + validation_result = validator.validate_callback_data(callback.data) + if not validation_result: + logger.warning(f"⚠️ Невалидный callback data от пользователя {callback.from_user.id}: {callback.data}") + await callback.answer("❌ Неверные данные", show_alert=True) + return + + # Валидируем user_id + try: + user_id = int(user_id_str) + if validator: + user_id_validation = validator.validate_telegram_id(user_id) + if not user_id_validation: + logger.warning(f"⚠️ Невалидный user_id в callback: {user_id}") + await callback.answer("❌ Неверный ID пользователя", show_alert=True) + return + except ValueError: + logger.warning(f"⚠️ Неверный формат user_id в callback: {user_id_str}") + await callback.answer("❌ Неверный формат ID", show_alert=True) + return + + if not database: + from dependencies import get_database + database = get_database() + + # Получаем информацию о пользователе + user = await database.get_user(user_id) + if not user: + await callback.answer("❌ Пользователь не найден", show_alert=True) + return + + # Формируем текст с информацией о пользователе + user_text = f"👤 Информация о пользователе\n\n" + user_text += f"🆔 ID: {user.telegram_id}\n" + user_text += f"👤 Имя: {user.display_name}\n" + user_text += f"📝 Полное имя: {user.full_name}\n" + user_text += f"🔗 Ссылка: {user.profile_link}\n" + created_at_str = user.created_at.strftime('%d.%m.%Y %H:%M') if user.created_at else 'Неизвестно' + user_text += f"📅 Регистрация: {created_at_str}\n" + user_text += f"✅ Активен: {'Да' if user.is_active else 'Нет'}\n" + user_text += f"🔍 Суперпользователь: {'Да' if user.is_superuser else 'Нет'}\n\n" + + if user.is_superuser: + user_text += "❓ Хотите снять права суперпользователя?" + else: + user_text += "❓ Хотите назначить суперпользователем?" + + # Показываем информацию и кнопки подтверждения + await callback.message.edit_text( + user_text, + reply_markup=get_superuser_confirm_keyboard(user_id, user.is_superuser), + parse_mode="HTML" + ) + await callback.answer() + + except ValueError: + await callback.answer("❌ Неверный ID пользователя", show_alert=True) + except Exception as e: + logger.error(f"Ошибка при получении информации о пользователе: {e}") + await callback.answer("❌ Ошибка при загрузке информации", show_alert=True) + + +@router.callback_query(F.data.startswith("confirm_superuser_")) +@require_permission("manage_users", "❌ У вас нет прав для управления пользователями.") +async def confirm_superuser_callback(callback: CallbackQuery, database: DatabaseService = None, validator = None): + """Обработчик подтверждения назначения суперпользователем""" + + try: + # Извлекаем ID пользователя + user_id_str = callback.data.split("_")[-1] + + # Валидируем callback data + if validator: + validation_result = validator.validate_callback_data(callback.data) + if not validation_result: + logger.warning(f"⚠️ Невалидный callback data от пользователя {callback.from_user.id}: {callback.data}") + await callback.answer("❌ Неверные данные", show_alert=True) + return + + # Валидируем user_id + try: + user_id = int(user_id_str) + if validator: + user_id_validation = validator.validate_telegram_id(user_id) + if not user_id_validation: + logger.warning(f"⚠️ Невалидный user_id в callback: {user_id}") + await callback.answer("❌ Неверный ID пользователя", show_alert=True) + return + except ValueError: + logger.warning(f"⚠️ Неверный формат user_id в callback: {user_id_str}") + await callback.answer("❌ Неверный формат ID", show_alert=True) + return + + if not database: + from dependencies import get_database + database = get_database() + + # Получаем пользователя + user = await database.get_user(user_id) + if not user: + await callback.answer("❌ Пользователь не найден", show_alert=True) + return + + # Назначаем суперпользователем + user.is_superuser = True + await database.update_user(user) + + await callback.message.edit_text( + f"✅ Права назначены!\n\n" + f"👤 Пользователь {user.display_name} теперь является суперпользователем.\n\n" + f"🔍 Суперпользователи могут видеть информацию об авторах вопросов.", + reply_markup=get_superuser_confirm_keyboard(user_id, True), + parse_mode="HTML" + ) + await callback.answer("✅ Права суперпользователя назначены!") + + except ValueError: + await callback.answer("❌ Неверный ID пользователя", show_alert=True) + except Exception as e: + logger.error(f"Ошибка при назначении суперпользователя: {e}") + await callback.answer("❌ Ошибка при назначении прав", show_alert=True) + + +@router.callback_query(F.data.startswith("remove_superuser_")) +@require_permission("manage_users", "❌ У вас нет прав для управления пользователями.") +async def remove_superuser_callback(callback: CallbackQuery, database: DatabaseService = None, validator = None): + """Обработчик снятия прав суперпользователя""" + + try: + # Извлекаем ID пользователя + user_id_str = callback.data.split("_")[-1] + + # Валидируем callback data + if validator: + validation_result = validator.validate_callback_data(callback.data) + if not validation_result: + logger.warning(f"⚠️ Невалидный callback data от пользователя {callback.from_user.id}: {callback.data}") + await callback.answer("❌ Неверные данные", show_alert=True) + return + + # Валидируем user_id + try: + user_id = int(user_id_str) + if validator: + user_id_validation = validator.validate_telegram_id(user_id) + if not user_id_validation: + logger.warning(f"⚠️ Невалидный user_id в callback: {user_id}") + await callback.answer("❌ Неверный ID пользователя", show_alert=True) + return + except ValueError: + logger.warning(f"⚠️ Неверный формат user_id в callback: {user_id_str}") + await callback.answer("❌ Неверный формат ID", show_alert=True) + return + + if not database: + from dependencies import get_database + database = get_database() + + # Получаем пользователя + user = await database.get_user(user_id) + if not user: + await callback.answer("❌ Пользователь не найден", show_alert=True) + return + + # Снимаем права суперпользователя + user.is_superuser = False + await database.update_user(user) + + await callback.message.edit_text( + f"❌ Права сняты!\n\n" + f"👤 Пользователь {user.display_name} больше не является суперпользователем.\n\n" + f"👤 Теперь он видит анонимные вопросы без информации об авторах.", + reply_markup=get_superuser_confirm_keyboard(user_id, False), + parse_mode="HTML" + ) + await callback.answer("❌ Права суперпользователя сняты!") + + except ValueError: + await callback.answer("❌ Неверный ID пользователя", show_alert=True) + except Exception as e: + logger.error(f"Ошибка при снятии прав суперпользователя: {e}") + await callback.answer("❌ Ошибка при снятии прав", show_alert=True) + + +async def show_superuser_assignment_page( + callback: CallbackQuery, + users: list, + page: int = MIN_PAGE_NUMBER, + per_page: int = DEFAULT_PAGE_SIZE, + pagination_service: PaginationService = None, + message_service: MessageService = None +): + """Показать страницу с пользователями для назначения суперпользователей""" + try: + # Используем сервисы если они переданы, иначе создаем временные + if not pagination_service: + from dependencies import get_pagination_service + pagination_service = get_pagination_service() + + if not message_service: + from dependencies import get_message_service + message_service = get_message_service() + + # Формируем текст сообщения + users_text = await _format_users_list(users, page, per_page, pagination_service) + + # Создаем клавиатуру + keyboard = get_superuser_assignment_keyboard(users, page, per_page) + + # Отправляем или редактируем сообщение + await message_service.edit_message(callback, users_text, keyboard) + + except Exception as e: + logger.error(f"Ошибка при отображении страницы пользователей: {e}") + await message_service.edit_message( + callback, + "❌ Произошла ошибка при отображении списка пользователей.", + get_admin_keyboard() + ) + + +# =========================================== +# Rate Limiting callback обработчики +# =========================================== + +@router.callback_query(F.data == "admin_rate_limit") +async def admin_rate_limit_menu( + callback: CallbackQuery, + message_service: MessageService, + auth: AuthService +): + """Показать меню rate limiting""" + try: + if not auth.is_admin(callback.from_user.id): + await callback.answer("❌ Доступ запрещен. Только для администраторов.", show_alert=True) + return + + text = "🚦 Управление Rate Limiting\n\n" + text += "Выберите действие для управления системой ограничения скорости отправки сообщений:" + + await message_service.edit_message(callback, text, get_rate_limit_keyboard()) + + except Exception as e: + logger.error(f"Ошибка при отображении меню rate limiting: {e}") + await callback.answer("❌ Произошла ошибка при отображении меню.", show_alert=True) + + +@router.callback_query(F.data == "rate_limit_stats") +@inject_admin_services +async def rate_limit_stats_callback( + callback: CallbackQuery, + rate_limit_service: RateLimitService, + message_service: MessageService, + auth: AuthService, + **kwargs +): + """Показать статистику rate limiting""" + try: + if not auth.is_admin(callback.from_user.id): + await callback.answer("❌ Доступ запрещен. Только для администраторов.", show_alert=True) + return + + stats = rate_limit_service.get_stats() + + stats_text = "📊 Статистика Rate Limiting\n\n" + stats_text += "🔢 Общая статистика:\n" + stats_text += f"• Всего запросов: {stats.get('total_requests', 0)}\n" + stats_text += f"• Успешных запросов: {stats.get('successful_requests', 0)}\n" + stats_text += f"• Неудачных запросов: {stats.get('failed_requests', 0)}\n" + stats_text += f"• Процент успеха: {stats.get('success_rate', 0.0):.{PERCENTAGE_DECIMAL_PLACES}%}\n" + stats_text += f"• Процент ошибок: {stats.get('error_rate', 0.0):.{PERCENTAGE_DECIMAL_PLACES}%}\n" + stats_text += f"• Среднее время ожидания: {stats.get('average_wait_time', 0.0):.{TIME_DECIMAL_PLACES}f}с\n\n" + + stats_text += "🔍 Детальная статистика:\n" + stats_text += f"• RetryAfter ошибок: {stats.get('retry_after_errors', 0)}\n" + stats_text += f"• Других ошибок: {stats.get('other_errors', 0)}\n" + stats_text += f"• Процент RetryAfter: {stats.get('retry_after_rate', 0.0):.{PERCENTAGE_DECIMAL_PLACES}%}\n" + stats_text += f"• Процент других ошибок: {stats.get('other_error_rate', 0.0):.{PERCENTAGE_DECIMAL_PLACES}%}\n" + + await message_service.edit_message(callback, stats_text, get_rate_limit_keyboard()) + + except Exception as e: + logger.error(f"Ошибка при получении статистики rate limiting: {e}") + await callback.answer("❌ Произошла ошибка при получении статистики.", show_alert=True) + + +@router.callback_query(F.data == "rate_limit_reset_stats") +@inject_admin_services +async def reset_rate_limit_stats_callback( + callback: CallbackQuery, + rate_limit_service: RateLimitService, + message_service: MessageService, + auth: AuthService, + **kwargs +): + """Сбросить статистику rate limiting""" + try: + if not auth.is_admin(callback.from_user.id): + await callback.answer("❌ Доступ запрещен. Только для администраторов.", show_alert=True) + return + + rate_limit_service.reset_stats() + await callback.answer("✅ Статистика rate limiting сброшена.", show_alert=True) + + # Обновляем сообщение + text = "🚦 Управление Rate Limiting\n\n" + text += "Выберите действие для управления системой ограничения скорости отправки сообщений:" + await message_service.edit_message(callback, text, get_rate_limit_keyboard()) + + except Exception as e: + logger.error(f"Ошибка при сбросе статистики rate limiting: {e}") + await callback.answer("❌ Произошла ошибка при сбросе статистики.", show_alert=True) + + +@router.callback_query(F.data == "rate_limit_adapt") +@inject_admin_services +async def adapt_rate_limit_config_callback( + callback: CallbackQuery, + rate_limit_service: RateLimitService, + message_service: MessageService, + auth: AuthService, + **kwargs +): + """Адаптировать конфигурацию rate limiting""" + try: + if not auth.is_admin(callback.from_user.id): + await callback.answer("❌ Доступ запрещен. Только для администраторов.", show_alert=True) + return + + if rate_limit_service.should_adapt_config(): + await rate_limit_service.adapt_config_if_needed() + await callback.answer("✅ Конфигурация rate limiting адаптирована на основе текущей производительности.", show_alert=True) + else: + await callback.answer("ℹ️ Адаптация конфигурации не требуется. Недостаточно данных или производительность в норме.", show_alert=True) + + # Обновляем сообщение + text = "🚦 Управление Rate Limiting\n\n" + text += "Выберите действие для управления системой ограничения скорости отправки сообщений:" + await message_service.edit_message(callback, text, get_rate_limit_keyboard()) + + except Exception as e: + logger.error(f"Ошибка при адаптации конфигурации rate limiting: {e}") + await callback.answer("❌ Произошла ошибка при адаптации конфигурации.", show_alert=True) + + diff --git a/handlers/answers.py b/handlers/answers.py new file mode 100644 index 0000000..4a70e07 --- /dev/null +++ b/handlers/answers.py @@ -0,0 +1,478 @@ +""" +Обработчики для работы с ответами на вопросы +""" +from datetime import datetime +from aiogram import Router, F +from aiogram.types import Message, CallbackQuery +from aiogram.filters import StateFilter +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup + +from config import config +from models.question import Question, QuestionStatus +from services.infrastructure.database import DatabaseService +from services.auth.auth_new import AuthService +from services.utils import is_valid_answer_text, format_question_info, send_answer_to_author, escape_html +from services.infrastructure.logger import get_logger +from services.infrastructure.metrics import get_metrics_service, track_answer_processing +from dependencies import inject_answer_services, inject_main_menu_services +from keyboards.inline import get_question_view_keyboard +from keyboards.reply import get_main_keyboard_for_user, get_cancel_keyboard + +logger = get_logger(__name__) +router = Router() + + +class AnswerStates(StatesGroup): + """Состояния для работы с ответами""" + waiting_for_answer = State() + editing_answer = State() + confirming_delete = State() + + +@router.callback_query(F.data.startswith("view_question_")) +async def view_question_callback(callback: CallbackQuery): + """Обработчик просмотра конкретного вопроса""" + try: + question_id = int(callback.data.split("_")[2]) + + from dependencies import get_database + db = get_database() + + # Получаем вопрос + question = await db.get_question(question_id) + + if not question: + await callback.answer("❌ Вопрос не найден", show_alert=True) + return + + if question.to_user_id != callback.from_user.id: + await callback.answer("❌ У вас нет прав на этот вопрос", show_alert=True) + return + + # Формируем текст сообщения + question_text = format_question_info(question, show_answer=True) + + # Получаем клавиатуру + keyboard = get_question_view_keyboard(question) + + # Обновляем сообщение + await callback.message.edit_text( + question_text, + reply_markup=keyboard, + parse_mode="HTML" + ) + + await callback.answer() + + except Exception as e: + logger.error(f"Ошибка при просмотре вопроса: {e}") + await callback.answer("❌ Произошла ошибка", show_alert=True) + + +@router.callback_query(F.data.startswith("answer_")) +async def answer_callback(callback: CallbackQuery, state: FSMContext): + """Обработчик создания нового ответа""" + try: + logger.info(f"Получен callback для ответа: {callback.data}") + question_id = int(callback.data.split("_")[1]) + logger.info(f"Извлечен question_id: {question_id}") + + # Получаем базу данных + from dependencies import get_database + db = get_database() + + # Получаем вопрос + question = await db.get_question(question_id) + if not question: + await callback.answer("❌ Вопрос не найден", show_alert=True) + return + + # Проверяем права доступа + if question.to_user_id != callback.from_user.id: + await callback.answer("❌ У вас нет прав на этот вопрос", show_alert=True) + return + + # Проверяем, что вопрос еще не отвечен + if question.status == QuestionStatus.ANSWERED: + await callback.answer("❌ На этот вопрос уже отвечено", show_alert=True) + return + + # Устанавливаем состояние ожидания ответа + logger.info(f"Устанавливаем состояние waiting_for_answer для вопроса {question_id}") + await state.set_state(AnswerStates.waiting_for_answer) + await state.update_data(question_id=question_id) + logger.info(f"Состояние установлено, данные сохранены") + + # Отправляем сообщение с просьбой ввести ответ + logger.info(f"Отправляем сообщение с просьбой ввести ответ для вопроса {question_id}") + await callback.message.edit_text( + f"✏️ Ответ на вопрос #{question_id}\n\n" + f"❓ Вопрос:\n{escape_html(question.message_text)}\n\n" + f"💬 Введите ваш ответ:", + reply_markup=get_cancel_keyboard(), + parse_mode="HTML" + ) + + await callback.answer() + logger.info(f"Callback обработан успешно для вопроса {question_id}") + + except Exception as e: + logger.error(f"Ошибка при создании ответа: {e}") + await callback.answer("❌ Произошла ошибка", show_alert=True) + + +@router.callback_query(F.data.startswith("edit_answer_")) +async def edit_answer_callback(callback: CallbackQuery, state: FSMContext): + """Обработчик редактирования ответа""" + try: + question_id = int(callback.data.split("_")[2]) + + from dependencies import get_database + db = get_database() + + # Получаем вопрос + question = await db.get_question(question_id) + + if not question: + await callback.answer("❌ Вопрос не найден", show_alert=True) + return + + if question.to_user_id != callback.from_user.id: + await callback.answer("❌ У вас нет прав на этот вопрос", show_alert=True) + return + + if question.status != QuestionStatus.ANSWERED: + await callback.answer("❌ На этот вопрос еще не отвечено", show_alert=True) + return + + # Устанавливаем состояние редактирования ответа + await state.set_state(AnswerStates.editing_answer) + await state.update_data(question_id=question_id, current_answer=question.answer_text) + + # Отправляем сообщение с просьбой ввести новый ответ + await callback.message.edit_text( + f"✏️ Редактирование ответа на вопрос #{question_id}\n\n" + f"📝 Вопрос:\n{escape_html(question.message_text)}\n\n" + f"💬 Текущий ответ:\n{escape_html(question.answer_text)}\n\n" + f"✍️ Введите новый ответ:", + reply_markup=None, + parse_mode="HTML" + ) + + await callback.answer() + + except Exception as e: + logger.error(f"Ошибка при редактировании ответа: {e}") + await callback.answer("❌ Произошла ошибка", show_alert=True) + + +@router.message(StateFilter(AnswerStates.waiting_for_answer)) +@inject_answer_services +async def process_new_answer(message: Message, state: FSMContext, validator, **kwargs): + """Обработка нового ответа""" + try: + logger.info(f"Получено сообщение в состоянии waiting_for_answer: {message.text[:50]}...") + # Получаем данные из состояния + data = await state.get_data() + question_id = data.get('question_id') + logger.info(f"Получен question_id из состояния: {question_id}") + + if not question_id: + await message.answer( + "❌ Ошибка: не найден вопрос для ответа.", + reply_markup=get_main_keyboard_for_user(message.from_user.id) + ) + await state.clear() + return + + # Валидируем текст ответа + validation_result = validator.validate_answer_text( + message.text, + config.MAX_ANSWER_LENGTH + ) + + if not validation_result: + logger.warning(f"⚠️ Невалидный ответ от пользователя {message.from_user.id}: {validation_result.error_message}") + await message.answer( + f"❌ {validation_result.error_message}\n\n" + "Попробуйте отправить ответ еще раз:", + reply_markup=get_cancel_keyboard() + ) + return + + # Используем санитизированный текст + sanitized_answer_text = validation_result.sanitized_value + + # Сохраняем ответ в БД + from dependencies import get_database + db = get_database() + + question = await db.get_question(question_id) + if question: + question.answer_text = sanitized_answer_text + question.answered_at = datetime.now() + question.mark_as_answered() # Устанавливаем статус ANSWERED + await db.update_question(question) + + # Отправляем ответ автору вопроса + logger.info(f"Попытка отправить ответ автору вопроса {question.id}, from_user_id: {question.from_user_id}") + await send_answer_to_author(message.bot, question, question.answer_text) + + # Отправляем подтверждение + await message.answer( + "✅ Ответ отправлен!\n\n" + "💬 Ваш ответ был успешно отправлен автору вопроса.\n\n" + "📋 Используйте 'Мои вопросы' для просмотра всех вопросов и ответов.", + reply_markup=get_main_keyboard_for_user(message.from_user.id), + parse_mode="HTML" + ) + + await state.clear() + + except Exception as e: + logger.error(f"Ошибка при обработке нового ответа: {e}") + await message.answer( + "❌ Произошла ошибка при отправке ответа. Попробуйте позже.", + reply_markup=get_main_keyboard_for_user(message.from_user.id) + ) + await state.clear() + + +@router.message(StateFilter(AnswerStates.editing_answer)) +@inject_answer_services +async def process_edited_answer(message: Message, state: FSMContext, validator, **kwargs): + """Обработка отредактированного ответа""" + try: + # Получаем данные из состояния + data = await state.get_data() + question_id = data.get('question_id') + current_answer = data.get('current_answer') + + if not question_id: + await message.answer( + "❌ Ошибка: не найден вопрос для редактирования.", + reply_markup=get_main_keyboard_for_user(message.from_user.id) + ) + await state.clear() + return + + # Валидируем текст ответа + validation_result = validator.validate_answer_text( + message.text, + config.MAX_ANSWER_LENGTH + ) + + if not validation_result: + logger.warning(f"⚠️ Невалидный отредактированный ответ от пользователя {message.from_user.id}: {validation_result.error_message}") + await message.answer( + f"❌ {validation_result.error_message}\n\n" + "Попробуйте отправить ответ еще раз:", + reply_markup=get_cancel_keyboard() + ) + return + + # Используем санитизированный текст + sanitized_answer_text = validation_result.sanitized_value + + # Сохраняем отредактированный ответ + from dependencies import get_database + db = get_database() + + question = await db.get_question(question_id) + if question: + question.answer_text = sanitized_answer_text + question.answered_at = datetime.now() # Обновляем время ответа + await db.update_question(question) + + # Отправляем ответ автору вопроса + logger.info(f"Попытка отправить ответ автору вопроса {question.id}, from_user_id: {question.from_user_id}") + await send_answer_to_author(message.bot, question, question.answer_text) + + # Отправляем подтверждение + await message.answer( + "✅ Ответ обновлен!\n\n" + "💬 Ваш ответ был успешно обновлен.\n\n" + "📋 Используйте 'Мои вопросы' для просмотра всех вопросов и ответов.", + reply_markup=get_main_keyboard_for_user(message.from_user.id), + parse_mode="HTML" + ) + + await state.clear() + + except Exception as e: + logger.error(f"Ошибка при обработке отредактированного ответа: {e}") + await message.answer( + "❌ Произошла ошибка при обновлении ответа. Попробуйте позже.", + reply_markup=get_main_keyboard_for_user(message.from_user.id) + ) + await state.clear() + + +@router.callback_query(F.data.startswith("confirm_delete_")) +async def confirm_delete_callback(callback: CallbackQuery): + """Обработчик подтверждения удаления вопроса""" + try: + question_id = int(callback.data.split("_")[2]) + + from dependencies import get_database + db = get_database() + + # Получаем вопрос + question = await db.get_question(question_id) + + if not question: + await callback.answer("❌ Вопрос не найден", show_alert=True) + return + + if question.to_user_id != callback.from_user.id: + await callback.answer("❌ У вас нет прав на этот вопрос", show_alert=True) + return + + # Удаляем вопрос + question.mark_as_deleted() + await db.update_question(question) + + # Обновляем сообщение + await callback.message.edit_text( + f"🗑️ Вопрос #{question.user_question_number if question.user_question_number is not None else question_id} удален\n\n" + f"📅 Удален: {question.answered_at.strftime('%d.%m.%Y %H:%M')}", + reply_markup=None, + parse_mode="HTML" + ) + + await callback.answer("🗑️ Вопрос удален") + + except Exception as e: + logger.error(f"Ошибка при подтверждении удаления: {e}") + await callback.answer("❌ Произошла ошибка", show_alert=True) + + +@router.callback_query(F.data.startswith("cancel_delete_")) +async def cancel_delete_callback(callback: CallbackQuery): + """Обработчик отмены удаления вопроса""" + try: + question_id = int(callback.data.split("_")[2]) + + from dependencies import get_database + db = get_database() + + # Получаем вопрос + question = await db.get_question(question_id) + + if not question: + await callback.answer("❌ Вопрос не найден", show_alert=True) + return + + if question.to_user_id != callback.from_user.id: + await callback.answer("❌ У вас нет прав на этот вопрос", show_alert=True) + return + + # Возвращаемся к просмотру вопроса + question_text = format_question_info(question, show_answer=True) + keyboard = get_question_view_keyboard(question) + + await callback.message.edit_text( + question_text, + reply_markup=keyboard, + parse_mode="HTML" + ) + + await callback.answer("❌ Удаление отменено") + + except Exception as e: + logger.error(f"Ошибка при отмене удаления: {e}") + await callback.answer("❌ Произошла ошибка", show_alert=True) + + +@router.callback_query(F.data == "back_to_questions") +async def back_to_questions_callback(callback: CallbackQuery): + """Обработчик кнопки 'Назад к списку'""" + try: + from dependencies import get_database + db = get_database() + + # Получаем вопросы пользователя + questions = await db.get_user_questions(callback.from_user.id, limit=10) + + if not questions: + await callback.message.edit_text( + "📭 У вас пока нет вопросов.\n\n" + "🔗 Поделитесь своей ссылкой, чтобы получать анонимные вопросы!", + reply_markup=None + ) + else: + questions_text = f"📋 Ваши вопросы ({len(questions)}):\n\n" + + for i, question in enumerate(questions, 1): + status_emoji = { + 'pending': '⏳', + 'answered': '✅', + 'rejected': '❌', + 'deleted': '🗑️' + } + + emoji = status_emoji.get(question.status.value, '❓') + preview = question.get_question_preview(50) + + # Используем user_question_number для отображения, если он есть + display_number = question.user_question_number if question.user_question_number is not None else i + questions_text += f"{i}. {emoji} #{display_number}\n" + questions_text += f" {preview}\n" + questions_text += f" 📅 {question.created_at.strftime('%d.%m.%Y %H:%M')}\n\n" + + questions_text += "💡 Нажмите на номер вопроса для просмотра деталей." + + await callback.message.edit_text( + questions_text, + reply_markup=None, + parse_mode="HTML" + ) + + await callback.answer() + + except Exception as e: + logger.error(f"Ошибка при возврате к списку вопросов: {e}") + await callback.answer("❌ Произошла ошибка", show_alert=True) + + +@router.callback_query( + F.data == "back_to_main", +) +@inject_main_menu_services +async def back_to_main_callback( + callback: CallbackQuery, + auth: AuthService, + **kwargs + ): + """Обработчик кнопки 'Назад' в главное меню""" + try: + # Используем инжектированную систему авторизации + is_admin = auth.is_admin(callback.from_user.id) + + if is_admin: + from keyboards.inline import get_admin_keyboard + keyboard = get_admin_keyboard() + text = "🏠 Главное меню (Админ)\n\nВыберите действие:" + else: + keyboard = get_main_keyboard_for_user(callback.from_user.id) + text = "🏠 Главное меню\n\nВыберите действие:" + + await callback.message.edit_text( + text, + reply_markup=None, + parse_mode="HTML" + ) + + # Отправляем новое сообщение с клавиатурой + await callback.message.answer( + text, + reply_markup=keyboard, + parse_mode="HTML" + ) + + await callback.answer() + + except Exception as e: + logger.error(f"Ошибка при возврате в главное меню: {e}") + await callback.answer("❌ Произошла ошибка", show_alert=True) diff --git a/handlers/errors.py b/handlers/errors.py new file mode 100644 index 0000000..f5d776d --- /dev/null +++ b/handlers/errors.py @@ -0,0 +1,217 @@ +""" +Глобальная обработка ошибок +""" +import traceback +from aiogram import Router +from aiogram.types import ErrorEvent, Message, CallbackQuery +from aiogram.exceptions import TelegramBadRequest, TelegramNetworkError, TelegramRetryAfter + +from config import config +from services.infrastructure.logger import get_logger +from services.infrastructure.metrics import get_metrics_service +from keyboards.reply import get_main_keyboard_for_user + +logger = get_logger(__name__) +router = Router() + + +@router.error() +async def error_handler(event: ErrorEvent): + """Глобальный обработчик ошибок""" + error = event.exception + update = event.update + + # Записываем метрику ошибки + metrics_service = get_metrics_service() + metrics_service.increment_errors(type(error).__name__, "global_handler") + + # Логируем ошибку + logger.error(f"💥 Ошибка в обработчике: {error}") + logger.error(f"🔍 Тип ошибки: {type(error).__name__}") + logger.error(f"📋 Детали: {traceback.format_exc()}") + + # Определяем тип обновления + if update.message: + await handle_message_error(update.message, error) + elif update.callback_query: + await handle_callback_error(update.callback_query, error) + else: + logger.error(f"❓ Неизвестный тип обновления: {update}") + + +async def handle_message_error(message: Message, error: Exception): + """Обработка ошибок в сообщениях""" + try: + # Определяем тип ошибки и отправляем соответствующее сообщение + if isinstance(error, TelegramRetryAfter): + # Ошибка rate limiting + await message.answer( + f"⏳ Слишком много запросов. Попробуйте через {error.retry_after} секунд.", + reply_markup=get_main_keyboard_for_user(message.from_user.id) + ) + elif isinstance(error, TelegramBadRequest): + # Некорректный запрос + if "message is not modified" in str(error): + # Сообщение не изменилось - это не критическая ошибка + logger.warning("Сообщение не изменилось") + elif "chat not found" in str(error): + # Чат не найден + logger.warning(f"Чат не найден: {message.chat.id}") + else: + await message.answer( + "❌ Произошла ошибка при обработке запроса. Попробуйте позже.", + reply_markup=get_main_keyboard_for_user(message.from_user.id) + ) + elif isinstance(error, TelegramNetworkError): + # Сетевая ошибка + await message.answer( + "🌐 Проблемы с сетью. Проверьте подключение к интернету.", + reply_markup=get_main_keyboard_for_user(message.from_user.id) + ) + elif isinstance(error, ValueError): + # Ошибка валидации + await message.answer( + "❌ Некорректные данные. Проверьте введенную информацию.", + reply_markup=get_main_keyboard_for_user(message.from_user.id) + ) + elif isinstance(error, KeyError): + # Ошибка ключа (обычно в FSM) + await message.answer( + "❌ Ошибка состояния. Попробуйте начать заново с команды /start.", + reply_markup=get_main_keyboard_for_user(message.from_user.id) + ) + else: + # Неизвестная ошибка + if config.DEBUG: + # В режиме отладки показываем детали ошибки + error_text = f"🐛 Ошибка отладки:\n\n" + error_text += f"{type(error).__name__}: {str(error)}" + + await message.answer( + error_text, + reply_markup=get_main_keyboard_for_user(message.from_user.id), + parse_mode="HTML" + ) + else: + # В продакшене показываем общее сообщение + await message.answer( + "❌ Произошла неожиданная ошибка. Попробуйте позже или обратитесь к администратору.", + reply_markup=get_main_keyboard_for_user(message.from_user.id) + ) + + # Уведомляем администраторов о критических ошибках + if not isinstance(error, (TelegramRetryAfter, TelegramBadRequest)): + await notify_admins_about_error(error, message) + + except Exception as e: + # Если даже обработка ошибки не удалась + logger.critical(f"Критическая ошибка в обработчике ошибок: {e}") + try: + await message.answer( + "❌ Критическая ошибка. Бот временно недоступен.", + reply_markup=get_main_keyboard_for_user(message.from_user.id) + ) + except: + pass # Если даже это не удалось, ничего не делаем + + +async def handle_callback_error(callback: CallbackQuery, error: Exception): + """Обработка ошибок в callback запросах""" + try: + # Определяем тип ошибки и отправляем соответствующее сообщение + if isinstance(error, TelegramRetryAfter): + # Ошибка rate limiting + await callback.answer( + f"⏳ Слишком много запросов. Попробуйте через {error.retry_after} секунд.", + show_alert=True + ) + elif isinstance(error, TelegramBadRequest): + # Некорректный запрос + if "message is not modified" in str(error): + # Сообщение не изменилось - это не критическая ошибка + logger.warning("Сообщение не изменилось в callback") + elif "query is too old" in str(error): + # Запрос слишком старый + await callback.answer( + "⏰ Запрос устарел. Обновите страницу и попробуйте снова.", + show_alert=True + ) + else: + await callback.answer( + "❌ Произошла ошибка при обработке запроса.", + show_alert=True + ) + elif isinstance(error, TelegramNetworkError): + # Сетевая ошибка + await callback.answer( + "🌐 Проблемы с сетью. Проверьте подключение.", + show_alert=True + ) + else: + # Неизвестная ошибка + if config.DEBUG: + # В режиме отладки показываем детали ошибки + error_text = f"🐛 Ошибка отладки:\n\n" + error_text += f"{type(error).__name__}: {str(error)}" + + await callback.message.edit_text( + error_text, + parse_mode="HTML" + ) + else: + # В продакшене показываем общее сообщение + await callback.answer( + "❌ Произошла неожиданная ошибка.", + show_alert=True + ) + + # Уведомляем администраторов о критических ошибках + if not isinstance(error, (TelegramRetryAfter, TelegramBadRequest)): + await notify_admins_about_error(error, callback.message) + + except Exception as e: + # Если даже обработка ошибки не удалась + logger.critical(f"Критическая ошибка в обработчике callback ошибок: {e}") + try: + await callback.answer( + "❌ Критическая ошибка.", + show_alert=True + ) + except: + pass # Если даже это не удалось, ничего не делаем + + +async def notify_admins_about_error(error: Exception, message: Message): + """Уведомление администраторов об ошибке""" + if not config.ADMINS: + return + + try: + error_text = f"🚨 Ошибка в боте\n\n" + error_text += f"🐛 Тип: {type(error).__name__}\n" + error_text += f"📝 Сообщение: {str(error)}\n" + error_text += f"👤 Пользователь: {message.from_user.id}\n" + error_text += f"💬 Чат: {message.chat.id}\n" + error_text += f"📅 Время: {message.date.strftime('%d.%m.%Y %H:%M:%S')}\n\n" + + if config.DEBUG: + error_text += f"🔍 Трассировка:\n{traceback.format_exc()}" + + # Отправляем уведомление всем администраторам + from dependencies import get_message_service + message_service = get_message_service() + + for admin_id in config.ADMINS: + try: + await message_service.send_bot_message( + message.bot, + admin_id, + error_text + ) + except Exception as e: + logger.error(f"Не удалось отправить уведомление админу {admin_id}: {e}") + + except Exception as e: + logger.error(f"Ошибка при уведомлении администраторов: {e}") + + diff --git a/handlers/questions.py b/handlers/questions.py new file mode 100644 index 0000000..e480146 --- /dev/null +++ b/handlers/questions.py @@ -0,0 +1,1272 @@ +""" +Обработчики для работы с анонимными вопросами +""" +from datetime import datetime +from aiogram import Router, F +from aiogram.types import Message, CallbackQuery +from aiogram.filters import StateFilter +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup + +from config import config +from models.user import User +from models.question import Question, QuestionStatus +from services.infrastructure.database import DatabaseService +from services.business.question_service import QuestionService +from services.business.user_service import UserService +from services.business.message_service import MessageService +from services.business.pagination_service import PaginationService +from services.utils import is_valid_answer_text, send_answer_to_author +from services.utils import is_valid_question_text, format_question_info, escape_html +from services.infrastructure.logger import get_logger +from services.infrastructure.metrics import get_metrics_service, track_question_processing, track_answer_processing +from services.infrastructure.logging_decorators import log_function_call, log_business_event, log_fsm_transition, log_utility +from services.infrastructure.logging_utils import log_user_action, log_business_operation, log_fsm_event +from dependencies import inject_question_services, inject_answer_services +from keyboards.inline import get_answer_keyboard, get_question_view_keyboard, get_user_questions_keyboard +from keyboards.reply import get_main_keyboard_for_user, get_cancel_keyboard +from keyboards.inline import get_admin_keyboard + +logger = get_logger(__name__) +router = Router() + + +@log_function_call(log_params=True, log_result=False) +async def _format_questions_list( + questions_with_authors: list, + page: int, + per_page: int, + user_id: int, + question_service: QuestionService +) -> str: + """Форматирование списка вопросов для отображения (оптимизированная версия)""" + try: + # Проверяем, является ли пользователь суперпользователем + is_superuser = False + if user_id: + try: + from dependencies import get_auth + auth_service = get_auth() + if auth_service: + is_superuser = await auth_service.is_superuser(user_id) + except Exception as e: + logger.warning(f"Ошибка при проверке суперпользователя: {e}") + is_superuser = False + + # Формируем текст сообщения + questions_text = "" + + for i, (question, author_user) in enumerate(questions_with_authors, page * per_page + 1): + # Проверяем, что question не None + if not question: + logger.warning(f"Найден None question в списке на позиции {i}") + continue + + # Дополнительная проверка на наличие атрибута status + if not hasattr(question, 'status') or question.status is None: + logger.warning(f"Вопрос {question.id if hasattr(question, 'id') else 'unknown'} не имеет статуса") + continue + + status_emoji = { + 'pending': '⏳', + 'answered': '✅', + 'rejected': '❌', + 'deleted': '🗑️' + } + + emoji = status_emoji.get(question.status.value, '❓') + preview = question_service.get_question_preview(question, 40) + + # Используем user_question_number для отображения, если он есть + display_number = question.user_question_number if question.user_question_number is not None else i + questions_text += f"{i}. {emoji} #{display_number}" + + # Для суперпользователей добавляем информацию об авторе вопроса + if is_superuser and author_user: + try: + from services.utils import format_user_display_name + author_info = format_user_display_name(author_user) + questions_text += f" Вопрос от {author_info}" + except Exception as e: + logger.error(f"Ошибка при форматировании информации об авторе вопроса: {e}") + elif is_superuser and not author_user: + questions_text += " (автор неизвестен)" + + questions_text += "\n" + questions_text += f" {preview}\n" + questions_text += f" 📅 {question.created_at.strftime('%d.%m.%Y %H:%M')}\n\n" + + questions_text += "💡 Нажмите на номер вопроса для просмотра деталей." + + return questions_text + + except Exception as e: + logger.error(f"Ошибка при форматировании списка вопросов: {e}") + return "❌ Ошибка при форматировании списка вопросов." + + + +@router.message(F.text == "❌ Отмена") +@log_fsm_transition(to_state="cancelled") +@log_function_call(log_params=True) +async def cancel_action(message: Message, state: FSMContext): + """Обработчик кнопки 'Отмена'""" + await state.clear() + + # Используем новую систему авторизации + is_admin = False + try: + from dependencies import get_auth + auth_service = get_auth() + if auth_service: + is_admin = auth_service.is_admin(message.from_user.id) + except Exception as e: + logger.warning(f"Ошибка при проверке админа: {e}") + is_admin = False + keyboard = get_admin_keyboard() if is_admin else get_main_keyboard_for_user(message.from_user.id) + + await message.answer( + "❌ Действие отменено.", + reply_markup=keyboard + ) + + +@log_function_call(log_params=True, log_result=True) +async def _send_questions_page( + target, + questions_text: str, + keyboard, + message_service: MessageService +) -> bool: + """Отправка страницы с вопросами""" + try: + if isinstance(target, CallbackQuery): + # Это CallbackQuery + logger.info("Отправляем CallbackQuery через edit_text") + try: + await message_service.edit_message(target, questions_text, keyboard) + except Exception as edit_error: + # Если не удалось отредактировать, отправляем новое сообщение + logger.warning(f"Не удалось отредактировать сообщение: {edit_error}") + await message_service.send_message(target, questions_text, keyboard) + else: + # Это Message - используем answer() напрямую + logger.info("Отправляем Message через answer()") + await message_service.send_message(target, questions_text, keyboard) + + return True + + except Exception as e: + logger.error(f"Ошибка при отправке страницы вопросов: {e}") + return False + + +@log_business_event("show_questions_page", log_params=True, log_result=False) +async def show_questions_page( + message_or_callback, + questions: list, + page: int = 0, + per_page: int = 9, + user_id: int = None, + question_service: QuestionService = None, + user_service: UserService = None, + message_service: MessageService = None, + pagination_service: PaginationService = None +): + """ + Показать страницу с вопросами + + Args: + message_or_callback: Message или CallbackQuery объект + questions: Список всех вопросов + page: Номер страницы (начиная с 0) + per_page: Количество вопросов на странице + user_id: ID пользователя для проверки прав суперпользователя + question_service: Сервис для работы с вопросами + user_service: Сервис для работы с пользователями + message_service: Сервис для отправки сообщений + pagination_service: Сервис для пагинации + """ + try: + # Логируем тип объекта для отладки + object_type = type(message_or_callback).__name__ + questions_count = len(questions) if questions is not None else 0 + logger.info(f"show_questions_page вызвана с объектом типа: {object_type}, page: {page}, questions: {questions_count}") + + # Используем сервисы если они переданы, иначе создаем временные + if not question_service: + from dependencies import get_question_service + question_service = get_question_service() + + if not user_service: + from dependencies import get_user_service + user_service = get_user_service() + + if not message_service: + from dependencies import get_message_service + message_service = get_message_service() + + if not pagination_service: + from dependencies import get_pagination_service + pagination_service = get_pagination_service() + + # Получаем общее количество вопросов для пагинации + total_questions_count = await user_service.database.get_user_questions_count(user_id) + + if total_questions_count == 0: + empty_text = ( + "📭 У вас пока нет вопросов.\n\n" + "🔗 Поделитесь своей ссылкой, чтобы получать анонимные вопросы!" + ) + + if message_service: + await message_service.send_message(message_or_callback, empty_text) + else: + if isinstance(message_or_callback, CallbackQuery): + await message_or_callback.message.edit_text(empty_text, reply_markup=None) + else: + await message_or_callback.answer(empty_text) + return + + # Рассчитываем пагинацию на основе БД + total_questions, current_page, total_pages, offset = await pagination_service.calculate_pagination_from_db( + total_questions_count, page, per_page + ) + + # Получаем вопросы с авторами для текущей страницы (оптимизированный запрос) + questions_with_authors = await user_service.database.get_user_questions_with_authors( + user_id, limit=per_page, offset=offset + ) + + # Формируем информацию о пагинации + start_idx = current_page * per_page + end_idx = min(start_idx + per_page, total_questions) + pagination_info = pagination_service.format_pagination_info( + current_page, total_pages, start_idx, end_idx, total_questions + ) + + # Формируем текст сообщения + questions_text = f"📋 Ваши вопросы\n\n" + questions_text += pagination_info + + # Добавляем список вопросов + questions_list_text = await _format_questions_list( + questions_with_authors, current_page, per_page, user_id, question_service + ) + questions_text += questions_list_text + + # Создаем клавиатуру с пагинацией, используя реальные вопросы из базы данных + keyboard = get_user_questions_keyboard(questions_with_authors, current_page, per_page, total_questions) + + # Отправляем страницу + await _send_questions_page(message_or_callback, questions_text, keyboard, message_service) + + except Exception as e: + logger.error(f"Ошибка при отображении страницы вопросов: {e},") + try: + if message_service: + await message_service.send_error_message( + message_or_callback, + "❌ Ошибка при загрузке вопросов" + ) + else: + if isinstance(message_or_callback, CallbackQuery): + await message_or_callback.answer("❌ Ошибка при загрузке вопросов", show_alert=True) + else: + await message_or_callback.answer("❌ Ошибка при загрузке вопросов") + except Exception as answer_error: + logger.error(f"Не удалось отправить сообщение об ошибке: {answer_error}") + + +class QuestionStates(StatesGroup): + """Состояния для работы с вопросами""" + waiting_for_question = State() + waiting_for_answer = State() + editing_answer = State() + + +@router.message(F.text == "📋 Мои вопросы") +@log_business_event("my_questions_button", log_params=True) +async def my_questions_button( + message: Message, + state: FSMContext, + question_service: QuestionService, + message_service: MessageService +): + """Обработчик кнопки 'Мои вопросы'""" + try: + await state.clear() + + # Получаем все вопросы пользователя + all_questions = await question_service.get_user_questions(message.from_user.id, limit=1000) + + if not all_questions: + await message_service.send_message( + message, + "📭 У вас пока нет вопросов.\n\n" + "🔗 Поделитесь своей ссылкой, чтобы получать анонимные вопросы!", + get_main_keyboard_for_user(message.from_user.id) + ) + else: + # Показываем первую страницу + await show_questions_page( + message, + all_questions, + page=0, + user_id=message.from_user.id, + question_service=question_service, + message_service=message_service + ) + + except Exception as e: + logger.error(f"Ошибка при получении вопросов: {e}") + await message_service.send_error_message( + message, + "❌ Произошла ошибка при получении вопросов. Попробуйте позже." + ) + + +@log_utility +def _has_attachments(message: Message) -> bool: + """Проверяет, содержит ли сообщение вложения""" + return ( + message.photo is not None or + message.video is not None or + message.audio is not None or + message.document is not None or + message.sticker is not None or + message.animation is not None or + message.voice is not None or + message.video_note is not None or + message.contact is not None or + message.location is not None or + message.media_group_id is not None or + message.caption is not None + ) + + +@router.message(StateFilter(QuestionStates.waiting_for_question)) +@inject_question_services +@log_fsm_transition(to_state="processing_question") +@log_business_event("process_anonymous_question", log_params=True) +async def process_anonymous_question( + message: Message, + state: FSMContext, + question_service: QuestionService, + user_service: UserService, + message_service: MessageService, + validator, + **kwargs +): + """Обработка анонимного вопроса""" + # Убираем dispatcher из kwargs, если он есть + kwargs.pop('dispatcher', None) + try: + # Получаем данные из состояния + data = await state.get_data() + target_user_id = data.get('target_user_id') + + if not target_user_id: + await message_service.send_message( + message, + "❌ Ошибка: не найден получатель вопроса.", + get_main_keyboard_for_user(message.from_user.id) + ) + await state.clear() + return + + # Проверяем, содержит ли сообщение вложения + if _has_attachments(message): + logger.warning(f"⚠️ Пользователь {message.from_user.id} отправил сообщение с вложениями") + await message_service.send_message( + message, + "❌ Вложения не допускаются\n\n" + "📝 Вопрос должен состоять только из текста.\n" + "🚫 Фотографии, видео, документы, аудио и другие вложения не принимаются.\n\n" + "Попробуйте отправить вопрос еще раз:", + get_cancel_keyboard() + ) + return + + # Проверяем, что сообщение содержит текст + if not message.text: + logger.warning(f"⚠️ Пользователь {message.from_user.id} отправил пустое сообщение") + await message_service.send_message( + message, + "❌ Вопрос не может быть пустым\n\n" + "Попробуйте отправить вопрос еще раз:", + get_cancel_keyboard() + ) + return + + # Валидируем текст вопроса + validation_result = validator.validate_question_text( + message.text, + config.MAX_QUESTION_LENGTH + ) + + if not validation_result: + logger.warning(f"⚠️ Невалидный вопрос от пользователя {message.from_user.id}: {validation_result.error_message}") + await message_service.send_message( + message, + f"❌ {validation_result.error_message}\n\n" + "Попробуйте отправить вопрос еще раз:", + get_cancel_keyboard() + ) + return + + # Используем санитизированный текст + sanitized_question_text = validation_result.sanitized_value + + # Проверяем, не заблокирован ли отправитель получателем + if await user_service.is_user_blocked(target_user_id, message.from_user.id): + await message_service.send_message( + message, + "🚫 Вы заблокированы этим пользователем\n\n" + "К сожалению, вы не можете отправлять вопросы этому пользователю.", + get_main_keyboard_for_user(message.from_user.id) + ) + await state.clear() + return + + # Создаем вопрос с санитизированным текстом + question = await question_service.create_question( + message.from_user.id, + target_user_id, + sanitized_question_text + ) + + # Отправляем уведомление получателю + await _send_question_notification( + message.bot, + question, + target_user_id, + user_service, + message_service + ) + + # Отправляем подтверждение отправителю + confirmation_text = ( + "✅ Вопрос отправлен!\n\n" + "📝 Ваш вопрос был передан получателю анонимно.\n" + "🔔 Он получит уведомление и сможет ответить на него.\n\n" + "💡 Спасибо за использование нашего бота!" + ) + + await message_service.send_message( + message, + confirmation_text, + get_main_keyboard_for_user(message.from_user.id) + ) + + await state.clear() + + except Exception as e: + logger.error(f"Ошибка при обработке вопроса: {e}") + await message_service.send_error_message( + message, + "❌ Произошла ошибка при отправке вопроса. Попробуйте позже." + ) + await state.clear() + + +@log_function_call(log_params=True, log_result=True) +async def _send_question_notification( + bot, + question: Question, + target_user_id: int, + user_service: UserService, + message_service: MessageService +) -> bool: + """Отправка уведомления о новом вопросе""" + try: + # Получаем информацию о получателе + target_user = await user_service.get_user_by_telegram_id(target_user_id) + if not target_user: + return False + + # Проверяем, является ли получатель суперпользователем + is_target_superuser = False + try: + from dependencies import get_auth + auth_service = get_auth() + if auth_service: + is_target_superuser = await auth_service.is_superuser(target_user_id) + except Exception as e: + logger.warning(f"Ошибка при проверке суперпользователя для уведомления: {e}") + is_target_superuser = False + + notification_text = "❓ Новый анонимный вопрос!\n\n" + + # Для суперпользователей добавляем информацию об авторе + if is_target_superuser and question.from_user_id: + try: + from services.utils import format_user_display_name + author_user = await user_service.get_user_by_telegram_id(question.from_user_id) + if author_user: + author_info = format_user_display_name(author_user) + notification_text = f"❓ Новый вопрос от {author_info}!\n\n" + except Exception as e: + logger.error(f"Ошибка при получении информации об авторе для уведомления: {e}") + + notification_text += f"📝 Вопрос:\n{escape_html(question.message_text)}\n\n" + notification_text += f"📅 {question.created_at.strftime('%d.%m.%Y %H:%M')}" + + # Отправляем уведомление + await message_service.send_bot_message( + bot, + target_user_id, + notification_text, + get_answer_keyboard(question.id, question.from_user_id) + ) + + return True + + except Exception as e: + logger.error(f"Не удалось отправить уведомление пользователю {target_user_id}: {e}") + return False + + +@router.callback_query(F.data.startswith("answer_")) +@log_fsm_transition(to_state="waiting_for_answer") +@log_business_event("answer_question_callback", log_params=True) +async def answer_question_callback(callback: CallbackQuery, state: FSMContext, validator = None): + """Обработчик кнопки 'Ответить' на вопрос""" + try: + # Валидируем callback data + if validator: + validation_result = validator.validate_callback_data(callback.data) + if not validation_result: + logger.warning(f"⚠️ Невалидный callback data от пользователя {callback.from_user.id}: {callback.data}") + await callback.answer("❌ Неверные данные", show_alert=True) + return + + # Извлекаем и валидируем question_id + question_id_str = callback.data.split("_")[1] + try: + question_id = int(question_id_str) + if question_id <= 0: + logger.warning(f"⚠️ Невалидный question_id в callback: {question_id}") + await callback.answer("❌ Неверный ID вопроса", show_alert=True) + return + except ValueError: + logger.warning(f"⚠️ Неверный формат question_id в callback: {question_id_str}") + await callback.answer("❌ Неверный формат ID", show_alert=True) + return + + from dependencies import get_database + db = get_database() + + # Получаем вопрос + question = await db.get_question(question_id) + + if not question: + await callback.answer("❌ Вопрос не найден", show_alert=True) + return + + if question.to_user_id != callback.from_user.id: + await callback.answer("❌ У вас нет прав на этот вопрос", show_alert=True) + return + + if question.status != QuestionStatus.PENDING: + await callback.answer("❌ На этот вопрос уже отвечено", show_alert=True) + return + + # Устанавливаем состояние ожидания ответа + await state.set_state(QuestionStates.waiting_for_answer) + await state.update_data(question_id=question_id) + + # Отправляем сообщение с просьбой ввести ответ + await callback.message.edit_text( + f"💬 Ответ на вопрос #{question_id}\n\n" + f"📝 Вопрос:\n{escape_html(question.message_text)}\n\n" + f"✍️ Введите ваш ответ:", + reply_markup=None, + parse_mode="HTML" + ) + + await callback.answer() + + except Exception as e: + logger.error(f"Ошибка при обработке ответа на вопрос: {e}") + await callback.answer("❌ Произошла ошибка", show_alert=True) + + +@router.message(StateFilter(QuestionStates.waiting_for_answer)) +@inject_answer_services +@log_fsm_transition(to_state="processing_answer") +@log_business_event("process_answer", log_params=True) +async def process_answer( + message: Message, + state: FSMContext, + validator, + message_service: MessageService, + **kwargs +): + """Обработка ответа на вопрос""" + try: + # Получаем данные из состояния + data = await state.get_data() + question_id = data.get('question_id') + + if not question_id: + await message.answer( + "❌ Ошибка: не найден вопрос для ответа.", + reply_markup=get_main_keyboard_for_user(message.from_user.id) + ) + await state.clear() + return + + # Валидируем текст ответа + validation_result = validator.validate_answer_text( + message.text, + config.MAX_ANSWER_LENGTH + ) + + if not validation_result: + logger.warning(f"⚠️ Невалидный ответ от пользователя {message.from_user.id}: {validation_result.error_message}") + await message.answer( + f"❌ {validation_result.error_message}\n\n" + "Попробуйте отправить ответ еще раз:", + reply_markup=get_cancel_keyboard() + ) + return + + # Используем санитизированный текст + sanitized_answer_text = validation_result.sanitized_value + + # Сохраняем ответ + from dependencies import get_database + db = get_database() + + question = await db.get_question(question_id) + if question: + question.mark_as_answered(sanitized_answer_text) + await db.update_question(question) + + # Обновляем статистику пользователя (если нужно) + # user = await db.get_user(message.from_user.id) + # if user: + # user.increment_questions_answered() + # await db.update_user(user) + + # Отправляем ответ автору вопроса + logger.info(f"Попытка отправить ответ автору вопроса {question.id}, from_user_id: {question.from_user_id}") + await send_answer_to_author(message.bot, question, question.answer_text) + + # Отправляем подтверждение + await message_service.send_message( + message, + "✅ Ответ сохранен!\n\n" + "💬 Ваш ответ был сохранен и будет показан при просмотре вопроса.\n\n" + "📋 Используйте 'Мои вопросы' для просмотра всех вопросов и ответов.", + get_main_keyboard_for_user(message.from_user.id) + ) + + await state.clear() + + except Exception as e: + logger.error(f"Ошибка при обработке ответа: {e}") + await message_service.send_message( + message, + "❌ Произошла ошибка при сохранении ответа. Попробуйте позже.", + get_main_keyboard_for_user(message.from_user.id) + ) + await state.clear() + + +@router.callback_query(F.data.startswith("reject_")) +@log_business_event("reject_question_callback", log_params=True) +async def reject_question_callback(callback: CallbackQuery, validator = None): + """Обработчик кнопки 'Отклонить' вопрос""" + try: + # Валидируем callback data + if validator: + validation_result = validator.validate_callback_data(callback.data) + if not validation_result: + logger.warning(f"⚠️ Невалидный callback data от пользователя {callback.from_user.id}: {callback.data}") + await callback.answer("❌ Неверные данные", show_alert=True) + return + + # Извлекаем и валидируем question_id + question_id_str = callback.data.split("_")[1] + try: + question_id = int(question_id_str) + if question_id <= 0: + logger.warning(f"⚠️ Невалидный question_id в callback: {question_id}") + await callback.answer("❌ Неверный ID вопроса", show_alert=True) + return + except ValueError: + logger.warning(f"⚠️ Неверный формат question_id в callback: {question_id_str}") + await callback.answer("❌ Неверный формат ID", show_alert=True) + return + + from dependencies import get_database + db = get_database() + + # Получаем вопрос + question = await db.get_question(question_id) + + if not question: + await callback.answer("❌ Вопрос не найден", show_alert=True) + return + + if question.to_user_id != callback.from_user.id: + await callback.answer("❌ У вас нет прав на этот вопрос", show_alert=True) + return + + if question.status != QuestionStatus.PENDING: + await callback.answer("❌ На этот вопрос уже отвечено", show_alert=True) + return + + # Отклоняем вопрос + question.mark_as_rejected() + await db.update_question(question) + + # Обновляем сообщение + await callback.message.edit_text( + f"❌ Вопрос #{question.user_question_number if question.user_question_number is not None else question_id} отклонен\n\n" + f"📝 Вопрос:\n{escape_html(question.message_text)}\n\n" + f"📅 {question.created_at.strftime('%d.%m.%Y %H:%M')}\n" + f"❌ Отклонен: {question.answered_at.strftime('%d.%m.%Y %H:%M')}", + reply_markup=None, + parse_mode="HTML" + ) + + await callback.answer("❌ Вопрос отклонен") + + except Exception as e: + logger.error(f"Ошибка при отклонении вопроса: {e}") + await callback.answer("❌ Произошла ошибка", show_alert=True) + + +@router.callback_query(F.data.startswith("delete_")) +@log_business_event("delete_question_callback", log_params=True) +async def delete_question_callback(callback: CallbackQuery, validator = None): + """Обработчик кнопки 'Удалить' вопрос""" + try: + # Валидируем callback data + if validator: + validation_result = validator.validate_callback_data(callback.data) + if not validation_result: + logger.warning(f"⚠️ Невалидный callback data от пользователя {callback.from_user.id}: {callback.data}") + await callback.answer("❌ Неверные данные", show_alert=True) + return + + # Извлекаем и валидируем question_id + question_id_str = callback.data.split("_")[1] + try: + question_id = int(question_id_str) + if question_id <= 0: + logger.warning(f"⚠️ Невалидный question_id в callback: {question_id}") + await callback.answer("❌ Неверный ID вопроса", show_alert=True) + return + except ValueError: + logger.warning(f"⚠️ Неверный формат question_id в callback: {question_id_str}") + await callback.answer("❌ Неверный формат ID", show_alert=True) + return + + from dependencies import get_database + db = get_database() + + # Получаем вопрос + question = await db.get_question(question_id) + + if not question: + await callback.answer("❌ Вопрос не найден", show_alert=True) + return + + if question.to_user_id != callback.from_user.id: + await callback.answer("❌ У вас нет прав на этот вопрос", show_alert=True) + return + + # Удаляем вопрос + question.mark_as_deleted() + await db.update_question(question) + + # Обновляем сообщение + await callback.message.edit_text( + f"🗑️ Вопрос #{question.user_question_number if question.user_question_number is not None else question_id} удален\n\n" + f"📅 Удален: {question.answered_at.strftime('%d.%m.%Y %H:%M')}", + reply_markup=None, + parse_mode="HTML" + ) + + await callback.answer("🗑️ Вопрос удален") + + except Exception as e: + logger.error(f"Ошибка при удалении вопроса: {e}") + await callback.answer("❌ Произошла ошибка", show_alert=True) + + +@router.callback_query(F.data.startswith("questions_page_")) +@log_business_event("questions_pagination_handler", log_params=True) +async def questions_pagination_handler(callback: CallbackQuery): + """Обработчик пагинации списка вопросов (оптимизированная версия)""" + try: + # Извлекаем номер страницы из callback_data + page = int(callback.data.split("_")[-1]) + + # Получаем сервисы + from dependencies import get_user_service + user_service = get_user_service() + + # Получаем общее количество вопросов для пагинации (оптимизированный запрос) + total_questions_count = await user_service.database.get_user_questions_count(callback.from_user.id) + + if total_questions_count == 0: + await callback.answer("❌ Вопросы не найдены", show_alert=True) + return + + # Показываем нужную страницу (show_questions_page сам загрузит данные из БД) + logger.info(f"Показываем страницу {page} для пользователя {callback.from_user.id}, всего вопросов: {total_questions_count}") + await show_questions_page(callback, None, page, user_id=callback.from_user.id) + await callback.answer() + + except ValueError as e: + logger.error(f"Ошибка парсинга номера страницы: {e}") + await callback.answer("❌ Неверный номер страницы", show_alert=True) + except Exception as e: + logger.error(f"Ошибка при пагинации вопросов: {e}") + await callback.answer("❌ Ошибка при загрузке страницы", show_alert=True) + + +@router.callback_query(F.data == "back_to_questions") +@log_business_event("back_to_questions_handler", log_params=True) +async def back_to_questions_handler(callback: CallbackQuery): + """Обработчик кнопки 'Назад к списку вопросов'""" + try: + # Получаем базу данных + from dependencies import get_database + db = get_database() + + # Получаем все вопросы пользователя + all_questions = await db.get_user_questions(callback.from_user.id, limit=1000) + + if not all_questions: + await callback.answer("❌ Вопросы не найдены", show_alert=True) + return + + # Показываем первую страницу списка + await show_questions_page(callback, all_questions, page=0, user_id=callback.from_user.id) + await callback.answer() + + except Exception as e: + logger.error(f"Ошибка при возврате к списку вопросов: {e}") + await callback.answer("❌ Ошибка при загрузке списка", show_alert=True) + + +@router.callback_query(F.data.startswith("view_question_")) +@log_business_event("view_question_handler", log_params=True) +async def view_question_handler(callback: CallbackQuery, validator = None): + """Обработчик просмотра конкретного вопроса""" + try: + # Валидируем callback data + if validator: + validation_result = validator.validate_callback_data(callback.data) + if not validation_result: + logger.warning(f"⚠️ Невалидный callback data от пользователя {callback.from_user.id}: {callback.data}") + await callback.answer("❌ Неверные данные", show_alert=True) + return + + # Извлекаем и валидируем question_id + question_id_str = callback.data.split("_")[-1] + try: + question_id = int(question_id_str) + if question_id <= 0: + logger.warning(f"⚠️ Невалидный question_id в callback: {question_id}") + await callback.answer("❌ Неверный ID вопроса", show_alert=True) + return + except ValueError: + logger.warning(f"⚠️ Неверный формат question_id в callback: {question_id_str}") + await callback.answer("❌ Неверный формат ID", show_alert=True) + return + + # Получаем базу данных + from dependencies import get_database + db = get_database() + + # Получаем вопрос + question = await db.get_question(question_id) + if not question: + await callback.answer("❌ Вопрос не найден", show_alert=True) + return + + # Форматируем информацию о вопросе + question_text = format_question_info(question, show_answer=True) + + # Создаем клавиатуру для просмотра вопроса + keyboard = get_question_view_keyboard(question) + + await callback.message.edit_text( + question_text, + reply_markup=keyboard, + parse_mode="HTML" + ) + + await callback.answer() + + except Exception as e: + logger.error(f"Ошибка при просмотре вопроса: {e}") + await callback.answer("❌ Ошибка при загрузке вопроса", show_alert=True) + + +@router.callback_query(F.data.startswith("block_")) +@log_business_event("block_user_callback", log_params=True) +async def block_user_callback(callback: CallbackQuery, validator = None): + """Обработчик блокировки пользователя""" + try: + # Валидируем callback data + if validator: + validation_result = validator.validate_callback_data(callback.data) + if not validation_result: + logger.warning(f"⚠️ Невалидный callback data от пользователя {callback.from_user.id}: {callback.data}") + await callback.answer("❌ Неверные данные", show_alert=True) + return + + # Парсим данные: block_{user_id}_{question_id} + data_parts = callback.data.split("_") + if len(data_parts) != 3: + await callback.answer("❌ Ошибка в данных", show_alert=True) + return + + # Валидируем user_id и question_id + try: + blocked_user_id = int(data_parts[1]) + question_id = int(data_parts[2]) + + if validator: + # Валидируем Telegram ID + user_id_validation = validator.validate_telegram_id(blocked_user_id) + if not user_id_validation: + logger.warning(f"⚠️ Невалидный blocked_user_id в callback: {blocked_user_id}") + await callback.answer("❌ Неверный ID пользователя", show_alert=True) + return + + # Валидируем question_id + if question_id <= 0: + logger.warning(f"⚠️ Невалидный question_id в callback: {question_id}") + await callback.answer("❌ Неверный ID вопроса", show_alert=True) + return + except ValueError: + logger.warning(f"⚠️ Неверный формат ID в callback: {callback.data}") + await callback.answer("❌ Неверный формат ID", show_alert=True) + return + + blocker_id = callback.from_user.id + + # Проверяем, не блокирует ли пользователь сам себя + if blocked_user_id == blocker_id: + await callback.answer("❌ Нельзя заблокировать самого себя", show_alert=True) + return + + # Получаем базу данных + from dependencies import get_database + db = get_database() + + # Проверяем, не заблокирован ли уже пользователь + if await db.is_user_blocked(blocker_id, blocked_user_id): + await callback.answer("❌ Пользователь уже заблокирован", show_alert=True) + return + + # Блокируем пользователя + await db.block_user(blocker_id, blocked_user_id) + + # Получаем информацию о заблокированном пользователе + blocked_user = await db.get_user(blocked_user_id) + blocked_name = blocked_user.display_name if blocked_user else f"ID: {blocked_user_id}" + + # Обновляем сообщение с вопросом + question_text = f"🚫 Пользователь заблокирован\n\n" + question_text += f"👤 Заблокирован: {blocked_name}\n" + question_text += f"📅 Дата блокировки: {datetime.now().strftime('%d.%m.%Y %H:%M')}\n\n" + question_text += "✅ Пользователь больше не сможет отправлять вам вопросы." + + # Создаем клавиатуру с кнопкой разблокировки + from aiogram.utils.keyboard import InlineKeyboardBuilder + from aiogram.types import InlineKeyboardButton + builder = InlineKeyboardBuilder() + builder.add( + InlineKeyboardButton( + text="🔓 Разблокировать", + callback_data=f"unblock_{blocked_user_id}_{question_id}" + ) + ) + builder.add( + InlineKeyboardButton( + text="⬅️ К списку вопросов", + callback_data="back_to_questions" + ) + ) + builder.adjust(1) + + await callback.message.edit_text( + question_text, + reply_markup=builder.as_markup(), + parse_mode="HTML" + ) + + await callback.answer("✅ Пользователь заблокирован", show_alert=True) + + except ValueError: + await callback.answer("❌ Неверный формат данных", show_alert=True) + except Exception as e: + logger.error(f"Ошибка при блокировке пользователя: {e}") + await callback.answer("❌ Произошла ошибка при блокировке", show_alert=True) + + +@router.callback_query(F.data.startswith("unblock_") & ~F.data.startswith("unblock_from_list_")) +@log_business_event("unblock_user_callback", log_params=True) +async def unblock_user_callback(callback: CallbackQuery): + """Обработчик разблокировки пользователя""" + try: + # Логируем входящие данные для отладки + logger.info(f"🔓 Попытка разблокировки пользователя. Callback data: '{callback.data}'") + + # Парсим данные: unblock_{user_id}_{question_id} + data_parts = callback.data.split("_") + logger.info(f"🔍 Разобранные части данных: {data_parts}, количество частей: {len(data_parts)}") + + if len(data_parts) != 3: + logger.error(f"❌ Неверное количество частей в данных: {len(data_parts)}, ожидалось 3. Данные: {callback.data}") + await callback.answer("❌ Ошибка в данных", show_alert=True) + return + + try: + unblocked_user_id = int(data_parts[1]) + question_id = int(data_parts[2]) + unblocker_id = callback.from_user.id + + logger.info(f"🔍 Парсинг успешен: unblocked_user_id={unblocked_user_id}, question_id={question_id}, unblocker_id={unblocker_id}") + except ValueError as e: + logger.error(f"❌ Ошибка парсинга ID: {e}. Данные: {data_parts}") + await callback.answer("❌ Ошибка в данных пользователя", show_alert=True) + return + + # Получаем базу данных + from dependencies import get_database + db = get_database() + + # Проверяем, заблокирован ли пользователь + is_blocked = await db.is_user_blocked(unblocker_id, unblocked_user_id) + logger.info(f"🔍 Проверка блокировки: is_blocked={is_blocked} для unblocker_id={unblocker_id}, unblocked_user_id={unblocked_user_id}") + + if not is_blocked: + logger.warning(f"⚠️ Попытка разблокировать незаблокированного пользователя: unblocker_id={unblocker_id}, unblocked_user_id={unblocked_user_id}") + await callback.answer("❌ Пользователь не заблокирован", show_alert=True) + return + + # Разблокируем пользователя + logger.info(f"🔓 Начинаем разблокировку: unblocker_id={unblocker_id}, unblocked_user_id={unblocked_user_id}") + success = await db.unblock_user(unblocker_id, unblocked_user_id) + logger.info(f"🔓 Результат разблокировки: success={success}") + + if not success: + logger.error(f"❌ Не удалось разблокировать пользователя: unblocker_id={unblocker_id}, unblocked_user_id={unblocked_user_id}") + await callback.answer("❌ Не удалось разблокировать пользователя", show_alert=True) + return + + # Получаем информацию о разблокированном пользователе + unblocked_user = await db.get_user(unblocked_user_id) + unblocked_name = unblocked_user.display_name if unblocked_user else f"ID: {unblocked_user_id}" + + # Обновляем сообщение + question_text = f"🔓 Пользователь разблокирован\n\n" + question_text += f"👤 Разблокирован: {unblocked_name}\n" + question_text += f"📅 Дата разблокировки: {datetime.now().strftime('%d.%m.%Y %H:%M')}\n\n" + question_text += "✅ Пользователь снова может отправлять вам вопросы." + + # Создаем клавиатуру с кнопкой блокировки + from aiogram.utils.keyboard import InlineKeyboardBuilder + from aiogram.types import InlineKeyboardButton + builder = InlineKeyboardBuilder() + builder.add( + InlineKeyboardButton( + text="🚫 Заблокировать", + callback_data=f"block_{unblocked_user_id}_{question_id}" + ) + ) + builder.add( + InlineKeyboardButton( + text="⬅️ К списку вопросов", + callback_data="back_to_questions" + ) + ) + builder.adjust(1) + + await callback.message.edit_text( + question_text, + reply_markup=builder.as_markup(), + parse_mode="HTML" + ) + + logger.info(f"✅ Пользователь успешно разблокирован: unblocked_user_id={unblocked_user_id}") + await callback.answer("✅ Пользователь разблокирован", show_alert=True) + + except ValueError as e: + logger.error(f"❌ Ошибка ValueError при разблокировке: {e}") + await callback.answer("❌ Неверный формат данных", show_alert=True) + except Exception as e: + logger.error(f"❌ Неожиданная ошибка при разблокировке пользователя: {e}") + await callback.answer("❌ Произошла ошибка при разблокировке", show_alert=True) + + +@router.message(F.text == "🚫 Заблокированные") +@log_business_event("blocked_users_button", log_params=True) +async def blocked_users_button(message: Message): + """Обработчик кнопки 'Заблокированные'""" + try: + from dependencies import get_database + db = get_database() + + # Получаем список заблокированных пользователей + blocked_user_ids = await db.user_blocks.get_blocked_users(message.from_user.id) + + if not blocked_user_ids: + await message.answer( + "📝 Заблокированные пользователи\n\n" + "У вас нет заблокированных пользователей.", + parse_mode="HTML" + ) + return + + # Получаем информацию о заблокированных пользователях + blocked_users = [] + for user_id in blocked_user_ids: + user = await db.get_user(user_id) + if user: + blocked_users.append(user) + + if not blocked_users: + await message.answer( + "📝 Заблокированные пользователи\n\n" + "Заблокированные пользователи не найдены в системе.", + parse_mode="HTML" + ) + return + + # Формируем сообщение со списком заблокированных + text = "🚫 Заблокированные пользователи\n\n" + + for i, user in enumerate(blocked_users, 1): + text += f"{i}. {user.display_name}\n" + if user.username: + text += f" @{user.username}\n" + text += f" ID: {user.telegram_id}\n\n" + + text += f"📊 Всего заблокировано: {len(blocked_users)}" + + # Создаем клавиатуру с кнопками разблокировки + from aiogram.utils.keyboard import InlineKeyboardBuilder + from aiogram.types import InlineKeyboardButton + + builder = InlineKeyboardBuilder() + + # Добавляем кнопки для каждого заблокированного пользователя + for user in blocked_users[:10]: # Ограничиваем до 10 кнопок + builder.add( + InlineKeyboardButton( + text=f"🔓 {user.display_name}", + callback_data=f"unblock_from_list_{user.telegram_id}" + ) + ) + + # Добавляем кнопку "Разблокировать всех" + if len(blocked_users) > 1: + builder.add( + InlineKeyboardButton( + text="🔓 Разблокировать всех", + callback_data="unblock_all" + ) + ) + + builder.adjust(1) # По одной кнопке в ряд + + await message.answer( + text, + reply_markup=builder.as_markup(), + parse_mode="HTML" + ) + + except Exception as e: + logger.error(f"Ошибка при получении списка заблокированных: {e}") + await message.answer( + "❌ Произошла ошибка при получении списка заблокированных пользователей.", + reply_markup=get_main_keyboard_for_user(message.from_user.id) + ) + + +@router.callback_query(F.data.startswith("unblock_from_list_")) +@log_business_event("unblock_from_list_callback", log_params=True) +async def unblock_from_list_callback(callback: CallbackQuery): + """Обработчик разблокировки пользователя из списка""" + try: + # Парсим данные: unblock_from_list_{user_id} + user_id = int(callback.data.split("_")[-1]) + unblocker_id = callback.from_user.id + + # Получаем базу данных + from dependencies import get_database + db = get_database() + + # Проверяем, заблокирован ли пользователь + if not await db.is_user_blocked(unblocker_id, user_id): + await callback.answer("❌ Пользователь не заблокирован", show_alert=True) + return + + # Разблокируем пользователя + success = await db.unblock_user(unblocker_id, user_id) + + if not success: + await callback.answer("❌ Не удалось разблокировать пользователя", show_alert=True) + return + + # Получаем информацию о разблокированном пользователе + unblocked_user = await db.get_user(user_id) + unblocked_name = unblocked_user.display_name if unblocked_user else f"ID: {user_id}" + + await callback.answer(f"✅ {unblocked_name} разблокирован", show_alert=True) + + # Обновляем сообщение со списком + await blocked_users_button(callback.message) + + except ValueError: + await callback.answer("❌ Неверный формат данных", show_alert=True) + except Exception as e: + logger.error(f"Ошибка при разблокировке из списка: {e}") + await callback.answer("❌ Произошла ошибка при разблокировке", show_alert=True) + + +@router.callback_query(F.data == "unblock_all") +@log_business_event("unblock_all_callback", log_params=True) +async def unblock_all_callback(callback: CallbackQuery): + """Обработчик разблокировки всех пользователей""" + try: + unblocker_id = callback.from_user.id + + # Получаем базу данных + from dependencies import get_database + db = get_database() + + # Получаем список заблокированных пользователей + blocked_user_ids = await db.user_blocks.get_blocked_users(unblocker_id) + + if not blocked_user_ids: + await callback.answer("❌ Нет заблокированных пользователей", show_alert=True) + return + + # Разблокируем всех пользователей + unblocked_count = 0 + for user_id in blocked_user_ids: + success = await db.unblock_user(unblocker_id, user_id) + if success: + unblocked_count += 1 + + await callback.answer(f"✅ Разблокировано {unblocked_count} пользователей", show_alert=True) + + # Обновляем сообщение со списком + await blocked_users_button(callback.message) + + except Exception as e: + logger.error(f"Ошибка при разблокировке всех: {e}") + await callback.answer("❌ Произошла ошибка при разблокировке", show_alert=True) diff --git a/handlers/start.py b/handlers/start.py new file mode 100644 index 0000000..8d9f6a3 --- /dev/null +++ b/handlers/start.py @@ -0,0 +1,328 @@ +""" +Обработчики команд /start и /help +""" +from datetime import datetime +from aiogram import Router, F +from aiogram.types import Message +from aiogram.filters import Command, CommandStart +from aiogram.fsm.context import FSMContext + +from config import config +from models.user import User +from services.infrastructure.database import DatabaseService +from services.auth.auth_new import AuthService +from services.utils import UtilsService +from services.business.user_service import UserService +from services.business.message_service import MessageService +from services.infrastructure.logger import get_logger +from services.infrastructure.metrics import get_metrics_service, track_message_processing +from keyboards.reply import get_main_keyboard_for_user, get_admin_reply_keyboard +from keyboards.inline import get_admin_keyboard +from dependencies import inject_start_services, inject_link_services, inject_main_menu_services + +logger = get_logger(__name__) +router = Router() + + +async def _create_welcome_message(user: User, referral_link: str) -> str: + """Создание приветственного сообщения""" + welcome_text = f"👋 Добро пожаловать, {user.display_name}!\n\n" + welcome_text += "🤖 Я бот для анонимных вопросов.\n\n" + welcome_text += "📝 Как это работает:\n" + welcome_text += "• Поделитесь своей ссылкой с друзьями\n" + welcome_text += "• Они смогут задать вам анонимные вопросы\n" + welcome_text += "• Вы получите уведомления и сможете ответить\n\n" + welcome_text += f"🔗 Ваша персональная ссылка:\n" + welcome_text += f"{referral_link}\n\n" + welcome_text += "💡 Совет: Скопируйте ссылку и поделитесь ею в социальных сетях!" + + return welcome_text + + +async def _process_start_command( + message: Message, + user_service: UserService, + auth: AuthService, + utils: UtilsService, + message_service: MessageService, + validator +) -> User: + """Обработка команды /start без аргументов""" + # Валидируем Telegram ID пользователя + user_id_validation = validator.validate_telegram_id(message.from_user.id) + if not user_id_validation: + logger.error(f"❌ Невалидный Telegram ID: {message.from_user.id}") + await message_service.send_message( + message, + "❌ Ошибка: недопустимый ID пользователя.", + get_main_keyboard_for_user(message.from_user.id) + ) + raise ValueError(f"Invalid Telegram ID: {message.from_user.id}") + + # Создаем или обновляем пользователя + user = await user_service.create_or_update_user(message.from_user, message.chat.id) + + # Проверяем, является ли пользователь админом + is_admin = auth.is_admin(user.telegram_id) + + # Генерируем персональную ссылку + bot_info = await message.bot.get_me() + referral_link = user_service.generate_referral_link(bot_info.username, user) + + # Создаем приветственное сообщение + welcome_text = await _create_welcome_message(user, referral_link) + + # Выбираем клавиатуру в зависимости от роли + if is_admin: + keyboard = get_admin_reply_keyboard() + else: + keyboard = get_main_keyboard_for_user(user.telegram_id) + logger.info(f"⌨️ Создана клавиатура для пользователя {user.telegram_id}: {type(keyboard).__name__}") + + # Отправляем сообщение + await message_service.send_message(message, welcome_text, keyboard) + logger.info(f"✅ Приветственное сообщение отправлено пользователю {user.telegram_id}") + + return user + + +@router.message(CommandStart()) +@track_message_processing("start_command") +@inject_start_services +async def cmd_start( + message: Message, + state: FSMContext, + user_service: UserService, + auth: AuthService, + utils: UtilsService, + message_service: MessageService, + validator, + **kwargs +): + """Обработчик команды /start""" + try: + logger.info(f"🚀 Команда /start от пользователя {message.from_user.id} ({message.from_user.first_name})") + + # Сбрасываем состояние FSM при команде /start + await state.clear() + logger.info(f"🔄 Состояние FSM сброшено для пользователя {message.from_user.id}") + + # Получаем аргументы команды + args = message.text.split()[1:] if len(message.text.split()) > 1 else [] + + # Обрабатываем deep linking если есть аргументы + if args: + logger.info(f"🔗 Обработка deep link: {args[0]}") + await handle_deep_link(message, args[0], user_service, state, message_service, validator) + else: + # Обрабатываем обычную команду /start + await _process_start_command(message, user_service, auth, utils, message_service, validator) + + except Exception as e: + logger.error(f"💥 Ошибка в обработчике /start: {e}") + await message_service.send_error_message( + message, + "❌ Произошла ошибка при запуске бота. Попробуйте позже." + ) + + +async def handle_deep_link( + message: Message, + ref_code: str, + user_service: UserService, + state: FSMContext, + message_service: MessageService, + validator +): + """Обработка deep linking для анонимных вопросов""" + try: + # Валидируем deep link + validation_result = validator.validate_deep_link(ref_code) + if not validation_result: + logger.warning(f"⚠️ Невалидный deep link от пользователя {message.from_user.id}: {ref_code}") + await message_service.send_message( + message, + f"❌ {validation_result.error_message}", + get_main_keyboard_for_user(message.from_user.id) + ) + return + + # Используем санитизированное значение + ref_code = validation_result.sanitized_value + + if not ref_code.startswith('ref_'): + await message_service.send_message( + message, + "❌ Неверная ссылка.", + get_main_keyboard_for_user(message.from_user.id) + ) + return + + # Извлекаем анонимный ID из реферального кода + anonymous_id = ref_code[4:] # Убираем 'ref_' + + # Ищем пользователя по profile_link + target_user = await user_service.get_user_by_profile_link(anonymous_id) + if not target_user: + await message_service.send_message( + message, + "❌ Пользователь, на которого вы перешли, не найден.", + get_main_keyboard_for_user(message.from_user.id) + ) + return + + # Отправляем сообщение о переходе по ссылке + deep_link_text = ( + f"👋 Вы перешли по ссылке пользователя {target_user.display_name}!\n\n" + f"📝 Теперь вы можете задать анонимный вопрос.\n" + f"Просто отправьте ваше сообщение, и оно будет передано получателю." + ) + + await message_service.send_message( + message, + deep_link_text, + get_main_keyboard_for_user(message.from_user.id) + ) + + # Устанавливаем состояние ожидания вопроса + from aiogram.fsm.state import State, StatesGroup + + class QuestionStates(StatesGroup): + waiting_for_question = State() + + await state.set_state(QuestionStates.waiting_for_question) + await state.update_data(target_user_id=target_user.telegram_id) + + except Exception as e: + logger.error(f"Ошибка при обработке deep link: {e}") + await message_service.send_error_message( + message, + "❌ Произошла ошибка при обработке ссылки." + ) + + +@router.message(Command("help")) +async def cmd_help(message: Message): + """Обработчик команды /help""" + help_text = "📖 Справка по боту\n\n" + help_text += "🤖 Основные команды:\n" + help_text += "/start - Запуск бота и получение персональной ссылки\n" + help_text += "/help - Показать эту справку\n" + help_text += "/stats - Показать статистику (только для админов)\n\n" + help_text += "📝 Как задать анонимный вопрос:\n" + help_text += "1. Перейдите по персональной ссылке пользователя\n" + help_text += "2. Отправьте ваш вопрос боту\n" + help_text += "3. Вопрос будет передан получателю анонимно\n\n" + help_text += "💬 Как ответить на вопрос:\n" + help_text += "1. Получите уведомление о новом вопросе\n" + help_text += "2. Нажмите кнопку 'Ответить'\n" + help_text += "3. Введите ваш ответ\n" + help_text += "4. Ответ будет отправлен анонимно\n\n" + help_text += "🔗 Ваша персональная ссылка:\n" + help_text += "Используйте кнопку 'Моя ссылка' для получения ссылки\n\n" + help_text += "❓ Нужна помощь?\n" + help_text += "Обратитесь к администратору бота: @Kerrad1" + + await message.answer(help_text, parse_mode="HTML") + + +@router.message(F.text == "ℹ️ Помощь") +async def help_button(message: Message): + """Обработчик кнопки 'Помощь'""" + await cmd_help(message) + + +@router.message(F.text == "🔗 Моя ссылка") +@inject_link_services +async def my_link_button( + message: Message, + user_service: UserService, + message_service: MessageService, + **kwargs +): + """Обработчик кнопки 'Моя ссылка'""" + try: + # Получаем пользователя из БД + user = await user_service.get_user_by_telegram_id(message.from_user.id) + + if not user: + await message_service.send_message( + message, + "❌ Пользователь не найден. Используйте /start для регистрации." + ) + return + + # Получаем информацию о боте + bot_info = await message.bot.get_me() + referral_link = user_service.generate_referral_link(bot_info.username, user) + + link_text = "🔗 Ваша персональная ссылка:\n\n" + link_text += f"{referral_link}\n\n" + link_text += "📝 Как использовать:\n" + link_text += "• Скопируйте ссылку\n" + link_text += "• Поделитесь ею в социальных сетях\n" + link_text += "• Друзья смогут задать вам анонимные вопросы\n\n" + link_text += "💡 Совет: Добавьте ссылку в описание профиля или поделитесь в Stories!" + + await message_service.send_message(message, link_text) + + except Exception as e: + logger.error(f"Ошибка при получении ссылки: {e}") + await message_service.send_error_message( + message, + "❌ Произошла ошибка при получении ссылки. Попробуйте позже." + ) + + +@router.message(F.text == "⬅️ Главное меню") +@inject_main_menu_services +async def back_to_main( + message: Message, + state: FSMContext, + auth: AuthService, + message_service: MessageService, + **kwargs +): + """Обработчик кнопки 'Главное меню'""" + # Сбрасываем состояние FSM при возврате в главное меню + await state.clear() + logger.info(f"🔄 Состояние FSM сброшено для пользователя {message.from_user.id} (кнопка 'Главное меню')") + + is_admin = auth.is_admin(message.from_user.id) + if is_admin: + keyboard = get_admin_reply_keyboard() + else: + keyboard = get_main_keyboard_for_user(message.from_user.id) + + await message_service.send_message( + message, + "🏠 Главное меню\n\nВыберите действие:", + keyboard + ) + + +@router.message(F.text == "⚙️ Админ панель") +@inject_main_menu_services +async def admin_panel_button( + message: Message, + auth: AuthService, + message_service: MessageService, + **kwargs +): + """Обработчик кнопки 'Админ панель'""" + # Проверяем, является ли пользователь админом + if not auth.is_admin(message.from_user.id): + await message_service.send_message( + message, + "❌ У вас нет прав для доступа к админ панели." + ) + return + + # Получаем inline клавиатуру для админов + admin_keyboard = get_admin_keyboard() + + await message_service.send_message( + message, + "👑 Админ панель\n\nВыберите действие:", + admin_keyboard + ) diff --git a/keyboards/__init__.py b/keyboards/__init__.py new file mode 100644 index 0000000..954c084 --- /dev/null +++ b/keyboards/__init__.py @@ -0,0 +1,17 @@ +""" +Клавиатуры для бота +""" + +from .inline import ( + get_answer_keyboard, + get_admin_keyboard, + get_stats_keyboard +) +from .reply import get_main_keyboard + +__all__ = [ + 'get_answer_keyboard', + 'get_admin_keyboard', + 'get_stats_keyboard', + 'get_main_keyboard' +] diff --git a/keyboards/inline.py b/keyboards/inline.py new file mode 100644 index 0000000..72c6077 --- /dev/null +++ b/keyboards/inline.py @@ -0,0 +1,615 @@ +""" +Inline клавиатуры для бота +""" +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from models.question import Question +from typing import List +from services.utils import escape_html + + +def get_answer_keyboard(question_id: int, from_user_id: int = None) -> InlineKeyboardMarkup: + """ + Клавиатура для ответа на вопрос + + Args: + question_id: ID вопроса + from_user_id: ID отправителя вопроса (для блокировки) + + Returns: + Inline клавиатура + """ + builder = InlineKeyboardBuilder() + + builder.add( + InlineKeyboardButton( + text="💬 Ответить", + callback_data=f"answer_{question_id}" + ) + ) + builder.add( + InlineKeyboardButton( + text="❌ Отклонить", + callback_data=f"reject_{question_id}" + ) + ) + + # Добавляем кнопку блокировки, если есть ID отправителя + if from_user_id: + builder.add( + InlineKeyboardButton( + text="🚫 Заблокировать", + callback_data=f"block_{from_user_id}_{question_id}" + ) + ) + + builder.add( + InlineKeyboardButton( + text="🗑️ Удалить", + callback_data=f"delete_{question_id}" + ) + ) + + builder.adjust(1) # По одной кнопке в ряд + return builder.as_markup() + + + + +def get_admin_keyboard() -> InlineKeyboardMarkup: + """ + Клавиатура для администраторов + + Returns: + Inline клавиатура + """ + builder = InlineKeyboardBuilder() + + builder.add( + InlineKeyboardButton( + text="📊 Статистика", + callback_data="admin_stats" + ) + ) + builder.add( + InlineKeyboardButton( + text="📢 Рассылка", + callback_data="admin_broadcast" + ) + ) + builder.add( + InlineKeyboardButton( + text="🔍 Назначить суперпользователя", + callback_data="admin_assign_superuser" + ) + ) + builder.add( + InlineKeyboardButton( + text="🚫 Забанить пользователя", + callback_data="admin_ban_user" + ) + ) + builder.add( + InlineKeyboardButton( + text="🚦 Rate Limiting", + callback_data="admin_rate_limit" + ) + ) + + builder.adjust(2) # По две кнопки в ряд + return builder.as_markup() + + +def get_superuser_assignment_keyboard(users: List, page: int = 0, per_page: int = 10) -> InlineKeyboardMarkup: + """ + Клавиатура для назначения суперпользователей + + Args: + users: Список пользователей + page: Номер страницы + per_page: Количество пользователей на странице + + Returns: + Inline клавиатура с пользователями для назначения + """ + builder = InlineKeyboardBuilder() + + # Вычисляем диапазон пользователей для текущей страницы + start_idx = page * per_page + end_idx = min(start_idx + per_page, len(users)) + page_users = users[start_idx:end_idx] + + # Добавляем кнопки для пользователей + for user in page_users: + # Определяем статус пользователя + status_emoji = "🔍" if user.is_superuser else "👤" + button_text = f"{status_emoji} {user.display_name}" + + # Обрезаем текст если слишком длинный + if len(button_text) > 30: + button_text = button_text[:27] + "..." + + builder.add( + InlineKeyboardButton( + text=button_text, + callback_data=f"assign_superuser_{user.telegram_id}" + ) + ) + + # Настраиваем расположение кнопок: 1 кнопка в ряд для лучшей читаемости + builder.adjust(1) + + # Добавляем управляющие кнопки + control_buttons = [] + + # Кнопка "Предыдущая" (если есть предыдущие страницы) + if page > 0: + control_buttons.append( + InlineKeyboardButton( + text="⬅️ Предыдущая", + callback_data=f"superuser_page_{page - 1}" + ) + ) + + # Кнопка "Следующая" (если есть следующие страницы) + total_pages = (len(users) + per_page - 1) // per_page + if page < total_pages - 1: + control_buttons.append( + InlineKeyboardButton( + text="Следующая ➡️", + callback_data=f"superuser_page_{page + 1}" + ) + ) + + # Добавляем управляющие кнопки + if control_buttons: + builder.row(*control_buttons) + + # Кнопка "Назад" + builder.add( + InlineKeyboardButton( + text="🔙 Назад к админ панели", + callback_data="back_to_admin" + ) + ) + + return builder.as_markup() + + +def get_superuser_confirm_keyboard(user_id: int, is_superuser: bool) -> InlineKeyboardMarkup: + """ + Клавиатура для подтверждения назначения/снятия суперпользователя + + Args: + user_id: ID пользователя + is_superuser: Текущий статус суперпользователя + + Returns: + Inline клавиатура с кнопками подтверждения + """ + builder = InlineKeyboardBuilder() + + if is_superuser: + # Если уже суперпользователь, предлагаем снять права + builder.add( + InlineKeyboardButton( + text="❌ Снять права суперпользователя", + callback_data=f"remove_superuser_{user_id}" + ) + ) + else: + # Если не суперпользователь, предлагаем назначить + builder.add( + InlineKeyboardButton( + text="✅ Назначить суперпользователем", + callback_data=f"confirm_superuser_{user_id}" + ) + ) + + builder.add( + InlineKeyboardButton( + text="🔙 Назад к списку", + callback_data="admin_assign_superuser" + ) + ) + + return builder.as_markup() + + +def get_ban_user_keyboard(users: List, page: int = 0, per_page: int = 10) -> InlineKeyboardMarkup: + """ + Клавиатура для выбора пользователя для бана + + Args: + users: Список пользователей + page: Номер страницы + per_page: Количество пользователей на странице + + Returns: + Inline клавиатура с пользователями для бана + """ + builder = InlineKeyboardBuilder() + + # Вычисляем диапазон пользователей для текущей страницы + start_idx = page * per_page + end_idx = min(start_idx + per_page, len(users)) + page_users = users[start_idx:end_idx] + + # Добавляем кнопки для пользователей + for user in page_users: + # Определяем статус пользователя + status_emoji = "🚫" if user.is_banned else "👤" + button_text = f"{status_emoji} {user.display_name}" + + # Обрезаем текст если слишком длинный + if len(button_text) > 30: + button_text = button_text[:27] + "..." + + builder.add( + InlineKeyboardButton( + text=button_text, + callback_data=f"ban_user_select_{user.telegram_id}" + ) + ) + + # Настраиваем расположение кнопок: 1 кнопка в ряд для лучшей читаемости + builder.adjust(1) + + # Добавляем управляющие кнопки + control_buttons = [] + + # Кнопка "Предыдущая" (если есть предыдущие страницы) + if page > 0: + control_buttons.append( + InlineKeyboardButton( + text="⬅️ Предыдущая", + callback_data=f"ban_user_page_{page - 1}" + ) + ) + + # Кнопка "Следующая" (если есть следующие страницы) + total_pages = (len(users) + per_page - 1) // per_page + if page < total_pages - 1: + control_buttons.append( + InlineKeyboardButton( + text="Следующая ➡️", + callback_data=f"ban_user_page_{page + 1}" + ) + ) + + # Добавляем управляющие кнопки + if control_buttons: + builder.row(*control_buttons) + + # Кнопка "Назад" + builder.add( + InlineKeyboardButton( + text="🔙 Назад к админ панели", + callback_data="back_to_admin" + ) + ) + + return builder.as_markup() + + +def get_ban_duration_keyboard(user_id: int) -> InlineKeyboardMarkup: + """ + Клавиатура для выбора срока бана + + Args: + user_id: ID пользователя для бана + + Returns: + Inline клавиатура с вариантами срока бана + """ + builder = InlineKeyboardBuilder() + + # Варианты срока бана + ban_options = [ + ("1 час", "ban_1h"), + ("1 день", "ban_1d"), + ("3 дня", "ban_3d"), + ("1 неделя", "ban_1w"), + ("1 месяц", "ban_1m"), + ("Навсегда", "ban_forever") + ] + + for text, callback_data in ban_options: + builder.add( + InlineKeyboardButton( + text=text, + callback_data=f"{callback_data}_{user_id}" + ) + ) + + builder.adjust(2) # По две кнопки в ряд + + # Кнопка "Назад" + builder.add( + InlineKeyboardButton( + text="🔙 Назад к списку", + callback_data="admin_ban_user" + ) + ) + + return builder.as_markup() + + +def get_ban_confirm_keyboard(user_id: int, duration: str, reason: str = None) -> InlineKeyboardMarkup: + """ + Клавиатура для подтверждения бана пользователя + + Args: + user_id: ID пользователя + duration: Срок бана + reason: Причина бана + + Returns: + Inline клавиатура с кнопками подтверждения + """ + builder = InlineKeyboardBuilder() + + builder.add( + InlineKeyboardButton( + text="✅ Подтвердить бан", + callback_data=f"confirm_ban_{user_id}_{duration}" + ) + ) + builder.add( + InlineKeyboardButton( + text="❌ Отменить", + callback_data=f"ban_user_select_{user_id}" + ) + ) + + builder.adjust(1) + return builder.as_markup() + + +def get_unban_keyboard(user_id: int) -> InlineKeyboardMarkup: + """ + Клавиатура для разбана пользователя + + Args: + user_id: ID пользователя + + Returns: + Inline клавиатура с кнопкой разбана + """ + builder = InlineKeyboardBuilder() + + builder.add( + InlineKeyboardButton( + text="✅ Разбанить пользователя", + callback_data=f"unban_user_{user_id}" + ) + ) + builder.add( + InlineKeyboardButton( + text="🔙 Назад к списку", + callback_data="admin_ban_user" + ) + ) + + builder.adjust(1) + return builder.as_markup() + + +def get_stats_keyboard() -> InlineKeyboardMarkup: + """ + Клавиатура для статистики + + Returns: + Inline клавиатура + """ + builder = InlineKeyboardBuilder() + + builder.add( + InlineKeyboardButton( + text="📈 Общая статистика", + callback_data="stats_general" + ) + ) + builder.add( + InlineKeyboardButton( + text="⬅️ Назад", + callback_data="back_to_admin" + ) + ) + + builder.adjust(2) + return builder.as_markup() + + +def get_user_questions_keyboard(questions: list, page: int = 0, per_page: int = 9, total_questions: int = None) -> InlineKeyboardMarkup: + """ + Клавиатура со списком вопросов пользователя + + Args: + questions: Список вопросов для текущей страницы + page: Номер страницы + per_page: Количество вопросов на странице + total_questions: Общее количество вопросов (для расчета пагинации) + + Returns: + Inline клавиатура + """ + builder = InlineKeyboardBuilder() + + # Если не передано общее количество вопросов, используем длину списка + if total_questions is None: + total_questions = len(questions) + + # Вычисляем общее количество страниц + total_pages = (total_questions + per_page - 1) // per_page if total_questions > 0 else 1 + + # Добавляем кнопки для вопросов + for i, question_data in enumerate(questions): + # Проверяем, является ли элемент кортежем (question, author_user) или просто question + if isinstance(question_data, tuple): + question, author_user = question_data + else: + question = question_data + + status_emoji = { + 'pending': '⏳', + 'answered': '✅', + 'rejected': '❌', + 'deleted': '🗑️' + } + + emoji = status_emoji.get(question.status.value, '❓') + # Используем user_question_number для отображения + display_number = question.user_question_number if question.user_question_number is not None else (page * per_page + i + 1) + text = f"{emoji} Вопрос #{display_number}" + + # Обрезаем текст если слишком длинный + if len(text) > 30: + text = text[:27] + "..." + + builder.add( + InlineKeyboardButton( + text=text, + callback_data=f"view_question_{question.id}" + ) + ) + + # Настраиваем расположение кнопок: 3 кнопки в ряд для вопросов + builder.adjust(3) + + # Добавляем управляющие кнопки только если есть больше одной страницы + if total_pages > 1: + control_buttons = [] + + # Кнопка "Предыдущая" (только если не первая страница) + if page > 0: + control_buttons.append( + InlineKeyboardButton( + text="⬅️ Предыдущая", + callback_data=f"questions_page_{page - 1}" + ) + ) + + # Кнопка "В меню" + control_buttons.append( + InlineKeyboardButton( + text="🏠 В меню", + callback_data="back_to_main" + ) + ) + + # Кнопка "Следующая" (только если не последняя страница) + if page < total_pages - 1: + control_buttons.append( + InlineKeyboardButton( + text="Следующая ➡️", + callback_data=f"questions_page_{page + 1}" + ) + ) + + # Добавляем управляющие кнопки в отдельный ряд + builder.row(*control_buttons) + else: + # Если только одна страница, добавляем только кнопку "В меню" + builder.row( + InlineKeyboardButton( + text="🏠 В меню", + callback_data="back_to_main" + ) + ) + + return builder.as_markup() + + +def get_question_view_keyboard(question: Question) -> InlineKeyboardMarkup: + """ + Клавиатура для просмотра конкретного вопроса + + Args: + question: Объект вопроса + + Returns: + Inline клавиатура + """ + builder = InlineKeyboardBuilder() + + if question.status.value == 'pending': + # Если вопрос ожидает ответа + builder.add( + InlineKeyboardButton( + text="💬 Ответить", + callback_data=f"answer_{question.id}" + ) + ) + builder.add( + InlineKeyboardButton( + text="❌ Отклонить", + callback_data=f"reject_{question.id}" + ) + ) + elif question.status.value == 'answered': + # Если вопрос уже отвечен + builder.add( + InlineKeyboardButton( + text="✏️ Редактировать ответ", + callback_data=f"edit_answer_{question.id}" + ) + ) + + # Общие действия + builder.add( + InlineKeyboardButton( + text="🗑️ Удалить", + callback_data=f"delete_{question.id}" + ) + ) + builder.add( + InlineKeyboardButton( + text="⬅️ К списку вопросов", + callback_data="back_to_questions" + ) + ) + + builder.adjust(1) + return builder.as_markup() + + +def get_rate_limit_keyboard() -> InlineKeyboardMarkup: + """ + Клавиатура для управления rate limiting + + Returns: + Inline клавиатура с кнопками rate limiting + """ + builder = InlineKeyboardBuilder() + + builder.add( + InlineKeyboardButton( + text="📊 Статистика Rate Limiting", + callback_data="rate_limit_stats" + ) + ) + builder.add( + InlineKeyboardButton( + text="🔄 Сбросить статистику", + callback_data="rate_limit_reset_stats" + ) + ) + builder.add( + InlineKeyboardButton( + text="⚙️ Адаптировать конфигурацию", + callback_data="rate_limit_adapt" + ) + ) + builder.add( + InlineKeyboardButton( + text="⬅️ Назад к админке", + callback_data="back_to_admin" + ) + ) + + builder.adjust(1) + return builder.as_markup() + + diff --git a/keyboards/reply.py b/keyboards/reply.py new file mode 100644 index 0000000..dc31776 --- /dev/null +++ b/keyboards/reply.py @@ -0,0 +1,118 @@ +""" +Reply клавиатуры для бота +""" +from aiogram.types import ReplyKeyboardMarkup, KeyboardButton +from aiogram.utils.keyboard import ReplyKeyboardBuilder +from services.auth.auth_new import AuthService +from dependencies import get_auth + + +def get_main_keyboard(user_id: int = None, auth: AuthService = None) -> ReplyKeyboardMarkup: + """ + Основная клавиатура бота + + Args: + user_id: ID пользователя для проверки роли + auth: Сервис авторизации для проверки роли + + Returns: + Reply клавиатура + """ + builder = ReplyKeyboardBuilder() + + builder.add( + KeyboardButton(text="📋 Мои вопросы"), + KeyboardButton(text="🔗 Моя ссылка") + ) + builder.add( + KeyboardButton(text="🚫 Заблокированные"), + KeyboardButton(text="ℹ️ Помощь") + ) + + # Добавляем кнопку статистики только для администраторов + if user_id and auth and auth.is_admin(user_id): + builder.add(KeyboardButton(text="📊 Статистика")) + builder.adjust(2, 2, 1) # 2 кнопки в первом ряду, 2 во втором, 1 в третьем + else: + builder.adjust(2, 2) # 2 кнопки в первом ряду, 2 во втором + + return builder.as_markup( + resize_keyboard=True, + one_time_keyboard=False, + input_field_placeholder="Выберите действие или отправьте вопрос..." + ) + + +def get_main_keyboard_for_user(user_id: int, auth: AuthService = None) -> ReplyKeyboardMarkup: + """ + Основная клавиатура бота для конкретного пользователя + + Args: + user_id: ID пользователя для проверки роли + auth: Сервис авторизации для проверки роли (опционально, если не передан, будет получен через DI) + + Returns: + Reply клавиатура + """ + if auth is None: + auth = get_auth() + return get_main_keyboard(user_id, auth) + + +def get_admin_reply_keyboard() -> ReplyKeyboardMarkup: + """ + Reply клавиатура для администраторов + + Returns: + Reply клавиатура для админов + """ + builder = ReplyKeyboardBuilder() + + builder.add( + KeyboardButton(text="📋 Мои вопросы"), + KeyboardButton(text="🔗 Моя ссылка") + ) + builder.add( + KeyboardButton(text="🚫 Заблокированные"), + KeyboardButton(text="ℹ️ Помощь") + ) + builder.add( + KeyboardButton(text="📊 Статистика"), + KeyboardButton(text="⚙️ Админ панель") + ) + + builder.adjust(2, 2, 2) # 2 кнопки в каждом ряду + + return builder.as_markup( + resize_keyboard=True, + one_time_keyboard=False, + input_field_placeholder="Выберите действие или отправьте вопрос..." + ) + + + + + + +def get_cancel_keyboard() -> ReplyKeyboardMarkup: + """ + Клавиатура с кнопкой отмены + + Returns: + Reply клавиатура + """ + builder = ReplyKeyboardBuilder() + + builder.add( + KeyboardButton(text="❌ Отмена") + ) + + return builder.as_markup( + resize_keyboard=True, + one_time_keyboard=True, + input_field_placeholder="Отправьте текст или нажмите 'Отмена'" + ) + + + + diff --git a/loader.py b/loader.py new file mode 100644 index 0000000..cf32e4a --- /dev/null +++ b/loader.py @@ -0,0 +1,174 @@ +""" +Инициализация бота, диспетчера и базы данных +""" +import asyncio + +from aiogram import Bot, Dispatcher +from aiogram.enums import ParseMode +from aiogram.fsm.storage.memory import MemoryStorage + +from config import config +from config.constants import ALLOWED_UPDATES +from dependencies import DependencyMiddleware, get_dependencies +from handlers import admin, answers, errors, questions, start +from middlewares.rate_limit_middleware import RateLimitMiddleware +from middlewares.validation_middleware import ValidationMiddleware +from services.infrastructure.database import DatabaseService +from services.infrastructure.logger import get_logger + +# Настройка логирования +logger = get_logger(__name__) + + +class BotLoader: + """Класс для инициализации и запуска бота""" + + def __init__(self): + self.bot: Bot = None + self.dp: Dispatcher = None + self.db: DatabaseService = None + + async def init_bot(self) -> Bot: + """Инициализация бота""" + logger.info("🤖 Инициализация Telegram бота") + self.bot = Bot(token=config.BOT_TOKEN) + # Устанавливаем parse_mode по умолчанию для aiogram 3.3.0 + self.bot.parse_mode = ParseMode.HTML + logger.info("✅ Бот успешно инициализирован") + return self.bot + + async def init_dispatcher(self) -> Dispatcher: + """Инициализация диспетчера""" + logger.info("📡 Инициализация диспетчера") + # Используем MemoryStorage для FSM + storage = MemoryStorage() + self.dp = Dispatcher(storage=storage) + + # Инициализируем зависимости + logger.info("🏗️ Инициализация системы инъекции зависимостей") + deps = get_dependencies() + await deps.init() + + # Добавляем middleware для инъекции зависимостей + self.dp.update.middleware(DependencyMiddleware(deps)) + logger.info("✅ DependencyMiddleware зарегистрирован") + + # Добавляем middleware для rate limiting + self.dp.update.middleware(RateLimitMiddleware()) + logger.info("✅ RateLimitMiddleware зарегистрирован") + + # Добавляем middleware для валидации + validation_middleware = ValidationMiddleware(deps.validator) + self.dp.update.middleware(validation_middleware) + logger.info("✅ ValidationMiddleware зарегистрирован") + + # Регистрируем обработчики + self._register_handlers() + logger.info("✅ Диспетчер успешно инициализирован") + + return self.dp + + async def init_database(self) -> DatabaseService: + """Инициализация базы данных""" + logger.info(f"💾 Инициализация базы данных: {config.DATABASE_PATH}") + self.db = DatabaseService(config.DATABASE_PATH) + await self.db.init() + logger.info("✅ База данных успешно инициализирована") + return self.db + + def _register_handlers(self): + """Регистрация всех обработчиков""" + logger.info("🔧 Регистрация обработчиков") + # Обработчики команд + self.dp.include_router(start.router) + self.dp.include_router(questions.router) + self.dp.include_router(answers.router) + self.dp.include_router(admin.router) + + # Обработчик ошибок (должен быть последним) + self.dp.include_router(errors.router) + logger.info("✅ Все обработчики зарегистрированы") + + async def start_polling(self): + """Запуск бота в режиме polling""" + try: + logger.info("🚀 Запуск бота в режиме polling") + + # Инициализируем компоненты + await self.init_bot() + await self.init_dispatcher() + await self.init_database() + + # Уведомляем администраторов о запуске + await self._notify_admins_startup() + + # Запускаем polling + logger.info("🔄 Начинаем polling...") + await self.dp.start_polling( + self.bot, + allowed_updates=ALLOWED_UPDATES + ) + + except Exception as e: + logger.error(f"💥 Ошибка при запуске бота: {e}") + raise + finally: + await self.cleanup() + + async def _notify_admins_startup(self): + """Уведомление администраторов о запуске бота""" + if not config.ADMINS: + logger.warning("⚠️ Список администраторов пуст") + return + + logger.info(f"📢 Уведомление {len(config.ADMINS)} администраторов о запуске") + message = "🤖 Бот запущен!\n\n" \ + "Анонимный бот для вопросов готов к работе." + + for admin_id in config.ADMINS: + try: + await self.bot.send_message(admin_id, message) + logger.info(f"✅ Уведомление отправлено админу {admin_id}") + except Exception as e: + logger.warning(f"⚠️ Не удалось отправить уведомление админу {admin_id}: {e}") + + async def cleanup(self): + """Очистка ресурсов при завершении""" + logger.info("🧹 Очистка ресурсов") + + # Закрываем зависимости + try: + deps = get_dependencies() + await deps.close() + logger.info("✅ Зависимости закрыты") + except Exception as e: + logger.warning(f"⚠️ Ошибка при закрытии зависимостей: {e}") + + if self.bot: + await self.bot.session.close() + logger.info("✅ Сессия бота закрыта") + + if self.db: + await self.db.close() + logger.info("✅ Соединение с БД закрыто") + + logger.info("🛑 Бот остановлен") + + +# Создаем глобальный экземпляр загрузчика +loader = BotLoader() + + +async def main(): + """Главная функция для запуска бота""" + await loader.start_polling() + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + logger.info("Получен сигнал остановки") + except Exception as e: + logger.error(f"Критическая ошибка: {e}") + raise diff --git a/main.py b/main.py index 82ac3f0..d724cbd 100644 --- a/main.py +++ b/main.py @@ -1,2 +1,9 @@ -if __name__ == '__main__': - print("Hello World") \ No newline at end of file +""" +Точка входа для запуска бота анонимных вопросов +""" +import asyncio + +from bot import main + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/middlewares/__init__.py b/middlewares/__init__.py new file mode 100644 index 0000000..8199a12 --- /dev/null +++ b/middlewares/__init__.py @@ -0,0 +1,8 @@ +""" +Middleware для бота +""" + +from .rate_limit_middleware import RateLimitMiddleware +from .validation_middleware import ValidationMiddleware, ValidationError + +__all__ = ['RateLimitMiddleware', 'ValidationMiddleware', 'ValidationError'] diff --git a/middlewares/rate_limit_middleware.py b/middlewares/rate_limit_middleware.py new file mode 100644 index 0000000..ac68ed4 --- /dev/null +++ b/middlewares/rate_limit_middleware.py @@ -0,0 +1,62 @@ +""" +Middleware для автоматического применения rate limiting ко всем входящим сообщениям +""" +from typing import Callable, Dict, Any, Awaitable, Union +from aiogram import BaseMiddleware +from aiogram.types import Message, CallbackQuery, InlineQuery, ChatMemberUpdated, Update +from aiogram.exceptions import TelegramRetryAfter, TelegramAPIError + +from services.infrastructure.logger import get_logger +from services.rate_limiting.rate_limiter import telegram_rate_limiter +from services.infrastructure.logging_decorators import log_middleware +from services.infrastructure.logging_utils import log_user_action + +logger = get_logger(__name__) + + +class RateLimitMiddleware(BaseMiddleware): + """Middleware для автоматического rate limiting входящих сообщений""" + + def __init__(self): + super().__init__() + self.rate_limiter = telegram_rate_limiter + + @log_middleware(log_params=True, log_result=False) + async def __call__( + self, + handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]], + event: Union[Update, Message, CallbackQuery, InlineQuery, ChatMemberUpdated], + data: Dict[str, Any] + ) -> Any: + """Обрабатывает событие с rate limiting""" + + # Извлекаем сообщение из Update + message = None + if isinstance(event, Update): + message = event.message + elif isinstance(event, Message): + message = event + + # Применяем rate limiting только к сообщениям + if message is not None: + chat_id = message.chat.id + + # Обертываем handler в rate limiting + async def rate_limited_handler(): + try: + return await handler(event, data) + except (TelegramRetryAfter, TelegramAPIError) as e: + logger.warning(f"Rate limit error in middleware: {e}") + # Middleware не должен перехватывать эти ошибки, + # пусть их обрабатывает rate_limiter в функциях отправки + raise + + # Применяем rate limiting к handler + result, wait_time = await self.rate_limiter.execute_with_rate_limit( + rate_limited_handler, + chat_id + ) + return result + else: + # Для других типов событий просто вызываем handler + return await handler(event, data) diff --git a/middlewares/validation_middleware.py b/middlewares/validation_middleware.py new file mode 100644 index 0000000..199c6cb --- /dev/null +++ b/middlewares/validation_middleware.py @@ -0,0 +1,133 @@ +""" +Middleware для валидации входных данных +""" +from typing import Any, Dict, Callable, Awaitable + +from aiogram import BaseMiddleware +from aiogram.types import TelegramObject, CallbackQuery, Message + +from services.infrastructure.logger import get_logger +from services.validation import InputValidator +from services.infrastructure.logging_decorators import log_middleware +from services.infrastructure.logging_utils import log_user_action + +logger = get_logger(__name__) + + +class ValidationMiddleware(BaseMiddleware): + """Middleware для валидации входных данных""" + + def __init__(self, validator: InputValidator): + super().__init__() + self.validator = validator + logger.info("🔍 ValidationMiddleware инициализирован") + + @log_middleware(log_params=True, log_result=False) + async def __call__( + self, + handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: Dict[str, Any] + ) -> Any: + """Валидация входных данных перед обработкой""" + + try: + # Валидация callback queries + if isinstance(event, CallbackQuery): + await self._validate_callback_query(event, data) + + # Валидация сообщений + elif isinstance(event, Message): + await self._validate_message(event, data) + + # Продолжаем обработку + return await handler(event, data) + + except ValidationError as e: + logger.warning(f"⚠️ Ошибка валидации: {e}") + await self._handle_validation_error(event, str(e)) + return + + @log_middleware(log_params=True, log_result=False) + async def _validate_callback_query(self, callback: CallbackQuery, data: Dict[str, Any]) -> None: + """Валидация callback query""" + try: + # Валидируем callback data + validation_result = self.validator.validate_callback_data(callback.data) + if not validation_result: + logger.warning(f"⚠️ Невалидный callback data от пользователя {callback.from_user.id}: {callback.data}") + await callback.answer("❌ Неверные данные", show_alert=True) + raise ValidationError(f"Invalid callback data: {validation_result.error_message}") + + # Валидируем Telegram ID пользователя + user_id_validation = self.validator.validate_telegram_id(callback.from_user.id) + if not user_id_validation: + logger.warning(f"⚠️ Невалидный Telegram ID в callback: {callback.from_user.id}") + await callback.answer("❌ Ошибка: недопустимый ID пользователя", show_alert=True) + raise ValidationError(f"Invalid Telegram ID: {user_id_validation.error_message}") + + # Валидируем username если есть + if callback.from_user.username: + username_validation = self.validator.validate_username(callback.from_user.username) + if not username_validation: + logger.warning(f"⚠️ Невалидный username в callback: {callback.from_user.username}") + # Username не критичен, только логируем + + logger.debug(f"✅ Callback query от пользователя {callback.from_user.id} прошел валидацию") + + except Exception as e: + if not isinstance(e, ValidationError): + logger.error(f"❌ Ошибка валидации callback query: {e}") + await callback.answer("❌ Ошибка валидации", show_alert=True) + raise ValidationError(f"Callback validation error: {str(e)}") + raise + + @log_middleware(log_params=True, log_result=False) + async def _validate_message(self, message: Message, data: Dict[str, Any]) -> None: + """Валидация сообщения""" + try: + # Валидируем Telegram ID пользователя + user_id_validation = self.validator.validate_telegram_id(message.from_user.id) + if not user_id_validation: + logger.warning(f"⚠️ Невалидный Telegram ID в сообщении: {message.from_user.id}") + await message.answer("❌ Ошибка: недопустимый ID пользователя") + raise ValidationError(f"Invalid Telegram ID: {user_id_validation.error_message}") + + # Валидируем username если есть + if message.from_user.username: + username_validation = self.validator.validate_username(message.from_user.username) + if not username_validation: + logger.warning(f"⚠️ Невалидный username в сообщении: {message.from_user.username}") + # Username не критичен, только логируем + + # Валидируем chat ID + chat_id_validation = self.validator.validate_telegram_id(message.chat.id) + if not chat_id_validation: + logger.warning(f"⚠️ Невалидный chat ID в сообщении: {message.chat.id}") + await message.answer("❌ Ошибка: недопустимый ID чата") + raise ValidationError(f"Invalid chat ID: {chat_id_validation.error_message}") + + logger.debug(f"✅ Сообщение от пользователя {message.from_user.id} прошло валидацию") + + except Exception as e: + if not isinstance(e, ValidationError): + logger.error(f"❌ Ошибка валидации сообщения: {e}") + await message.answer("❌ Ошибка валидации") + raise ValidationError(f"Message validation error: {str(e)}") + raise + + @log_middleware(log_params=True, log_result=False) + async def _handle_validation_error(self, event: TelegramObject, error_message: str) -> None: + """Обработка ошибок валидации""" + try: + if isinstance(event, CallbackQuery): + await event.answer(f"❌ {error_message}", show_alert=True) + elif isinstance(event, Message): + await event.answer(f"❌ {error_message}") + except Exception as e: + logger.error(f"❌ Ошибка при отправке сообщения об ошибке валидации: {e}") + + +class ValidationError(Exception): + """Исключение для ошибок валидации""" + pass diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..5d66744 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,10 @@ +""" +Модели данных для бота анонимных вопросов +""" + +from .user import User +from .question import Question, QuestionStatus +from .user_block import UserBlock +from .user_settings import UserSettings + +__all__ = ['User', 'Question', 'QuestionStatus', 'UserBlock', 'UserSettings'] diff --git a/models/question.py b/models/question.py new file mode 100644 index 0000000..f256088 --- /dev/null +++ b/models/question.py @@ -0,0 +1,118 @@ +""" +Модель вопроса +""" +import asyncio +from dataclasses import dataclass +from datetime import datetime +from enum import Enum +from typing import Optional + +from config.constants import DEFAULT_QUESTION_PREVIEW_LENGTH, EMPTY_VALUES + + +class QuestionStatus(Enum): + """Статусы вопроса""" + PENDING = "pending" # Ожидает ответа + ANSWERED = "answered" # Отвечен + REJECTED = "rejected" # Отклонен + DELETED = "deleted" # Удален + + +@dataclass +class Question: + """Модель вопроса""" + + id: Optional[int] = None + from_user_id: Optional[int] = None # ID отправителя (может быть None для анонимных) + to_user_id: int = None # ID получателя + message_text: str = "" # Текст вопроса + answer_text: Optional[str] = None # Текст ответа + is_anonymous: bool = True # Анонимный ли вопрос + message_id: Optional[int] = None # ID сообщения в Telegram + created_at: Optional[datetime] = None + answered_at: Optional[datetime] = None + is_read: bool = False # Прочитан ли вопрос + status: QuestionStatus = QuestionStatus.PENDING + user_question_number: Optional[int] = None # Локальный номер вопроса для пользователя + + # Lazy loading атрибуты + _from_user: Optional['User'] = None + _to_user: Optional['User'] = None + _user_loader: Optional[callable] = None + + @property + def is_answered(self) -> bool: + """Проверка, отвечен ли вопрос""" + return self.status == QuestionStatus.ANSWERED + + @property + def is_pending(self) -> bool: + """Проверка, ожидает ли вопрос ответа""" + return self.status == QuestionStatus.PENDING + + + @classmethod + def _parse_datetime(cls, date_str) -> Optional[datetime]: + """Безопасный парсинг datetime из строки""" + if not date_str or date_str in EMPTY_VALUES: + return None + try: + return datetime.fromisoformat(date_str) + except (ValueError, TypeError): + return None + + @classmethod + async def _parse_datetime_async(cls, date_str) -> Optional[datetime]: + """Асинхронный безопасный парсинг datetime из строки""" + if not date_str or date_str in EMPTY_VALUES: + return None + try: + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, datetime.fromisoformat, date_str) + except (ValueError, TypeError): + return None + + + def mark_as_answered(self, answer_text: str): + """Отметить вопрос как отвеченный""" + self.answer_text = answer_text + self.status = QuestionStatus.ANSWERED + self.answered_at = datetime.now() + + def mark_as_rejected(self): + """Отметить вопрос как отклоненный""" + self.status = QuestionStatus.REJECTED + self.answered_at = datetime.now() + + def mark_as_deleted(self): + """Отметить вопрос как удаленный""" + self.status = QuestionStatus.DELETED + self.answered_at = datetime.now() + self.user_question_number = None # Удаленные вопросы не имеют номера + + def set_user_loader(self, loader_func: callable): + """Установка функции для загрузки пользователей""" + self._user_loader = loader_func + + async def get_from_user(self) -> Optional['User']: + """Lazy loading автора вопроса""" + if self._from_user is None and self.from_user_id and self._user_loader: + self._from_user = await self._user_loader(self.from_user_id) + return self._from_user + + async def get_to_user(self) -> Optional['User']: + """Lazy loading получателя вопроса""" + if self._to_user is None and self.to_user_id and self._user_loader: + self._to_user = await self._user_loader(self.to_user_id) + return self._to_user + + def get_question_preview(self, max_length: int = DEFAULT_QUESTION_PREVIEW_LENGTH) -> str: + """Получить превью вопроса""" + if len(self.message_text) <= max_length: + return self.message_text + return self.message_text[:max_length] + "..." + + def get_display_number(self) -> int: + """Получить номер вопроса для отображения (приоритет user_question_number)""" + return self.user_question_number if self.user_question_number is not None else self.id + diff --git a/models/user.py b/models/user.py new file mode 100644 index 0000000..28aa07a --- /dev/null +++ b/models/user.py @@ -0,0 +1,92 @@ +""" +Модель пользователя +""" +import asyncio +from dataclasses import dataclass +from datetime import datetime +from typing import Optional + +from config.constants import EMPTY_VALUES + + +def escape_html(text: str) -> str: + """Экранирование HTML символов""" + if not text: + return "" + return (text + .replace('&', '&') + .replace('<', '<') + .replace('>', '>') + .replace('"', '"') + .replace("'", ''')) + + +@dataclass +class User: + """Модель пользователя бота""" + + id: Optional[int] = None + telegram_id: int = None + username: Optional[str] = None + first_name: str = "" + last_name: Optional[str] = None + chat_id: int = None + profile_link: str = "" + is_active: bool = True + is_superuser: bool = False + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + banned_until: Optional[datetime] = None + ban_reason: Optional[str] = None + + @property + def full_name(self) -> str: + """Полное имя пользователя""" + parts = [] + if self.first_name: + parts.append(escape_html(self.first_name)) + if self.last_name: + parts.append(escape_html(self.last_name)) + return ' '.join(parts) if parts else 'Неизвестно' + + @property + def display_name(self) -> str: + """Отображаемое имя пользователя""" + if self.username: + return f"@{escape_html(self.username)}" + return escape_html(self.full_name) + + @property + def is_banned(self) -> bool: + """Проверка, забанен ли пользователь""" + if not self.banned_until: + return False + return datetime.now() < self.banned_until + + + @classmethod + def _parse_datetime(cls, date_str) -> Optional[datetime]: + """Безопасный парсинг datetime из строки""" + if not date_str or date_str in EMPTY_VALUES: + return None + try: + return datetime.fromisoformat(date_str) + except (ValueError, TypeError): + return None + + @classmethod + async def _parse_datetime_async(cls, date_str) -> Optional[datetime]: + """Асинхронный безопасный парсинг datetime из строки""" + if not date_str or date_str in EMPTY_VALUES: + return None + try: + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, datetime.fromisoformat, date_str) + except (ValueError, TypeError): + return None + + + def update_timestamp(self): + """Обновление времени последнего обновления""" + self.updated_at = datetime.now() + diff --git a/models/user_block.py b/models/user_block.py new file mode 100644 index 0000000..dcad7af --- /dev/null +++ b/models/user_block.py @@ -0,0 +1,18 @@ +""" +Модель блокировки пользователя +""" +from datetime import datetime +from typing import Optional +from dataclasses import dataclass + + +@dataclass +class UserBlock: + """Модель блокировки пользователя""" + + id: Optional[int] = None + blocker_id: int = None # ID пользователя, который заблокировал + blocked_id: int = None # ID заблокированного пользователя + created_at: Optional[datetime] = None + + diff --git a/models/user_settings.py b/models/user_settings.py new file mode 100644 index 0000000..c3e17a5 --- /dev/null +++ b/models/user_settings.py @@ -0,0 +1,37 @@ +""" +Модель настроек пользователя +""" +from datetime import datetime +from typing import Optional +from dataclasses import dataclass + + +@dataclass +class UserSettings: + """Модель настроек пользователя""" + + id: Optional[int] = None + user_id: int = None + allow_questions: bool = True # Разрешить вопросы + notify_new_questions: bool = True # Уведомления о новых вопросах + notify_answers: bool = True # Уведомления об ответах + language: str = 'ru' # Язык интерфейса + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + + @classmethod + def _parse_datetime(cls, date_str) -> Optional[datetime]: + """Безопасный парсинг datetime из строки""" + if not date_str or date_str in ['0', '']: + return None + try: + return datetime.fromisoformat(date_str) + except (ValueError, TypeError): + return None + + + def update_timestamp(self): + """Обновление времени последнего обновления""" + self.updated_at = datetime.now() + diff --git a/prometheus.yml b/prometheus.yml new file mode 100644 index 0000000..a9ac738 --- /dev/null +++ b/prometheus.yml @@ -0,0 +1,22 @@ +# Конфигурация Prometheus для AnonBot +global: + scrape_interval: 15s + evaluation_interval: 15s + +rule_files: + # - "first_rules.yml" + # - "second_rules.yml" + +scrape_configs: + # AnonBot метрики + - job_name: 'anon-bot' + static_configs: + - targets: ['localhost:8081'] + metrics_path: '/metrics' + scrape_interval: 30s + scrape_timeout: 10s + + # Prometheus сам по себе + - job_name: 'prometheus' + static_configs: + - targets: ['localhost:9090'] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..23899a4 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,29 @@ +[tool:pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = + -v + --tb=short + --strict-markers + --disable-warnings + --cov=. + --cov-report=html + --cov-report=term-missing + --cov-fail-under=80 +markers = + unit: Unit tests + integration: Integration tests + slow: Slow tests + database: Database tests + bot: Bot tests + auth: Authentication tests + validation: Validation tests + crud: CRUD tests + middleware: Middleware tests + services: Services tests + handlers: Handlers tests + models: Models tests + config: Configuration tests +asyncio_mode = auto diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..33e89c0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,28 @@ +# Основные зависимости для Telegram бота +aiogram==3.3.0 +aiohttp==3.9.1 +aiosqlite==0.19.0 + +# Для работы с переменными окружения +python-dotenv==1.0.0 + +# Для работы с датами +python-dateutil==2.8.2 + +# Для валидации данных +pydantic==2.5.2 + +# Для логирования +loguru==0.7.2 + +# Для работы с JSON +orjson==3.9.10 + +# Дополнительные утилиты +cryptography>=42.0.0 + +# Для Prometheus метрик +prometheus-client==0.19.0 + +# Для работы с процессами и системной информацией +psutil==5.9.8 diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..05c12e9 --- /dev/null +++ b/services/__init__.py @@ -0,0 +1,9 @@ +""" +Сервисы для работы с данными и утилитами +""" + +# Импорты для обратной совместимости +from .infrastructure.database import DatabaseService +from .utils import generate_referral_link, format_question_info + +__all__ = ['DatabaseService', 'generate_referral_link', 'format_question_info'] diff --git a/services/auth/__init__.py b/services/auth/__init__.py new file mode 100644 index 0000000..719a5d2 --- /dev/null +++ b/services/auth/__init__.py @@ -0,0 +1,7 @@ +""" +Модуль авторизации и разрешений +""" + +from .auth_new import AuthService + +__all__ = ['AuthService'] diff --git a/services/auth/auth_new.py b/services/auth/auth_new.py new file mode 100644 index 0000000..7e95968 --- /dev/null +++ b/services/auth/auth_new.py @@ -0,0 +1,146 @@ +""" +Новый сервис авторизации с использованием системы разрешений +Соблюдает принцип открытости/закрытости (OCP) +""" +from typing import Optional +from services.infrastructure.database import DatabaseService +from services.permissions import get_permission_checker +from services.infrastructure.logger import get_logger + +logger = get_logger(__name__) + + +class AuthService: + """ + Сервис авторизации, использующий систему разрешений. + Соблюдает принцип открытости/закрытости (OCP). + """ + + def __init__(self, database: DatabaseService, config_provider): + self.database = database + self.config = config_provider + + def is_admin(self, user_id: int) -> bool: + """ + Проверка, является ли пользователь администратором + + Args: + user_id: ID пользователя в Telegram + + Returns: + True если пользователь администратор, False иначе + """ + return user_id in self.config.ADMINS + + async def is_superuser(self, user_id: int) -> bool: + """ + Проверка, является ли пользователь суперпользователем + + Args: + user_id: ID пользователя в Telegram + + Returns: + True если пользователь суперпользователь, False иначе + """ + try: + user = await self.database.get_user(user_id) + return user.is_superuser if user else False + except Exception: + return False + + async def get_user_role(self, user_id: int) -> str: + """ + Получение роли пользователя + + Args: + user_id: ID пользователя в Telegram + + Returns: + Роль пользователя: 'admin', 'superuser' или 'user' + """ + if self.is_admin(user_id): + return 'admin' + elif await self.is_superuser(user_id): + return 'superuser' + else: + return 'user' + + async def has_permission(self, user_id: int, permission: str) -> bool: + """ + Проверка наличия разрешения у пользователя через систему разрешений + + Args: + user_id: ID пользователя в Telegram + permission: Название разрешения + + Returns: + True если у пользователя есть разрешение, False иначе + """ + checker = get_permission_checker() + if not checker: + logger.error("Проверщик разрешений не инициализирован") + return False + + return await checker.has_permission(user_id, permission) + + async def has_any_permission(self, user_id: int, permissions: list[str]) -> bool: + """ + Проверка наличия хотя бы одного из разрешений у пользователя + + Args: + user_id: ID пользователя в Telegram + permissions: Список названий разрешений + + Returns: + True если у пользователя есть хотя бы одно разрешение, False иначе + """ + checker = get_permission_checker() + if not checker: + logger.error("Проверщик разрешений не инициализирован") + return False + + return await checker.has_any_permission(user_id, permissions) + + async def has_all_permissions(self, user_id: int, permissions: list[str]) -> bool: + """ + Проверка наличия всех разрешений у пользователя + + Args: + user_id: ID пользователя в Telegram + permissions: Список названий разрешений + + Returns: + True если у пользователя есть все разрешения, False иначе + """ + checker = get_permission_checker() + if not checker: + logger.error("Проверщик разрешений не инициализирован") + return False + + return await checker.has_all_permissions(user_id, permissions) + + async def get_user_permissions(self, user_id: int) -> list[str]: + """ + Получение списка всех разрешений пользователя + + Args: + user_id: ID пользователя в Telegram + + Returns: + Список названий разрешений пользователя + """ + checker = get_permission_checker() + if not checker: + logger.error("Проверщик разрешений не инициализирован") + return [] + + # Получаем все доступные разрешения + registry = checker.registry + all_permissions = registry.get_all() + + user_permissions = [] + for permission_name in all_permissions.keys(): + if await checker.has_permission(user_id, permission_name): + user_permissions.append(permission_name) + + return user_permissions diff --git a/services/business/__init__.py b/services/business/__init__.py new file mode 100644 index 0000000..fea18ee --- /dev/null +++ b/services/business/__init__.py @@ -0,0 +1,10 @@ +""" +Бизнес-логика сервисов +""" + +from .user_service import UserService +from .question_service import QuestionService +from .message_service import MessageService +from .pagination_service import PaginationService + +__all__ = ['UserService', 'QuestionService', 'MessageService', 'PaginationService'] diff --git a/services/business/message_service.py b/services/business/message_service.py new file mode 100644 index 0000000..0c133ce --- /dev/null +++ b/services/business/message_service.py @@ -0,0 +1,237 @@ +""" +Сервис для отправки сообщений +""" +from typing import Optional, Union +from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, ReplyKeyboardMarkup +from aiogram import Bot + +from services.infrastructure.logger import get_logger +from services.rate_limiting.rate_limit_service import RateLimitService +from services.infrastructure.logging_decorators import log_function_call, log_business_event + +logger = get_logger(__name__) + + +class MessageService: + """Сервис для отправки сообщений""" + + def __init__(self, rate_limit_service: Optional[RateLimitService] = None): + self.rate_limit_service = rate_limit_service + + @log_business_event("send_message", log_params=True, log_result=True) + async def send_message( + self, + target: Union[Message, CallbackQuery, Bot], + text: str, + reply_markup: Optional[Union[InlineKeyboardMarkup, ReplyKeyboardMarkup]] = None, + parse_mode: str = "HTML" + ) -> bool: + """ + Отправка сообщения + + Args: + target: Целевой объект (Message, CallbackQuery или Bot) + text: Текст сообщения + reply_markup: Клавиатура + parse_mode: Режим парсинга + + Returns: + True если успешно отправлено + """ + try: + logger.info(f"📤 Отправка сообщения с клавиатурой: {type(reply_markup).__name__ if reply_markup else 'None'}") + + if isinstance(target, Message): + await target.answer(text, reply_markup=reply_markup, parse_mode=parse_mode) + elif isinstance(target, CallbackQuery): + await target.message.answer(text, reply_markup=reply_markup, parse_mode=parse_mode) + elif isinstance(target, Bot): + # Для Bot нужен chat_id, который должен быть передан отдельно + logger.error("Для Bot нужен chat_id") + return False + + return True + + except Exception as e: + logger.error(f"Ошибка при отправке сообщения: {e}") + return False + + @log_business_event("edit_message", log_params=True, log_result=True) + async def edit_message( + self, + target: Union[Message, CallbackQuery], + text: str, + reply_markup: Optional[InlineKeyboardMarkup] = None, + parse_mode: str = "HTML" + ) -> bool: + """ + Редактирование сообщения + + Args: + target: Целевой объект (Message или CallbackQuery) + text: Новый текст сообщения + reply_markup: Новая клавиатура + parse_mode: Режим парсинга + + Returns: + True если успешно отредактировано + """ + try: + if isinstance(target, Message): + await target.edit_text(text, reply_markup=reply_markup, parse_mode=parse_mode) + elif isinstance(target, CallbackQuery): + await target.message.edit_text(text, reply_markup=reply_markup, parse_mode=parse_mode) + + return True + + except Exception as e: + logger.error(f"Ошибка при редактировании сообщения: {e}") + return False + + @log_function_call(log_params=True, log_result=True) + async def send_callback_answer( + self, + callback: CallbackQuery, + text: Optional[str] = None, + show_alert: bool = False + ) -> bool: + """ + Отправка ответа на callback query + + Args: + callback: CallbackQuery объект + text: Текст ответа + show_alert: Показывать ли alert + + Returns: + True если успешно отправлено + """ + try: + await callback.answer(text, show_alert=show_alert) + return True + except Exception as e: + logger.error(f"Ошибка при отправке callback answer: {e}") + return False + + @log_business_event("send_bot_message", log_params=True, log_result=True) + async def send_bot_message( + self, + bot: Bot, + chat_id: int, + text: str, + reply_markup: Optional[Union[InlineKeyboardMarkup, ReplyKeyboardMarkup]] = None, + parse_mode: str = "HTML" + ) -> bool: + """ + Отправка сообщения через бота с rate limiting + + Args: + bot: Экземпляр бота + chat_id: ID чата + text: Текст сообщения + reply_markup: Клавиатура + parse_mode: Режим парсинга + + Returns: + True если успешно отправлено + """ + try: + if self.rate_limit_service: + # Используем rate limiting + await self.rate_limit_service.send_with_rate_limit( + bot.send_message, + chat_id, + text=text, + reply_markup=reply_markup, + parse_mode=parse_mode + ) + else: + # Отправляем без rate limiting + await bot.send_message( + chat_id=chat_id, + text=text, + reply_markup=reply_markup, + parse_mode=parse_mode + ) + return True + except Exception as e: + logger.error(f"Ошибка при отправке сообщения через бота в чат {chat_id}: {e}") + return False + + @log_business_event("send_notification", log_params=True, log_result=True) + async def send_notification( + self, + bot: Bot, + user_id: int, + title: str, + message: str, + reply_markup: Optional[InlineKeyboardMarkup] = None + ) -> bool: + """ + Отправка уведомления пользователю с rate limiting + + Args: + bot: Экземпляр бота + user_id: ID пользователя + title: Заголовок уведомления + message: Текст уведомления + reply_markup: Клавиатура + + Returns: + True если успешно отправлено + """ + try: + notification_text = f"🔔 {title}\n\n{message}" + + if self.rate_limit_service: + # Используем rate limiting + await self.rate_limit_service.send_with_rate_limit( + bot.send_message, + user_id, + text=notification_text, + reply_markup=reply_markup, + parse_mode="HTML" + ) + else: + # Отправляем без rate limiting + await bot.send_message( + chat_id=user_id, + text=notification_text, + reply_markup=reply_markup, + parse_mode="HTML" + ) + + logger.info(f"Уведомление отправлено пользователю {user_id}") + return True + + except Exception as e: + logger.error(f"Ошибка при отправке уведомления пользователю {user_id}: {e}") + return False + + @log_business_event("send_error_message", log_params=True, log_result=True) + async def send_error_message( + self, + target: Union[Message, CallbackQuery], + error_text: str = "❌ Произошла ошибка. Попробуйте позже." + ) -> bool: + """ + Отправка сообщения об ошибке + + Args: + target: Целевой объект + error_text: Текст ошибки + + Returns: + True если успешно отправлено + """ + try: + if isinstance(target, CallbackQuery): + await target.answer(error_text, show_alert=True) + else: + await self.send_message(target, error_text) + + return True + + except Exception as e: + logger.error(f"Ошибка при отправке сообщения об ошибке: {e}") + return False diff --git a/services/business/pagination_service.py b/services/business/pagination_service.py new file mode 100644 index 0000000..f313d1b --- /dev/null +++ b/services/business/pagination_service.py @@ -0,0 +1,185 @@ +""" +Сервис для работы с пагинацией +""" +from typing import Any, List, Optional, Tuple + +from aiogram.types import InlineKeyboardMarkup + +from config.constants import DEFAULT_PAGE_SIZE, MIN_PAGE_NUMBER +from services.infrastructure.logger import get_logger + +logger = get_logger(__name__) + + +class PaginationService: + """Сервис для работы с пагинацией""" + + def __init__(self): + pass + + async def calculate_pagination_from_db( + self, + total_count: int, + page: int, + per_page: int = DEFAULT_PAGE_SIZE + ) -> Tuple[int, int, int, int]: + """ + Расчет пагинации на основе общего количества записей в БД + + Args: + total_count: Общее количество записей в БД + page: Номер страницы (начиная с 0) + per_page: Количество элементов на странице + + Returns: + Кортеж (общее_количество, текущая_страница, общее_количество_страниц, offset) + """ + try: + total_pages = (total_count + per_page - 1) // per_page # Округление вверх + + # Проверяем корректность номера страницы + if page < MIN_PAGE_NUMBER: + page = MIN_PAGE_NUMBER + elif page >= total_pages and total_pages > 0: + page = total_pages - 1 + + # Вычисляем offset для БД + offset = page * per_page + + return total_count, page, total_pages, offset + + except Exception as e: + logger.error(f"Ошибка при расчете пагинации из БД: {e}") + return MIN_PAGE_NUMBER, MIN_PAGE_NUMBER, MIN_PAGE_NUMBER, MIN_PAGE_NUMBER + + def calculate_pagination( + self, + items: List[Any], + page: int, + per_page: int = DEFAULT_PAGE_SIZE + ) -> Tuple[List[Any], int, int, int]: + """ + Расчет пагинации для списка элементов + + Args: + items: Список элементов + page: Номер страницы (начиная с 0) + per_page: Количество элементов на странице + + Returns: + Кортеж (элементы_страницы, общее_количество, текущая_страница, общее_количество_страниц) + """ + try: + total_items = len(items) + total_pages = (total_items + per_page - 1) // per_page # Округление вверх + + # Проверяем корректность номера страницы + if page < MIN_PAGE_NUMBER: + page = MIN_PAGE_NUMBER + elif page >= total_pages and total_pages > 0: + page = total_pages - 1 + + # Вычисляем диапазон элементов для текущей страницы + start_idx = page * per_page + end_idx = min(start_idx + per_page, total_items) + page_items = items[start_idx:end_idx] + + return page_items, total_items, page, total_pages + + except Exception as e: + logger.error(f"Ошибка при расчете пагинации: {e}") + return [], MIN_PAGE_NUMBER, MIN_PAGE_NUMBER, MIN_PAGE_NUMBER + + def format_pagination_info( + self, + current_page: int, + total_pages: int, + start_idx: int, + end_idx: int, + total_items: int + ) -> str: + """ + Форматирование информации о пагинации + + Args: + current_page: Текущая страница + total_pages: Общее количество страниц + start_idx: Начальный индекс + end_idx: Конечный индекс + total_items: Общее количество элементов + + Returns: + Отформатированная строка с информацией о пагинации + """ + try: + info = f"📊 Показано {start_idx + 1}-{end_idx} из {total_items}\n" + info += f"📄 Страница {current_page + 1} из {total_pages}\n\n" + + return info + + except Exception as e: + logger.error(f"Ошибка при форматировании информации о пагинации: {e}") + return "" + + def get_pagination_buttons( + self, + current_page: int, + total_pages: int, + callback_prefix: str, + additional_buttons: Optional[List[Tuple[str, str]]] = None + ) -> List[Tuple[str, str]]: + """ + Получение кнопок пагинации + + Args: + current_page: Текущая страница + total_pages: Общее количество страниц + callback_prefix: Префикс для callback_data + additional_buttons: Дополнительные кнопки + + Returns: + Список кортежей (текст_кнопки, callback_data) + """ + try: + buttons = [] + + # Кнопка "Предыдущая" + if current_page > MIN_PAGE_NUMBER: + buttons.append(("⬅️", f"{callback_prefix}_page_{current_page - 1}")) + + # Кнопка "Следующая" + if current_page < total_pages - 1: + buttons.append(("➡️", f"{callback_prefix}_page_{current_page + 1}")) + + # Дополнительные кнопки + if additional_buttons: + buttons.extend(additional_buttons) + + return buttons + + except Exception as e: + logger.error(f"Ошибка при создании кнопок пагинации: {e}") + return [] + + def validate_page_number(self, page: int, total_pages: int) -> int: + """ + Валидация номера страницы + + Args: + page: Номер страницы + total_pages: Общее количество страниц + + Returns: + Валидный номер страницы + """ + try: + if page < 0: + return 0 + elif page >= total_pages and total_pages > 0: + return total_pages - 1 + else: + return page + + except Exception as e: + logger.error(f"Ошибка при валидации номера страницы: {e}") + return MIN_PAGE_NUMBER diff --git a/services/business/question_service.py b/services/business/question_service.py new file mode 100644 index 0000000..3856f12 --- /dev/null +++ b/services/business/question_service.py @@ -0,0 +1,280 @@ +""" +Сервис для управления вопросами +""" +from datetime import datetime +from typing import List, Optional, Tuple +from aiogram import Bot + +from models.question import Question, QuestionStatus +from services.infrastructure.database import DatabaseService +from services.utils import UtilsService +from services.infrastructure.logger import get_logger +from services.infrastructure.metrics import get_metrics_service +from services.infrastructure.logging_decorators import log_function_call, log_business_event +from services.infrastructure.logging_utils import log_question_created, log_question_answered + +logger = get_logger(__name__) + + +class QuestionService: + """Сервис для управления вопросами""" + + def __init__(self, database: DatabaseService, utils: UtilsService): + self.database = database + self.utils = utils + self.metrics = get_metrics_service() + + @log_business_event("create_question", log_params=True, log_result=True) + async def create_question(self, from_user_id: int, to_user_id: int, message_text: str) -> Question: + """ + Создание нового вопроса + + Args: + from_user_id: ID автора вопроса + to_user_id: ID получателя вопроса + message_text: Текст вопроса + + Returns: + Созданный объект вопроса + """ + try: + question = Question( + from_user_id=from_user_id, + to_user_id=to_user_id, + message_text=message_text.strip(), + status=QuestionStatus.PENDING, + created_at=datetime.now(), + is_anonymous=True + ) + + question = await self.database.create_question(question) + self.metrics.increment_questions("created") + return question + + except Exception as e: + logger.error(f"Ошибка при создании вопроса от {from_user_id} к {to_user_id}: {e}") + raise + + @log_function_call(log_params=True, log_result=True) + async def get_question(self, question_id: int) -> Optional[Question]: + """ + Получение вопроса по ID + + Args: + question_id: ID вопроса + + Returns: + Объект вопроса или None + """ + try: + return await self.database.get_question(question_id) + except Exception as e: + logger.error(f"Ошибка при получении вопроса {question_id}: {e}") + return None + + @log_function_call(log_params=True, log_result=True) + async def get_user_questions(self, user_id: int, limit: int = 50, offset: int = 0) -> List[Question]: + """ + Получение вопросов пользователя + + Args: + user_id: ID пользователя + limit: Лимит вопросов + offset: Смещение + + Returns: + Список вопросов + """ + try: + return await self.database.get_user_questions(user_id, limit=limit, offset=offset) + except Exception as e: + logger.error(f"Ошибка при получении вопросов пользователя {user_id}: {e}") + return [] + + @log_business_event("answer_question", log_params=True, log_result=True) + async def answer_question(self, question_id: int, answer_text: str) -> Optional[Question]: + """ + Ответ на вопрос + + Args: + question_id: ID вопроса + answer_text: Текст ответа + + Returns: + Обновленный объект вопроса или None + """ + try: + question = await self.database.get_question(question_id) + if not question: + return None + + question.mark_as_answered(answer_text.strip()) + question.answered_at = datetime.now() + + question = await self.database.update_question(question) + self.metrics.increment_answers("sent") + return question + + except Exception as e: + logger.error(f"Ошибка при ответе на вопрос {question_id}: {e}") + return None + + @log_business_event("reject_question", log_params=True, log_result=True) + async def reject_question(self, question_id: int) -> Optional[Question]: + """ + Отклонение вопроса + + Args: + question_id: ID вопроса + + Returns: + Обновленный объект вопроса или None + """ + try: + question = await self.database.get_question(question_id) + if not question: + return None + + question.mark_as_rejected() + question.answered_at = datetime.now() + + question = await self.database.update_question(question) + self.metrics.increment_questions("rejected") + return question + + except Exception as e: + logger.error(f"Ошибка при отклонении вопроса {question_id}: {e}") + return None + + @log_business_event("delete_question", log_params=True, log_result=True) + async def delete_question(self, question_id: int) -> Optional[Question]: + """ + Удаление вопроса + + Args: + question_id: ID вопроса + + Returns: + Обновленный объект вопроса или None + """ + try: + question = await self.database.get_question(question_id) + if not question: + return None + + question.mark_as_deleted() + question.answered_at = datetime.now() + + question = await self.database.update_question(question) + self.metrics.increment_questions("deleted") + return question + + except Exception as e: + logger.error(f"Ошибка при удалении вопроса {question_id}: {e}") + return None + + @log_business_event("edit_answer", log_params=True, log_result=True) + async def edit_answer(self, question_id: int, new_answer_text: str) -> Optional[Question]: + """ + Редактирование ответа на вопрос + + Args: + question_id: ID вопроса + new_answer_text: Новый текст ответа + + Returns: + Обновленный объект вопроса или None + """ + try: + question = await self.database.get_question(question_id) + if not question: + return None + + question.answer_text = new_answer_text.strip() + question.answered_at = datetime.now() + + question = await self.database.update_question(question) + self.metrics.increment_answers("edited") + return question + + except Exception as e: + logger.error(f"Ошибка при редактировании ответа на вопрос {question_id}: {e}") + return None + + @log_function_call(log_params=True, log_result=True) + def validate_question_text(self, text: str, max_length: int = 1000) -> Tuple[bool, str]: + """ + Валидация текста вопроса + + Args: + text: Текст вопроса + max_length: Максимальная длина + + Returns: + Кортеж (валидность, сообщение об ошибке) + """ + return self.utils.is_valid_question_text(text, max_length) + + @log_function_call(log_params=True, log_result=True) + def validate_answer_text(self, text: str, max_length: int = 2000) -> Tuple[bool, str]: + """ + Валидация текста ответа + + Args: + text: Текст ответа + max_length: Максимальная длина + + Returns: + Кортеж (валидность, сообщение об ошибке) + """ + return self.utils.is_valid_answer_text(text, max_length) + + @log_function_call(log_params=True, log_result=True) + async def send_answer_to_author(self, bot: Bot, question: Question, answer_text: str) -> bool: + """ + Отправка ответа автору вопроса + + Args: + bot: Экземпляр бота + question: Объект вопроса + answer_text: Текст ответа + + Returns: + True если успешно отправлено + """ + try: + await self.utils.send_answer_to_author(bot, question, answer_text) + self.metrics.increment_answers("delivered") + return True + except Exception as e: + logger.error(f"Ошибка при отправке ответа автору вопроса {question.id}: {e}") + self.metrics.increment_answers("delivery_failed") + return False + + @log_function_call(log_params=True, log_result=True) + def format_question_info(self, question: Question, show_answer: bool = False) -> str: + """ + Форматирование информации о вопросе + + Args: + question: Объект вопроса + show_answer: Показывать ли ответ + + Returns: + Отформатированная строка + """ + return self.utils.format_question_info(question, show_answer) + + @log_function_call(log_params=True, log_result=True) + def get_question_preview(self, question: Question, max_length: int = 50) -> str: + """ + Получение превью вопроса + + Args: + question: Объект вопроса + max_length: Максимальная длина превью + + Returns: + Превью вопроса + """ + return question.get_question_preview(max_length) diff --git a/services/business/user_service.py b/services/business/user_service.py new file mode 100644 index 0000000..c1f9ce1 --- /dev/null +++ b/services/business/user_service.py @@ -0,0 +1,208 @@ +""" +Сервис для управления пользователями +""" +from datetime import datetime +from typing import Optional, Tuple +from aiogram.types import User as TelegramUser + +from models.user import User +from services.infrastructure.database import DatabaseService +from services.utils import UtilsService +from services.infrastructure.logger import get_logger +from services.infrastructure.metrics import get_metrics_service +from services.infrastructure.logging_decorators import log_function_call, log_business_event +from services.infrastructure.logging_utils import log_user_created, log_user_blocked + +logger = get_logger(__name__) + + +class UserService: + """Сервис для управления пользователями""" + + def __init__(self, database: DatabaseService, utils: UtilsService): + self.database = database + self.utils = utils + self.metrics = get_metrics_service() + + @log_business_event("create_or_update_user", log_params=True, log_result=True) + async def create_or_update_user(self, telegram_user: TelegramUser, chat_id: int) -> User: + """ + Создание или обновление пользователя + + Args: + telegram_user: Объект пользователя из Telegram + chat_id: ID чата + + Returns: + Объект пользователя + """ + try: + # Проверяем, существует ли пользователь + existing_user = await self.database.get_user(telegram_user.id) + + if existing_user: + # Обновляем существующего пользователя + logger.info(f"👤 Обновление существующего пользователя {telegram_user.id}") + self.metrics.increment_users("updated") + return await self._update_existing_user(existing_user, telegram_user, chat_id) + else: + # Создаем нового пользователя + logger.info(f"👤 Создание нового пользователя {telegram_user.id}") + self.metrics.increment_users("created") + return await self._create_new_user(telegram_user, chat_id) + + except Exception as e: + logger.error(f"Ошибка при создании/обновлении пользователя {telegram_user.id}: {e}") + raise + + @log_function_call(log_params=True, log_result=True) + async def _update_existing_user(self, existing_user: User, telegram_user: TelegramUser, chat_id: int) -> User: + """Обновление существующего пользователя""" + existing_user.username = telegram_user.username + existing_user.first_name = telegram_user.first_name or "Пользователь" + existing_user.last_name = telegram_user.last_name + existing_user.chat_id = chat_id + existing_user.update_timestamp() + + return await self.database.update_user(existing_user) + + @log_function_call(log_params=True, log_result=True) + async def _create_new_user(self, telegram_user: TelegramUser, chat_id: int) -> User: + """Создание нового пользователя""" + user = User( + telegram_id=telegram_user.id, + username=telegram_user.username, + first_name=telegram_user.first_name or "Пользователь", + last_name=telegram_user.last_name, + chat_id=chat_id, + profile_link=self.utils.generate_anonymous_id(), + is_active=True, + created_at=datetime.now(), + updated_at=datetime.now() + ) + + return await self.database.create_user(user) + + @log_function_call(log_params=True, log_result=True) + async def get_user_by_profile_link(self, profile_link: str) -> Optional[User]: + """ + Получение пользователя по ссылке профиля + + Args: + profile_link: Ссылка профиля + + Returns: + Объект пользователя или None + """ + try: + return await self.database.get_user_by_profile_link(profile_link) + except Exception as e: + logger.error(f"Ошибка при получении пользователя по ссылке {profile_link}: {e}") + return None + + @log_function_call(log_params=True, log_result=True) + async def get_user_by_telegram_id(self, telegram_id: int) -> Optional[User]: + """ + Получение пользователя по Telegram ID + + Args: + telegram_id: ID пользователя в Telegram + + Returns: + Объект пользователя или None + """ + try: + return await self.database.get_user(telegram_id) + except Exception as e: + logger.error(f"Ошибка при получении пользователя {telegram_id}: {e}") + return None + + @log_function_call(log_params=True, log_result=True) + def generate_referral_link(self, bot_username: str, user: User) -> str: + """ + Генерация реферальной ссылки для пользователя + + Args: + bot_username: Имя бота + user: Объект пользователя + + Returns: + Реферальная ссылка + """ + return self.utils.generate_referral_link(bot_username, user.profile_link) + + @log_function_call(log_params=True, log_result=True) + async def is_user_blocked(self, blocker_id: int, blocked_id: int) -> bool: + """ + Проверка, заблокирован ли пользователь + + Args: + blocker_id: ID пользователя, который блокирует + blocked_id: ID пользователя, которого блокируют + + Returns: + True если заблокирован, False иначе + """ + try: + return await self.database.is_user_blocked(blocker_id, blocked_id) + except Exception as e: + logger.error(f"Ошибка при проверке блокировки {blocker_id} -> {blocked_id}: {e}") + return False + + @log_business_event("block_user", log_params=True, log_result=True) + async def block_user(self, blocker_id: int, blocked_id: int) -> bool: + """ + Блокировка пользователя + + Args: + blocker_id: ID пользователя, который блокирует + blocked_id: ID пользователя, которого блокируют + + Returns: + True если успешно заблокирован + """ + try: + await self.database.block_user(blocker_id, blocked_id) + logger.info(f"Пользователь {blocked_id} заблокирован пользователем {blocker_id}") + return True + except Exception as e: + logger.error(f"Ошибка при блокировке пользователя {blocked_id}: {e}") + return False + + @log_business_event("unblock_user", log_params=True, log_result=True) + async def unblock_user(self, blocker_id: int, blocked_id: int) -> bool: + """ + Разблокировка пользователя + + Args: + blocker_id: ID пользователя, который разблокирует + blocked_id: ID пользователя, которого разблокируют + + Returns: + True если успешно разблокирован + """ + try: + result = await self.database.unblock_user(blocker_id, blocked_id) + if result: + logger.info(f"Пользователь {blocked_id} разблокирован пользователем {blocker_id}") + return result + except Exception as e: + logger.error(f"Ошибка при разблокировке пользователя {blocked_id}: {e}") + return False + + @log_function_call(log_params=True, log_result=True) + async def get_blocked_users(self, user_id: int) -> list: + """ + Получение списка заблокированных пользователей + + Args: + user_id: ID пользователя + + Returns: + Список ID заблокированных пользователей + """ + try: + return await self.database.user_blocks.get_blocked_users(user_id) + except Exception as e: + logger.error(f"Ошибка при получении заблокированных пользователей для {user_id}: {e}") + return [] diff --git a/services/infrastructure/__init__.py b/services/infrastructure/__init__.py new file mode 100644 index 0000000..a4c37e3 --- /dev/null +++ b/services/infrastructure/__init__.py @@ -0,0 +1,31 @@ +""" +Инфраструктурные сервисы +""" + +from .database import DatabaseService +from .logger import get_logger, setup_logging +from .metrics import MetricsService, get_metrics_service +from .pid_manager import PIDManager, get_pid_manager, cleanup_pid_file +from .logging_decorators import ( + log_function_call, log_business_event, log_fsm_transition, + log_handler, log_service, log_business, log_fsm, + log_quiet, log_middleware, log_utility +) +from .logging_utils import ( + LoggingContext, get_logging_context, + log_user_action, log_business_operation, log_fsm_event, log_performance, + log_question_created, log_question_answered, log_user_created, log_user_blocked +) + +__all__ = [ + 'DatabaseService', + 'get_logger', 'setup_logging', + 'MetricsService', 'get_metrics_service', + 'PIDManager', 'get_pid_manager', 'cleanup_pid_file', + 'log_function_call', 'log_business_event', 'log_fsm_transition', + 'log_handler', 'log_service', 'log_business', 'log_fsm', + 'log_quiet', 'log_middleware', 'log_utility', + 'LoggingContext', 'get_logging_context', + 'log_user_action', 'log_business_operation', 'log_fsm_event', 'log_performance', + 'log_question_created', 'log_question_answered', 'log_user_created', 'log_user_blocked' +] diff --git a/services/infrastructure/database.py b/services/infrastructure/database.py new file mode 100644 index 0000000..23a8bca --- /dev/null +++ b/services/infrastructure/database.py @@ -0,0 +1,255 @@ +""" +Сервис для работы с базой данных SQLite +""" +import aiosqlite +from datetime import datetime +from typing import List, Optional, Dict, Any, Tuple +from contextlib import asynccontextmanager +from pathlib import Path + +from models.user import User +from models.question import Question, QuestionStatus +from models.user_block import UserBlock +from models.user_settings import UserSettings +from database.crud import UserCRUD, QuestionCRUD, UserBlockCRUD, UserSettingsCRUD +from .logger import get_logger + +logger = get_logger(__name__) + + +class DatabaseService: + """Сервис для работы с базой данных""" + + def __init__(self, db_path: str): + self.db_path = db_path + # Инициализируем CRUD операции + self.users = UserCRUD(db_path) + self.questions = QuestionCRUD(db_path) + self.user_blocks = UserBlockCRUD(db_path) + self.user_settings = UserSettingsCRUD(db_path) + + async def init(self): + """Инициализация базы данных и создание таблиц""" + logger.info(f"💾 Инициализация базы данных: {self.db_path}") + # Создаем директорию для базы данных если её нет + db_path = Path(self.db_path) + db_path.parent.mkdir(parents=True, exist_ok=True) + + async with self.get_connection() as conn: + await self._create_tables(conn) + logger.info("✅ База данных инициализирована") + + @asynccontextmanager + async def get_connection(self): + """Контекстный менеджер для подключения к БД с использованием пула""" + from database.crud import get_connection_pool + pool = get_connection_pool(self.db_path) + conn = await pool.get_connection() + try: + yield conn + finally: + await pool.return_connection(conn) + + async def _create_tables(self, conn: aiosqlite.Connection): + """Создание таблиц в базе данных""" + # Проверяем, существуют ли уже таблицы + cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='users';") + if await cursor.fetchone(): + logger.info("📋 Таблицы уже существуют, пропускаем создание") + return + + # Читаем схему из файла + schema_path = Path(__file__).parent.parent / "database" / "schema.sql" + + if schema_path.exists(): + logger.info("📄 Создание таблиц из схемы") + with open(schema_path, 'r', encoding='utf-8') as f: + schema_sql = f.read() + + # Выполняем SQL схему + await conn.executescript(schema_sql) + await conn.commit() + logger.info("✅ Таблицы созданы из схемы") + else: + logger.warning("⚠️ Файл схемы не найден, создаем таблицы вручную") + await self._create_tables_manual(conn) + + async def _create_tables_manual(self, conn: aiosqlite.Connection): + """Создание таблиц вручную если схема не найдена""" + # Простая схема для совместимости + await conn.execute(""" + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + telegram_id INTEGER UNIQUE NOT NULL, + username TEXT, + first_name TEXT NOT NULL, + last_name TEXT, + chat_id INTEGER NOT NULL, + profile_link TEXT UNIQUE NOT NULL, + is_active BOOLEAN DEFAULT TRUE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + banned_until DATETIME, + ban_reason TEXT + ) + """) + + await conn.execute(""" + CREATE TABLE IF NOT EXISTS questions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_user_id INTEGER, + to_user_id INTEGER NOT NULL, + message_text TEXT NOT NULL, + answer_text TEXT, + is_anonymous BOOLEAN DEFAULT TRUE, + message_id INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + answered_at DATETIME, + is_read BOOLEAN DEFAULT FALSE, + status TEXT DEFAULT 'pending' + ) + """) + + await conn.commit() + + # Обертки для CRUD операций (для совместимости) + + # Пользователи + async def create_user(self, user: User) -> User: + """Создание нового пользователя""" + return await self.users.create(user) + + async def create_users_batch(self, users: List[User]) -> List[User]: + """Создание нескольких пользователей за одну транзакцию (batch операция)""" + return await self.users.create_batch(users) + + async def get_user(self, telegram_id: int) -> Optional[User]: + """Получение пользователя по Telegram ID""" + return await self.users.get_by_telegram_id(telegram_id) + + async def get_user_by_profile_link(self, profile_link: str) -> Optional[User]: + """Получение пользователя по ссылке профиля""" + return await self.users.get_by_profile_link(profile_link) + + async def update_user(self, user: User) -> User: + """Обновление пользователя""" + return await self.users.update(user) + + async def get_all_users(self, limit: int = 100, offset: int = 0) -> List[User]: + """Получение всех пользователей""" + return await self.users.get_all(limit, offset) + + async def get_all_users_cursor(self, last_id: int, last_created_at: str, + limit: int, direction: str = "desc") -> List[User]: + """Получение пользователей с cursor-based пагинацией""" + return await self.users.get_all_users_cursor(last_id, last_created_at, limit, direction) + + async def get_all_users_asc(self, limit: int = 100, offset: int = 0) -> List[User]: + """Получение всех пользователей в порядке возрастания""" + return await self.users.get_all_users_asc(limit, offset) + + async def get_users_stats(self) -> Dict[str, Any]: + """Получение статистики пользователей""" + return await self.users.get_stats() + + # Вопросы + async def create_question(self, question: Question) -> Question: + """Создание нового вопроса""" + return await self.questions.create(question) + + async def create_questions_batch(self, questions: List[Question]) -> List[Question]: + """Создание нескольких вопросов за одну транзакцию (batch операция)""" + return await self.questions.create_batch(questions) + + async def get_question(self, question_id: int) -> Optional[Question]: + """Получение вопроса по ID""" + return await self.questions.get_by_id(question_id) + + async def get_user_questions(self, user_id: int, status: Optional[QuestionStatus] = None, + limit: int = 50, offset: int = 0) -> List[Question]: + """Получение вопросов пользователя""" + return await self.questions.get_by_to_user(user_id, status, limit, offset) + + async def get_user_questions_with_authors(self, user_id: int, status: Optional[QuestionStatus] = None, + limit: int = 50, offset: int = 0) -> List[Tuple[Question, Optional[User]]]: + """Получение вопросов пользователя с информацией об авторах (оптимизированный запрос)""" + return await self.questions.get_by_to_user_with_authors(user_id, status, limit, offset) + + async def get_user_questions_cursor(self, user_id: int, last_id: int, last_created_at: str, + limit: int, direction: str = "desc") -> List[Question]: + """Получение вопросов пользователя с cursor-based пагинацией""" + return await self.questions.get_by_to_user_cursor(user_id, last_id, last_created_at, limit, direction) + + async def get_user_questions_asc(self, user_id: int, status: Optional[QuestionStatus] = None, + limit: int = 50, offset: int = 0) -> List[Question]: + """Получение вопросов пользователя в порядке возрастания""" + return await self.questions.get_by_to_user_asc(user_id, status, limit, offset) + + async def update_question(self, question: Question) -> Question: + """Обновление вопроса""" + return await self.questions.update(question) + + async def get_questions_stats(self) -> Dict[str, Any]: + """Получение статистики вопросов""" + return await self.questions.get_stats() + + async def get_unread_questions_count(self, user_id: int) -> int: + """Получение количества непрочитанных вопросов""" + return await self.questions.get_unread_count(user_id) + + async def get_user_questions_count(self, user_id: int, status: Optional[QuestionStatus] = None) -> int: + """Получение общего количества вопросов пользователя""" + return await self.questions.get_count_by_to_user(user_id, status) + + # Блокировки + async def block_user(self, blocker_id: int, blocked_id: int) -> UserBlock: + """Блокировка пользователя""" + user_block = UserBlock( + blocker_id=blocker_id, + blocked_id=blocked_id, + created_at=datetime.now() + ) + return await self.user_blocks.create(user_block) + + async def unblock_user(self, blocker_id: int, blocked_id: int) -> bool: + """Разблокировка пользователя""" + return await self.user_blocks.delete(blocker_id, blocked_id) + + async def is_user_blocked(self, blocker_id: int, blocked_id: int) -> bool: + """Проверка, заблокирован ли пользователь""" + return await self.user_blocks.is_blocked(blocker_id, blocked_id) + + # Настройки + async def get_user_settings(self, user_id: int) -> Optional[UserSettings]: + """Получение настроек пользователя""" + return await self.user_settings.get_by_user_id(user_id) + + async def get_user_by_id(self, user_id: int) -> Optional[User]: + """Получение пользователя по ID (для получения информации об авторах вопросов)""" + return await self.users.get_by_telegram_id(user_id) + + async def update_user_settings(self, settings: UserSettings) -> UserSettings: + """Обновление настроек пользователя""" + return await self.user_settings.update(settings) + + async def create_user_settings(self, settings: UserSettings) -> UserSettings: + """Создание настроек пользователя""" + return await self.user_settings.create(settings) + + async def check_connection(self): + """Проверка соединения с базой данных""" + try: + async with self.get_connection() as conn: + # Выполняем простой запрос для проверки соединения + cursor = await conn.execute("SELECT 1") + await cursor.fetchone() + logger.debug("Database connection check successful") + except Exception as e: + logger.error(f"Database connection check failed: {e}") + raise + + async def close(self): + """Закрытие соединения с БД""" + from database.crud import get_connection_pool + pool = get_connection_pool(self.db_path) + await pool.close_all() diff --git a/services/infrastructure/http_server.py b/services/infrastructure/http_server.py new file mode 100644 index 0000000..6925980 --- /dev/null +++ b/services/infrastructure/http_server.py @@ -0,0 +1,350 @@ +""" +HTTP сервер для эндпоинтов метрик и health check +""" +import asyncio +import time +from typing import Optional + +from aiohttp import ClientSession, web +from aiohttp.web import Request, Response +from loguru import logger + +from config.constants import DEFAULT_HTTP_HOST, DEFAULT_HTTP_PORT, APP_VERSION, HTTP_STATUS_OK, HTTP_STATUS_SERVICE_UNAVAILABLE, HTTP_STATUS_INTERNAL_SERVER_ERROR +from dependencies import get_database_service +from .metrics import get_metrics_service + + +class HTTPServer: + """HTTP сервер для метрик и health check""" + + def __init__(self, host: str = DEFAULT_HTTP_HOST, port: int = DEFAULT_HTTP_PORT): + self.host = host + self.port = port + self.app = web.Application() + self.metrics_service = get_metrics_service() + self.database_service = get_database_service() + self.start_time = time.time() + self._setup_routes() + + def _setup_routes(self): + """Настройка маршрутов""" + self.app.router.add_get('/metrics', self.metrics_handler) + self.app.router.add_get('/health', self.health_handler) + self.app.router.add_get('/ready', self.ready_handler) + self.app.router.add_get('/status', self.status_handler) + self.app.router.add_get('/', self.root_handler) + + async def metrics_handler(self, request: Request) -> Response: + """Обработчик эндпоинта /metrics""" + start_time = time.time() + + try: + # Получаем метрики + metrics_data = self.metrics_service.get_metrics() + content_type = self.metrics_service.get_content_type() + + # Записываем метрику HTTP запроса + duration = time.time() - start_time + self.metrics_service.record_http_request_duration("GET", "/metrics", duration) + self.metrics_service.increment_http_requests("GET", "/metrics", HTTP_STATUS_OK) + + return Response( + text=metrics_data, + content_type=content_type, + status=HTTP_STATUS_OK + ) + + except Exception as e: + logger.error(f"Error in metrics handler: {e}") + duration = time.time() - start_time + self.metrics_service.record_http_request_duration("GET", "/metrics", duration) + self.metrics_service.increment_http_requests("GET", "/metrics", HTTP_STATUS_INTERNAL_SERVER_ERROR) + self.metrics_service.increment_errors(type(e).__name__, "metrics_handler") + + return Response( + text="Internal Server Error", + status=HTTP_STATUS_INTERNAL_SERVER_ERROR + ) + + async def health_handler(self, request: Request) -> Response: + """Обработчик эндпоинта /health""" + start_time = time.time() + + try: + # Проверяем состояние сервисов + health_status = { + "status": "healthy", + "timestamp": time.time(), + "uptime": time.time() - self.start_time, + "version": APP_VERSION, + "services": {} + } + + # Проверяем базу данных + try: + await self.database_service.check_connection() + health_status["services"]["database"] = "healthy" + except Exception as e: + health_status["services"]["database"] = f"unhealthy: {str(e)}" + health_status["status"] = "unhealthy" + + # Проверяем метрики + try: + self.metrics_service.get_metrics() + health_status["services"]["metrics"] = "healthy" + except Exception as e: + health_status["services"]["metrics"] = f"unhealthy: {str(e)}" + health_status["status"] = "unhealthy" + + # Определяем HTTP статус + http_status = HTTP_STATUS_OK if health_status["status"] == "healthy" else HTTP_STATUS_SERVICE_UNAVAILABLE + + # Записываем метрику HTTP запроса + duration = time.time() - start_time + self.metrics_service.record_http_request_duration("GET", "/health", duration) + self.metrics_service.increment_http_requests("GET", "/health", http_status) + + return Response( + json=health_status, + status=http_status + ) + + except Exception as e: + logger.error(f"Error in health handler: {e}") + duration = time.time() - start_time + self.metrics_service.record_http_request_duration("GET", "/health", duration) + self.metrics_service.increment_http_requests("GET", "/health", 500) + self.metrics_service.increment_errors(type(e).__name__, "health_handler") + + return Response( + json={"status": "error", "message": str(e)}, + status=HTTP_STATUS_INTERNAL_SERVER_ERROR + ) + + async def ready_handler(self, request: Request) -> Response: + """Обработчик эндпоинта /ready (readiness probe)""" + start_time = time.time() + + try: + # Проверяем готовность сервисов + ready_status = { + "status": "ready", + "timestamp": time.time(), + "services": {} + } + + # Проверяем базу данных + try: + await self.database_service.check_connection() + ready_status["services"]["database"] = "ready" + except Exception as e: + ready_status["services"]["database"] = f"not_ready: {str(e)}" + ready_status["status"] = "not_ready" + + # Определяем HTTP статус + http_status = HTTP_STATUS_OK if ready_status["status"] == "ready" else HTTP_STATUS_SERVICE_UNAVAILABLE + + # Записываем метрику HTTP запроса + duration = time.time() - start_time + self.metrics_service.record_http_request_duration("GET", "/ready", duration) + self.metrics_service.increment_http_requests("GET", "/ready", http_status) + + return Response( + json=ready_status, + status=http_status + ) + + except Exception as e: + logger.error(f"Error in ready handler: {e}") + duration = time.time() - start_time + self.metrics_service.record_http_request_duration("GET", "/ready", duration) + self.metrics_service.increment_http_requests("GET", "/ready", 500) + self.metrics_service.increment_errors(type(e).__name__, "ready_handler") + + return Response( + json={"status": "error", "message": str(e)}, + status=HTTP_STATUS_INTERNAL_SERVER_ERROR + ) + + async def status_handler(self, request: Request) -> Response: + """Handle /status endpoint for process status information.""" + try: + import os + import time + import psutil + + # Получаем PID текущего процесса + current_pid = os.getpid() + + try: + # Получаем информацию о процессе + process = psutil.Process(current_pid) + create_time = process.create_time() + uptime_seconds = time.time() - create_time + + # Логируем для диагностики + import datetime + create_time_str = datetime.datetime.fromtimestamp(create_time).strftime('%Y-%m-%d %H:%M:%S') + current_time_str = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + logger.info(f"Process PID {current_pid}: created at {create_time_str}, current time {current_time_str}, uptime {uptime_seconds:.1f}s") + + # Форматируем uptime + if uptime_seconds < 60: + uptime_str = f"{int(uptime_seconds)}с" + elif uptime_seconds < 3600: + minutes = int(uptime_seconds // 60) + uptime_str = f"{minutes}м" + elif uptime_seconds < 86400: + hours = int(uptime_seconds // 3600) + minutes = int((uptime_seconds % 3600) // 60) + uptime_str = f"{hours}ч {minutes}м" + else: + days = int(uptime_seconds // 86400) + hours = int((uptime_seconds % 86400) // 3600) + uptime_str = f"{days}д {hours}ч" + + # Проверяем, что процесс активен + if process.is_running(): + status = "running" + else: + status = "stopped" + + # Формируем ответ + response_data = { + "status": status, + "pid": current_pid, + "uptime": uptime_str, + "memory_usage_mb": round(process.memory_info().rss / 1024 / 1024, 2), + "cpu_percent": process.cpu_percent(), + "timestamp": time.time() + } + + import json + return Response( + text=json.dumps(response_data, ensure_ascii=False), + content_type='application/json', + status=200 + ) + + except psutil.NoSuchProcess: + # Процесс не найден + response_data = { + "status": "not_found", + "error": "Process not found", + "timestamp": time.time() + } + + import json + return Response( + text=json.dumps(response_data, ensure_ascii=False), + content_type='application/json', + status=404 + ) + + except Exception as e: + logger.error(f"Status check failed: {e}") + import json + response_data = { + "status": "error", + "error": str(e), + "timestamp": time.time() + } + + return Response( + text=json.dumps(response_data, ensure_ascii=False), + content_type='application/json', + status=500 + ) + + async def root_handler(self, request: Request) -> Response: + """Обработчик корневого эндпоинта""" + start_time = time.time() + + try: + info = { + "service": "AnonBot", + "version": APP_VERSION, + "endpoints": { + "metrics": "/metrics", + "health": "/health", + "ready": "/ready", + "status": "/status" + }, + "uptime": time.time() - self.start_time + } + + # Записываем метрику HTTP запроса + duration = time.time() - start_time + self.metrics_service.record_http_request_duration("GET", "/", duration) + self.metrics_service.increment_http_requests("GET", "/", 200) + + return Response( + json=info, + status=HTTP_STATUS_OK + ) + + except Exception as e: + logger.error(f"Error in root handler: {e}") + duration = time.time() - start_time + self.metrics_service.record_http_request_duration("GET", "/", duration) + self.metrics_service.increment_http_requests("GET", "/", 500) + self.metrics_service.increment_errors(type(e).__name__, "root_handler") + + return Response( + json={"error": str(e)}, + status=HTTP_STATUS_INTERNAL_SERVER_ERROR + ) + + async def start(self): + """Запуск HTTP сервера""" + try: + runner = web.AppRunner(self.app) + await runner.setup() + site = web.TCPSite(runner, self.host, self.port) + await site.start() + + logger.info(f"HTTP server started on {self.host}:{self.port}") + logger.info(f"Metrics endpoint: http://{self.host}:{self.port}/metrics") + logger.info(f"Health endpoint: http://{self.host}:{self.port}/health") + logger.info(f"Ready endpoint: http://{self.host}:{self.port}/ready") + logger.info(f"Status endpoint: http://{self.host}:{self.port}/status") + + return runner + + except Exception as e: + logger.error(f"Failed to start HTTP server: {e}") + self.metrics_service.increment_errors(type(e).__name__, "http_server") + raise + + async def stop(self, runner: web.AppRunner): + """Остановка HTTP сервера""" + try: + await runner.cleanup() + logger.info("HTTP server stopped") + except Exception as e: + logger.error(f"Error stopping HTTP server: {e}") + self.metrics_service.increment_errors(type(e).__name__, "http_server") + + +# Глобальный экземпляр HTTP сервера +_http_server: Optional[HTTPServer] = None + + +def get_http_server(host: str = DEFAULT_HTTP_HOST, port: int = DEFAULT_HTTP_PORT) -> HTTPServer: + """Получить экземпляр HTTP сервера""" + global _http_server + if _http_server is None: + _http_server = HTTPServer(host, port) + return _http_server + + +async def start_http_server(host: str = DEFAULT_HTTP_HOST, port: int = DEFAULT_HTTP_PORT) -> web.AppRunner: + """Запустить HTTP сервер""" + server = get_http_server(host, port) + return await server.start() + + +async def stop_http_server(runner: web.AppRunner): + """Остановить HTTP сервер""" + server = get_http_server() + await server.stop(runner) diff --git a/services/infrastructure/logger.py b/services/infrastructure/logger.py new file mode 100644 index 0000000..dc50135 --- /dev/null +++ b/services/infrastructure/logger.py @@ -0,0 +1,83 @@ +""" +Настройка системы логирования с использованием loguru +""" +import sys +from loguru import logger +from config import config + + +def setup_logging(): + """Настройка системы логирования""" + # Удаляем стандартный обработчик loguru + logger.remove() + + # Настраиваем логирование в stderr для Docker + log_level = "DEBUG" if config.DEBUG else "INFO" + + # Основной обработчик для stderr (для Docker) + logger.add( + sys.stderr, + level=log_level, + format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}", + colorize=True, + backtrace=True, + diagnose=True + ) + + # Дополнительный обработчик для файла (опционально) + if config.DEBUG: + logger.add( + "logs/bot.log", + level="DEBUG", + format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}", + rotation="10 MB", + retention="7 days", + compression="zip", + backtrace=True, + diagnose=True + ) + + # Настраиваем логирование для внешних библиотек + import logging + + # Отключаем логирование aiogram по умолчанию + logging.getLogger("aiogram").setLevel(logging.WARNING) + logging.getLogger("aiohttp").setLevel(logging.WARNING) + logging.getLogger("aiosqlite").setLevel(logging.WARNING) + + # Перенаправляем стандартное логирование в loguru + class InterceptHandler(logging.Handler): + def emit(self, record): + # Получаем соответствующий уровень loguru + try: + level = logger.level(record.levelname).name + except ValueError: + level = record.levelno + + # Находим caller из логов + frame, depth = logging.currentframe(), 2 + while frame.f_code.co_filename == logging.__file__: + frame = frame.f_back + depth += 1 + + logger.opt(depth=depth, exception=record.exc_info).log( + level, record.getMessage() + ) + + # Подключаем перехватчик к корневому логгеру + logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True) + + logger.info("🔧 Система логирования loguru настроена") + logger.info(f"📊 Уровень логирования: {log_level}") + logger.info(f"🐳 Логи выводятся в stderr для Docker") + + +def get_logger(name: str = None): + """Получить логгер для модуля""" + if name: + return logger.bind(name=name) + return logger + + +# Инициализируем логирование при импорте +setup_logging() diff --git a/services/infrastructure/logging_decorators.py b/services/infrastructure/logging_decorators.py new file mode 100644 index 0000000..a557023 --- /dev/null +++ b/services/infrastructure/logging_decorators.py @@ -0,0 +1,274 @@ +""" +Декораторы для автоматического логирования функций +""" +import asyncio +import inspect +from functools import wraps +from typing import Callable, Any, Optional, Dict, Union +from aiogram.types import Message, CallbackQuery + +from services.infrastructure.logger import get_logger + + +def log_function_call( + function_name: Optional[str] = None, + log_params: bool = True, + log_result: bool = False, + log_level: str = "info", + quiet: bool = False +): + """ + Декоратор для автоматического логирования входа/выхода из функций + + Args: + function_name: Кастомное имя функции для логов (по умолчанию берется из func.__name__) + log_params: Логировать ли параметры вызова + log_result: Логировать ли результат выполнения + log_level: Уровень логирования ('info', 'debug', 'warning') + quiet: Тихое логирование (только ошибки) + """ + def decorator(func: Callable) -> Callable: + @wraps(func) + async def async_wrapper(*args, **kwargs): + logger = get_logger(func.__module__) + name = function_name or func.__name__ + + # Формируем контекстную информацию + context_info = _build_context_info(args, kwargs, log_params) + + # Логируем вход в функцию (только если не тихий режим) + if not quiet: + log_method = getattr(logger, log_level) + log_method(f"🚀 Начало выполнения {name}{context_info}") + + try: + result = await func(*args, **kwargs) + + # Логируем успешное завершение (только если не тихий режим) + if not quiet: + result_info = "" + if log_result and result is not None: + result_info = f" | Результат: {_format_result(result)}" + + log_method(f"✅ Успешное завершение {name}{result_info}") + + return result + + except Exception as e: + # Логируем ошибку (всегда, даже в тихом режиме) + logger.error(f"❌ Ошибка в {name}: {e}") + raise + + @wraps(func) + def sync_wrapper(*args, **kwargs): + logger = get_logger(func.__module__) + name = function_name or func.__name__ + + # Формируем контекстную информацию + context_info = _build_context_info(args, kwargs, log_params) + + # Логируем вход в функцию (только если не тихий режим) + if not quiet: + log_method = getattr(logger, log_level) + log_method(f"🚀 Начало выполнения {name}{context_info}") + + try: + result = func(*args, **kwargs) + + # Логируем успешное завершение (только если не тихий режим) + if not quiet: + result_info = "" + if log_result and result is not None: + result_info = f" | Результат: {_format_result(result)}" + + log_method(f"✅ Успешное завершение {name}{result_info}") + + return result + + except Exception as e: + # Логируем ошибку (всегда, даже в тихом режиме) + logger.error(f"❌ Ошибка в {name}: {e}") + raise + + # Возвращаем правильный wrapper в зависимости от типа функции + return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper + return decorator + + +def log_business_event( + event_name: str, + log_params: bool = True, + log_result: bool = True +): + """ + Декоратор для логирования бизнес-событий + + Args: + event_name: Название бизнес-события + log_params: Логировать ли параметры + log_result: Логировать ли результат + """ + def decorator(func: Callable) -> Callable: + @wraps(func) + async def async_wrapper(*args, **kwargs): + logger = get_logger(func.__module__) + + # Формируем контекстную информацию + context_info = _build_context_info(args, kwargs, log_params) + + # Логируем бизнес-событие + logger.info(f"📊 Бизнес-событие: {event_name}{context_info}") + + try: + result = await func(*args, **kwargs) + + # Логируем результат бизнес-события + if log_result and result is not None: + result_info = _format_result(result) + logger.info(f"📈 Результат {event_name}: {result_info}") + + return result + + except Exception as e: + logger.error(f"💥 Ошибка в бизнес-событии {event_name}: {e}") + raise + + @wraps(func) + def sync_wrapper(*args, **kwargs): + logger = get_logger(func.__module__) + + # Формируем контекстную информацию + context_info = _build_context_info(args, kwargs, log_params) + + # Логируем бизнес-событие + logger.info(f"📊 Бизнес-событие: {event_name}{context_info}") + + try: + result = func(*args, **kwargs) + + # Логируем результат бизнес-события + if log_result and result is not None: + result_info = _format_result(result) + logger.info(f"📈 Результат {event_name}: {result_info}") + + return result + + except Exception as e: + logger.error(f"💥 Ошибка в бизнес-событии {event_name}: {e}") + raise + + return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper + return decorator + + +def log_fsm_transition( + from_state: Optional[str] = None, + to_state: Optional[str] = None +): + """ + Декоратор для логирования переходов FSM состояний + + Args: + from_state: Исходное состояние (если None, будет определено автоматически) + to_state: Целевое состояние (если None, будет определено автоматически) + """ + def decorator(func: Callable) -> Callable: + @wraps(func) + async def async_wrapper(*args, **kwargs): + logger = get_logger(func.__module__) + + # Извлекаем FSM context из аргументов + fsm_context = None + for arg in args: + if hasattr(arg, 'get_state') and hasattr(arg, 'set_state'): + fsm_context = arg + break + + # Логируем переход состояния + if fsm_context: + current_state = await fsm_context.get_state() + logger.info(f"🔄 FSM переход: {current_state} -> {to_state or 'новое состояние'}") + + try: + result = await func(*args, **kwargs) + + # Логируем успешный переход + if fsm_context: + new_state = await fsm_context.get_state() + logger.info(f"✅ FSM переход завершен: {from_state or 'предыдущее состояние'} -> {new_state}") + + return result + + except Exception as e: + logger.error(f"❌ Ошибка в FSM переходе: {e}") + raise + + return async_wrapper + return decorator + + +def _build_context_info(args: tuple, kwargs: dict, log_params: bool) -> str: + """Построение контекстной информации для логов""" + if not log_params: + return "" + + context_parts = [] + + # Извлекаем информацию о пользователе из аргументов + user_id = None + for arg in args: + if isinstance(arg, (Message, CallbackQuery)): + user_id = arg.from_user.id + context_parts.append(f"user_id={user_id}") + break + elif hasattr(arg, 'from_user_id'): + user_id = arg.from_user_id + context_parts.append(f"from_user_id={user_id}") + elif hasattr(arg, 'to_user_id'): + context_parts.append(f"to_user_id={arg.to_user_id}") + elif hasattr(arg, 'id') and isinstance(arg.id, int): + context_parts.append(f"id={arg.id}") + + # Добавляем важные параметры из kwargs + important_params = ['question_id', 'user_id', 'page', 'limit', 'status'] + for param in important_params: + if param in kwargs and kwargs[param] is not None: + context_parts.append(f"{param}={kwargs[param]}") + + return f" | {', '.join(context_parts)}" if context_parts else "" + + +def _format_result(result: Any) -> str: + """Форматирование результата для логов""" + if result is None: + return "None" + + if isinstance(result, (str, int, float, bool)): + return str(result) + + if hasattr(result, 'id'): + return f"id={result.id}" + + if isinstance(result, (list, tuple)): + return f"count={len(result)}" + + if isinstance(result, dict): + return f"keys={list(result.keys())}" + + return str(type(result).__name__) + + +# Удобные алиасы для часто используемых декораторов +log_handler = log_function_call +log_service = log_function_call +log_business = log_business_event +log_fsm = log_fsm_transition + +# Тихие декораторы для middleware и служебных функций +log_quiet = lambda **kwargs: log_function_call(quiet=True, **kwargs) +log_middleware = lambda **kwargs: log_function_call(quiet=True, log_level="debug", **kwargs) + +# Декоратор для служебных функций (только ошибки) +def log_utility(func: Callable) -> Callable: + """Декоратор для служебных функций - логирует только ошибки""" + return log_function_call(quiet=True, log_params=False, log_result=False)(func) diff --git a/services/infrastructure/logging_utils.py b/services/infrastructure/logging_utils.py new file mode 100644 index 0000000..eb010e8 --- /dev/null +++ b/services/infrastructure/logging_utils.py @@ -0,0 +1,227 @@ +""" +Утилиты для контекстного логирования +""" +from typing import Any, Optional, Dict, Union +from aiogram.types import Message, CallbackQuery, User + +from services.infrastructure.logger import get_logger + + +class LoggingContext: + """Контекст для логирования с дополнительной информацией""" + + def __init__(self, module_name: str): + self.logger = get_logger(module_name) + self.context_data = {} + + def add_context(self, key: str, value: Any) -> 'LoggingContext': + """Добавить данные в контекст""" + self.context_data[key] = value + return self + + def log_info(self, message: str, **kwargs): + """Логирование с контекстом""" + context_str = self._format_context() + full_message = f"{message}{context_str}" + self.logger.info(full_message, **kwargs) + + def log_warning(self, message: str, **kwargs): + """Логирование предупреждения с контекстом""" + context_str = self._format_context() + full_message = f"{message}{context_str}" + self.logger.warning(full_message, **kwargs) + + def log_error(self, message: str, **kwargs): + """Логирование ошибки с контекстом""" + context_str = self._format_context() + full_message = f"{message}{context_str}" + self.logger.error(full_message, **kwargs) + + def _format_context(self) -> str: + """Форматирование контекстных данных""" + if not self.context_data: + return "" + + context_parts = [f"{k}={v}" for k, v in self.context_data.items()] + return f" | {', '.join(context_parts)}" + + +def get_logging_context(module_name: str) -> LoggingContext: + """Получить контекст логирования для модуля""" + return LoggingContext(module_name) + + +def log_user_action( + logger, + action: str, + user: Union[User, Message, CallbackQuery, int], + additional_info: Optional[Dict[str, Any]] = None +): + """ + Логирование действий пользователя + + Args: + logger: Логгер + action: Действие пользователя + user: Объект пользователя, сообщение, callback или user_id + additional_info: Дополнительная информация + """ + user_id = _extract_user_id(user) + user_info = _extract_user_info(user) + + context_parts = [f"user_id={user_id}"] + if user_info: + context_parts.append(f"user_info={user_info}") + + if additional_info: + for key, value in additional_info.items(): + context_parts.append(f"{key}={value}") + + context_str = f" | {', '.join(context_parts)}" if context_parts else "" + logger.info(f"👤 {action}{context_str}") + + +def log_business_operation( + logger, + operation: str, + entity_type: str, + entity_id: Optional[Union[int, str]] = None, + additional_info: Optional[Dict[str, Any]] = None +): + """ + Логирование бизнес-операций + + Args: + logger: Логгер + operation: Операция (create, update, delete, etc.) + entity_type: Тип сущности (question, user, etc.) + entity_id: ID сущности + additional_info: Дополнительная информация + """ + context_parts = [f"operation={operation}", f"entity_type={entity_type}"] + + if entity_id is not None: + context_parts.append(f"entity_id={entity_id}") + + if additional_info: + for key, value in additional_info.items(): + context_parts.append(f"{key}={value}") + + context_str = f" | {', '.join(context_parts)}" + logger.info(f"📊 Бизнес-операция: {operation} {entity_type}{context_str}") + + +def log_fsm_event( + logger, + event: str, + state: Optional[str] = None, + user_id: Optional[int] = None, + additional_info: Optional[Dict[str, Any]] = None +): + """ + Логирование FSM событий + + Args: + logger: Логгер + event: Событие FSM + state: Текущее состояние + user_id: ID пользователя + additional_info: Дополнительная информация + """ + context_parts = [f"event={event}"] + + if state: + context_parts.append(f"state={state}") + + if user_id: + context_parts.append(f"user_id={user_id}") + + if additional_info: + for key, value in additional_info.items(): + context_parts.append(f"{key}={value}") + + context_str = f" | {', '.join(context_parts)}" + logger.info(f"🔄 FSM: {event}{context_str}") + + +def log_performance( + logger, + operation: str, + duration: float, + additional_info: Optional[Dict[str, Any]] = None +): + """ + Логирование производительности + + Args: + logger: Логгер + operation: Операция + duration: Время выполнения в секундах + additional_info: Дополнительная информация + """ + context_parts = [f"duration={duration:.3f}s"] + + if additional_info: + for key, value in additional_info.items(): + context_parts.append(f"{key}={value}") + + context_str = f" | {', '.join(context_parts)}" + logger.info(f"⏱️ Производительность: {operation}{context_str}") + + +def _extract_user_id(user: Union[User, Message, CallbackQuery, int]) -> int: + """Извлечение user_id из различных объектов""" + if isinstance(user, int): + return user + elif isinstance(user, User): + return user.id + elif isinstance(user, (Message, CallbackQuery)): + return user.from_user.id + else: + return 0 + + +def _extract_user_info(user: Union[User, Message, CallbackQuery, int]) -> Optional[str]: + """Извлечение информации о пользователе""" + if isinstance(user, int): + return None + elif isinstance(user, User): + return f"{user.first_name or ''} {user.last_name or ''}".strip() or user.username or "Unknown" + elif isinstance(user, (Message, CallbackQuery)): + user_obj = user.from_user + return f"{user_obj.first_name or ''} {user_obj.last_name or ''}".strip() or user_obj.username or "Unknown" + else: + return None + + +# Удобные функции для быстрого логирования +def log_question_created(logger, question_id: int, from_user_id: int, to_user_id: int): + """Логирование создания вопроса""" + log_business_operation( + logger, "create", "question", question_id, + {"from_user_id": from_user_id, "to_user_id": to_user_id} + ) + + +def log_question_answered(logger, question_id: int, user_id: int): + """Логирование ответа на вопрос""" + log_business_operation( + logger, "answer", "question", question_id, + {"user_id": user_id} + ) + + +def log_user_created(logger, user_id: int, username: Optional[str] = None): + """Логирование создания пользователя""" + additional_info = {"username": username} if username else None + log_business_operation( + logger, "create", "user", user_id, additional_info + ) + + +def log_user_blocked(logger, user_id: int, reason: Optional[str] = None): + """Логирование блокировки пользователя""" + additional_info = {"reason": reason} if reason else None + log_business_operation( + logger, "block", "user", user_id, additional_info + ) diff --git a/services/infrastructure/metrics.py b/services/infrastructure/metrics.py new file mode 100644 index 0000000..75621f2 --- /dev/null +++ b/services/infrastructure/metrics.py @@ -0,0 +1,351 @@ +""" +Сервис для работы с Prometheus метриками +""" +import time +import inspect +from typing import Optional, Callable +from prometheus_client import Counter, Histogram, Gauge, Info, generate_latest, CONTENT_TYPE_LATEST +from loguru import logger + + + + +class MetricsService: + """Сервис для управления Prometheus метриками""" + + def __init__(self): + self._init_metrics() + + def _init_metrics(self): + """Инициализация метрик""" + + # Информация о боте + self.bot_info = Info('anon_bot_info', 'Information about the AnonBot') + self.bot_info.info({ + 'version': '1.0.0', + 'service': 'anon-bot' + }) + + # Счетчики сообщений + self.messages_total = Counter( + 'anon_bot_messages_total', + 'Total number of messages processed', + ['message_type', 'status'] + ) + + # Счетчики вопросов + self.questions_total = Counter( + 'anon_bot_questions_total', + 'Total number of questions received', + ['status'] + ) + + # Счетчики ответов + self.answers_total = Counter( + 'anon_bot_answers_total', + 'Total number of answers sent', + ['status'] + ) + + # Счетчики пользователей + self.users_total = Counter( + 'anon_bot_users_total', + 'Total number of users', + ['action'] + ) + + # Время обработки сообщений + self.message_processing_time = Histogram( + 'anon_bot_message_processing_seconds', + 'Time spent processing messages', + ['message_type'], + buckets=[0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0] + ) + + # Время обработки вопросов + self.question_processing_time = Histogram( + 'anon_bot_question_processing_seconds', + 'Time spent processing questions', + buckets=[0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0] + ) + + # Время обработки ответов + self.answer_processing_time = Histogram( + 'anon_bot_answer_processing_seconds', + 'Time spent processing answers', + buckets=[0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0] + ) + + # Активные пользователи + self.active_users = Gauge( + 'anon_bot_active_users', + 'Number of active users' + ) + + # Активные вопросы + self.active_questions = Gauge( + 'anon_bot_active_questions', + 'Number of active questions' + ) + + # Ошибки + self.errors_total = Counter( + 'anon_bot_errors_total', + 'Total number of errors', + ['error_type', 'component'] + ) + + # HTTP запросы к эндпоинтам + self.http_requests_total = Counter( + 'anon_bot_http_requests_total', + 'Total number of HTTP requests', + ['method', 'endpoint', 'status_code'] + ) + + # Метрики производительности БД + self.db_queries_total = Counter( + 'anon_bot_db_queries_total', + 'Total number of database queries', + ['operation', 'table', 'status'] + ) + + self.db_query_duration = Histogram( + 'anon_bot_db_query_duration_seconds', + 'Database query duration', + ['operation', 'table'], + buckets=[0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0] + ) + + self.db_connections_active = Gauge( + 'anon_bot_db_connections_active', + 'Number of active database connections' + ) + + self.db_connections_total = Counter( + 'anon_bot_db_connections_total', + 'Total number of database connections', + ['status'] + ) + + # Метрики пагинации + self.pagination_requests_total = Counter( + 'anon_bot_pagination_requests_total', + 'Total number of pagination requests', + ['entity_type', 'method'] + ) + + self.pagination_duration = Histogram( + 'anon_bot_pagination_duration_seconds', + 'Pagination operation duration', + ['entity_type', 'method'], + buckets=[0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0] + ) + + self.pagination_errors_total = Counter( + 'anon_bot_pagination_errors_total', + 'Total number of pagination errors', + ['entity_type', 'error_type'] + ) + + # Метрики batch операций + self.batch_operations_total = Counter( + 'anon_bot_batch_operations_total', + 'Total number of batch operations', + ['operation', 'table', 'status'] + ) + + self.batch_operation_duration = Histogram( + 'anon_bot_batch_operation_duration_seconds', + 'Batch operation duration', + ['operation', 'table'], + buckets=[0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0] + ) + + self.batch_operation_size = Histogram( + 'anon_bot_batch_operation_size', + 'Batch operation size (number of items)', + ['operation', 'table'], + buckets=[1, 5, 10, 25, 50, 100, 250, 500, 1000] + ) + + # Время ответа HTTP эндпоинтов + self.http_request_duration = Histogram( + 'anon_bot_http_request_duration_seconds', + 'HTTP request duration', + ['method', 'endpoint'], + buckets=[0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0] + ) + + logger.info("Prometheus metrics initialized") + + def increment_messages(self, message_type: str, status: str = "success"): + """Увеличить счетчик сообщений""" + self.messages_total.labels(message_type=message_type, status=status).inc() + + def increment_questions(self, status: str = "received"): + """Увеличить счетчик вопросов""" + self.questions_total.labels(status=status).inc() + + def increment_answers(self, status: str = "sent"): + """Увеличить счетчик ответов""" + self.answers_total.labels(status=status).inc() + + def increment_users(self, action: str): + """Увеличить счетчик пользователей""" + self.users_total.labels(action=action).inc() + + def increment_errors(self, error_type: str, component: str): + """Увеличить счетчик ошибок""" + self.errors_total.labels(error_type=error_type, component=component).inc() + + def increment_http_requests(self, method: str, endpoint: str, status_code: int): + """Увеличить счетчик HTTP запросов""" + self.http_requests_total.labels( + method=method, + endpoint=endpoint, + status_code=status_code + ).inc() + + def set_active_users(self, count: int): + """Установить количество активных пользователей""" + self.active_users.set(count) + + def set_active_questions(self, count: int): + """Установить количество активных вопросов""" + self.active_questions.set(count) + + def record_message_processing_time(self, message_type: str, duration: float): + """Записать время обработки сообщения""" + self.message_processing_time.labels(message_type=message_type).observe(duration) + + def record_question_processing_time(self, duration: float): + """Записать время обработки вопроса""" + self.question_processing_time.observe(duration) + + def record_answer_processing_time(self, duration: float): + """Записать время обработки ответа""" + self.answer_processing_time.observe(duration) + + def record_http_request_duration(self, method: str, endpoint: str, duration: float): + """Записать время обработки HTTP запроса""" + self.http_request_duration.labels(method=method, endpoint=endpoint).observe(duration) + + # Методы для метрик БД + def record_db_query(self, operation: str, table: str, status: str, duration: float): + """Записать метрики запроса к БД""" + self.db_queries_total.labels(operation=operation, table=table, status=status).inc() + self.db_query_duration.labels(operation=operation, table=table).observe(duration) + + def record_db_connection(self, status: str): + """Записать метрики подключения к БД""" + self.db_connections_total.labels(status=status).inc() + if status == "opened": + self.db_connections_active.inc() + elif status == "closed": + self.db_connections_active.dec() + + def record_pagination_time(self, entity_type: str, duration: float, method: str = "cursor"): + """Записать время пагинации""" + self.pagination_requests_total.labels(entity_type=entity_type, method=method).inc() + self.pagination_duration.labels(entity_type=entity_type, method=method).observe(duration) + + def increment_pagination_requests(self, entity_type: str, method: str = "cursor"): + """Увеличить счетчик запросов пагинации""" + self.pagination_requests_total.labels(entity_type=entity_type, method=method).inc() + + def increment_pagination_errors(self, entity_type: str, error_type: str = "unknown"): + """Увеличить счетчик ошибок пагинации""" + self.pagination_errors_total.labels(entity_type=entity_type, error_type=error_type).inc() + + def record_batch_operation(self, operation: str, table: str, status: str, duration: float, size: int): + """Записать метрики batch операции""" + self.batch_operations_total.labels(operation=operation, table=table, status=status).inc() + self.batch_operation_duration.labels(operation=operation, table=table).observe(duration) + self.batch_operation_size.labels(operation=operation, table=table).observe(size) + + def get_metrics(self) -> str: + """Получить метрики в формате Prometheus""" + return generate_latest() + + def get_content_type(self) -> str: + """Получить Content-Type для метрик""" + return CONTENT_TYPE_LATEST + + +# Глобальный экземпляр сервиса метрик +metrics_service = MetricsService() + + +def get_metrics_service() -> MetricsService: + """Получить экземпляр сервиса метрик""" + return metrics_service + + +# Декораторы для автоматического сбора метрик +def track_message_processing(message_type: str): + """Декоратор для отслеживания обработки сообщений""" + def decorator(func): + async def wrapper(*args, **kwargs): + # Убираем dispatcher, если он есть, так как он не нужен + kwargs.pop('dispatcher', None) + + start_time = time.time() + try: + result = await func(*args, **kwargs) + metrics_service.increment_messages(message_type, "success") + return result + except Exception as e: + metrics_service.increment_messages(message_type, "error") + metrics_service.increment_errors(type(e).__name__, "message_processing") + raise + finally: + duration = time.time() - start_time + metrics_service.record_message_processing_time(message_type, duration) + return wrapper + return decorator + + +def track_question_processing(): + """Декоратор для отслеживания обработки вопросов""" + def decorator(func): + async def wrapper(*args, **kwargs): + # Убираем dispatcher, если он есть, так как он не нужен + kwargs.pop('dispatcher', None) + + start_time = time.time() + try: + result = await func(*args, **kwargs) + metrics_service.increment_questions("processed") + return result + except Exception as e: + metrics_service.increment_questions("error") + metrics_service.increment_errors(type(e).__name__, "question_processing") + raise + finally: + duration = time.time() - start_time + metrics_service.record_question_processing_time(duration) + return wrapper + return decorator + + +def track_answer_processing(): + """Декоратор для отслеживания обработки ответов""" + def decorator(func): + async def wrapper(*args, **kwargs): + # Убираем dispatcher, если он есть, так как он не нужен + kwargs.pop('dispatcher', None) + + start_time = time.time() + try: + result = await func(*args, **kwargs) + metrics_service.increment_answers("sent") + return result + except Exception as e: + metrics_service.increment_answers("error") + metrics_service.increment_errors(type(e).__name__, "answer_processing") + raise + finally: + duration = time.time() - start_time + metrics_service.record_answer_processing_time(duration) + return wrapper + return decorator diff --git a/services/infrastructure/pid_manager.py b/services/infrastructure/pid_manager.py new file mode 100644 index 0000000..32b227a --- /dev/null +++ b/services/infrastructure/pid_manager.py @@ -0,0 +1,117 @@ +""" +PID менеджер для управления PID файлом процесса +""" +import os +import sys +from pathlib import Path +from typing import Optional + +from loguru import logger + + +class PIDManager: + """Менеджер для управления PID файлом процесса""" + + def __init__(self, service_name: str = "anon_bot", pid_dir: str = "/tmp"): + self.service_name = service_name + self.pid_dir = Path(pid_dir) + self.pid_file_path = self.pid_dir / f"{service_name}.pid" + self.pid: Optional[int] = None + + def create_pid_file(self) -> bool: + """Создать PID файл""" + try: + # Создаем директорию для PID файлов, если она не существует + self.pid_dir.mkdir(parents=True, exist_ok=True) + + # Проверяем, не запущен ли уже процесс + if self.pid_file_path.exists(): + try: + with open(self.pid_file_path, 'r') as f: + existing_pid = int(f.read().strip()) + + # Проверяем, жив ли процесс с этим PID + if self._is_process_running(existing_pid): + logger.error(f"Процесс {self.service_name} уже запущен с PID {existing_pid}") + return False + else: + logger.warning(f"Найден устаревший PID файл для {existing_pid}, удаляем его") + self.pid_file_path.unlink() + + except (ValueError, OSError) as e: + logger.warning(f"Не удалось прочитать существующий PID файл: {e}, удаляем его") + self.pid_file_path.unlink() + + # Получаем PID текущего процесса + self.pid = os.getpid() + + # Создаем PID файл + with open(self.pid_file_path, 'w') as f: + f.write(str(self.pid)) + + logger.info(f"PID файл создан: {self.pid_file_path} (PID: {self.pid})") + return True + + except Exception as e: + logger.error(f"Не удалось создать PID файл: {e}") + return False + + def cleanup_pid_file(self) -> None: + """Очистить PID файл""" + try: + if self.pid_file_path.exists(): + # Проверяем, что PID файл принадлежит нашему процессу + with open(self.pid_file_path, 'r') as f: + file_pid = int(f.read().strip()) + + if file_pid == self.pid: + self.pid_file_path.unlink() + logger.info(f"PID файл удален: {self.pid_file_path}") + else: + logger.warning(f"PID файл содержит другой PID ({file_pid}), не удаляем") + + except Exception as e: + logger.error(f"Ошибка при удалении PID файла: {e}") + + def get_pid(self) -> Optional[int]: + """Получить PID процесса""" + return self.pid + + def get_pid_file_path(self) -> Path: + """Получить путь к PID файлу""" + return self.pid_file_path + + + def _is_process_running(self, pid: int) -> bool: + """Проверить, запущен ли процесс с указанным PID""" + try: + # В Unix-системах отправляем сигнал 0 для проверки существования процесса + os.kill(pid, 0) + return True + except (OSError, ProcessLookupError): + return False + + + +# Глобальный экземпляр PID менеджера +_pid_manager: Optional[PIDManager] = None + + +def get_pid_manager(service_name: str = "anon_bot", pid_dir: str = "/tmp") -> PIDManager: + """Получить экземпляр PID менеджера""" + global _pid_manager + if _pid_manager is None: + _pid_manager = PIDManager(service_name, pid_dir) + return _pid_manager + + +def create_pid_file(service_name: str = "anon_bot", pid_dir: str = "/tmp") -> bool: + """Создать PID файл""" + pid_manager = get_pid_manager(service_name, pid_dir) + return pid_manager.create_pid_file() + + +def cleanup_pid_file() -> None: + """Очистить PID файл""" + if _pid_manager: + _pid_manager.cleanup_pid_file() diff --git a/services/permissions/__init__.py b/services/permissions/__init__.py new file mode 100644 index 0000000..ff77b56 --- /dev/null +++ b/services/permissions/__init__.py @@ -0,0 +1,20 @@ +""" +Система разрешений для AnonBot +Соблюдает принцип открытости/закрытости (OCP) +""" + +from .base import Permission, PermissionChecker, PermissionRegistry +from .decorators import require_permission, require_admin, require_superuser +from .registry import get_permission_registry, get_permission_checker, init_permission_checker + +__all__ = [ + 'Permission', + 'PermissionChecker', + 'PermissionRegistry', + 'require_permission', + 'require_admin', + 'require_superuser', + 'get_permission_registry', + 'get_permission_checker', + 'init_permission_checker' +] diff --git a/services/permissions/base.py b/services/permissions/base.py new file mode 100644 index 0000000..9aeb0a9 --- /dev/null +++ b/services/permissions/base.py @@ -0,0 +1,165 @@ +""" +Базовые классы для системы разрешений +""" +from abc import ABC, abstractmethod +from typing import Dict, Type, Optional, Any +from services.infrastructure.database import DatabaseService +from services.infrastructure.logger import get_logger + +logger = get_logger(__name__) + + +class Permission(ABC): + """ + Абстрактный базовый класс для всех разрешений. + Соблюдает принцип открытости/закрытости (OCP). + """ + + def __init__(self, name: str, description: str = ""): + self.name = name + self.description = description + + @abstractmethod + async def check(self, user_id: int, database: DatabaseService, config: Any) -> bool: + """ + Проверка разрешения для пользователя + + Args: + user_id: ID пользователя в Telegram + database: Сервис базы данных + config: Конфигурация приложения + + Returns: + True если у пользователя есть разрешение, False иначе + """ + pass + + def __str__(self) -> str: + return f"Permission({self.name})" + + def __repr__(self) -> str: + return f"Permission(name='{self.name}', description='{self.description}')" + + +class PermissionRegistry: + """ + Реестр разрешений. Позволяет регистрировать и получать разрешения. + """ + + def __init__(self): + self._permissions: Dict[str, Permission] = {} + + def register(self, permission: Permission) -> None: + """ + Регистрация разрешения + + Args: + permission: Разрешение для регистрации + """ + if permission.name in self._permissions: + logger.warning(f"Разрешение '{permission.name}' уже зарегистрировано. Перезаписываем.") + + self._permissions[permission.name] = permission + logger.debug(f"Зарегистрировано разрешение: {permission}") + + def get(self, name: str) -> Optional[Permission]: + """ + Получение разрешения по имени + + Args: + name: Имя разрешения + + Returns: + Разрешение или None если не найдено + """ + return self._permissions.get(name) + + def get_all(self) -> Dict[str, Permission]: + """ + Получение всех зарегистрированных разрешений + + Returns: + Словарь всех разрешений + """ + return self._permissions.copy() + + def is_registered(self, name: str) -> bool: + """ + Проверка, зарегистрировано ли разрешение + + Args: + name: Имя разрешения + + Returns: + True если разрешение зарегистрировано, False иначе + """ + return name in self._permissions + + +class PermissionChecker: + """ + Сервис для проверки разрешений пользователей. + Использует реестр разрешений для получения логики проверки. + """ + + def __init__(self, registry: PermissionRegistry, database: DatabaseService, config: Any): + self.registry = registry + self.database = database + self.config = config + + async def has_permission(self, user_id: int, permission_name: str) -> bool: + """ + Проверка наличия разрешения у пользователя + + Args: + user_id: ID пользователя в Telegram + permission_name: Имя разрешения + + Returns: + True если у пользователя есть разрешение, False иначе + """ + try: + permission = self.registry.get(permission_name) + if not permission: + logger.warning(f"Разрешение '{permission_name}' не найдено в реестре") + return False + + result = await permission.check(user_id, self.database, self.config) + logger.debug(f"Проверка разрешения '{permission_name}' для пользователя {user_id}: {result}") + return result + + except Exception as e: + logger.error(f"Ошибка при проверке разрешения '{permission_name}' для пользователя {user_id}: {e}") + return False + + async def has_any_permission(self, user_id: int, permission_names: list[str]) -> bool: + """ + Проверка наличия хотя бы одного из разрешений у пользователя + + Args: + user_id: ID пользователя в Telegram + permission_names: Список имен разрешений + + Returns: + True если у пользователя есть хотя бы одно разрешение, False иначе + """ + for permission_name in permission_names: + if await self.has_permission(user_id, permission_name): + return True + return False + + async def has_all_permissions(self, user_id: int, permission_names: list[str]) -> bool: + """ + Проверка наличия всех разрешений у пользователя + + Args: + user_id: ID пользователя в Telegram + permission_names: Список имен разрешений + + Returns: + True если у пользователя есть все разрешения, False иначе + """ + for permission_name in permission_names: + if not await self.has_permission(user_id, permission_name): + return False + return True diff --git a/services/permissions/decorators.py b/services/permissions/decorators.py new file mode 100644 index 0000000..43e8a26 --- /dev/null +++ b/services/permissions/decorators.py @@ -0,0 +1,141 @@ +""" +Декораторы для проверки разрешений +""" +from functools import wraps +from typing import Callable, Any, Union +from aiogram.types import Message, CallbackQuery +from services.infrastructure.logger import get_logger +from .registry import get_permission_checker + +logger = get_logger(__name__) + + +def require_permission(permission_name: str, error_message: str = "❌ У вас нет прав для выполнения этой команды."): + """ + Декоратор для проверки разрешения пользователя + + Args: + permission_name: Имя разрешения для проверки + error_message: Сообщение об ошибке при отсутствии разрешения + """ + def decorator(func: Callable) -> Callable: + @wraps(func) + async def wrapper(*args, **kwargs): + # Извлекаем объект события (Message или CallbackQuery) + event = None + for arg in args: + if isinstance(arg, (Message, CallbackQuery)): + event = arg + break + + if not event: + logger.error("Не удалось найти объект события для проверки разрешения") + return await func(*args, **kwargs) + + # Получаем проверщик разрешений + checker = get_permission_checker() + if not checker: + logger.error("Проверщик разрешений не инициализирован") + return await func(*args, **kwargs) + + # Проверяем разрешение + user_id = event.from_user.id + has_permission = await checker.has_permission(user_id, permission_name) + + if not has_permission: + if isinstance(event, Message): + await event.answer(error_message) + elif isinstance(event, CallbackQuery): + await event.answer(error_message, show_alert=True) + return + + # Выполняем оригинальную функцию + return await func(*args, **kwargs) + + return wrapper + return decorator + + +def require_admin(error_message: str = "❌ У вас нет прав администратора."): + """ + Декоратор для проверки прав администратора + + Args: + error_message: Сообщение об ошибке при отсутствии прав администратора + """ + return require_permission("admin", error_message) + + +def require_superuser(error_message: str = "❌ У вас нет прав суперпользователя."): + """ + Декоратор для проверки прав суперпользователя + + Args: + error_message: Сообщение об ошибке при отсутствии прав суперпользователя + """ + return require_permission("superuser", error_message) + + +def require_admin_or_superuser(error_message: str = "❌ У вас нет прав для выполнения этой команды."): + """ + Декоратор для проверки прав администратора или суперпользователя + + Args: + error_message: Сообщение об ошибке при отсутствии прав + """ + def decorator(func: Callable) -> Callable: + @wraps(func) + async def wrapper(*args, **kwargs): + # Извлекаем объект события (Message или CallbackQuery) + event = None + for arg in args: + if isinstance(arg, (Message, CallbackQuery)): + event = arg + break + + if not event: + logger.error("Не удалось найти объект события для проверки разрешения") + return await func(*args, **kwargs) + + # Получаем проверщик разрешений + checker = get_permission_checker() + if not checker: + logger.error("Проверщик разрешений не инициализирован") + return await func(*args, **kwargs) + + # Проверяем права администратора или суперпользователя + user_id = event.from_user.id + has_permission = await checker.has_any_permission(user_id, ["admin", "superuser"]) + + if not has_permission: + if isinstance(event, Message): + await event.answer(error_message) + elif isinstance(event, CallbackQuery): + await event.answer(error_message, show_alert=True) + return + + # Выполняем оригинальную функцию + return await func(*args, **kwargs) + + return wrapper + return decorator + + +def require_active_user(error_message: str = "❌ Ваш аккаунт неактивен."): + """ + Декоратор для проверки активности пользователя + + Args: + error_message: Сообщение об ошибке при неактивном аккаунте + """ + return require_permission("view_questions", error_message) + + +def require_unbanned_user(error_message: str = "❌ Ваш аккаунт заблокирован."): + """ + Декоратор для проверки, что пользователь не забанен + + Args: + error_message: Сообщение об ошибке при заблокированном аккаунте + """ + return require_permission("ask_questions", error_message) diff --git a/services/permissions/init_permissions.py b/services/permissions/init_permissions.py new file mode 100644 index 0000000..53e4ddc --- /dev/null +++ b/services/permissions/init_permissions.py @@ -0,0 +1,55 @@ +""" +Инициализация системы разрешений +Автоматически регистрирует все доступные разрешения +""" +from .registry import get_permission_registry, register_permission +from .permissions import ( + AdminPermission, + SuperuserPermission, + ViewStatsPermission, + AdminPanelPermission, + ManageUsersPermission, + BroadcastPermission, + SuperuserOnlyPermission, + ViewQuestionsPermission, + AskQuestionsPermission, + AnswerQuestionsPermission +) +from services.infrastructure.logger import get_logger + +logger = get_logger(__name__) + + +def init_all_permissions(): + """ + Инициализация всех разрешений в системе + """ + logger.info("Начинаем инициализацию системы разрешений...") + + # Список всех разрешений для регистрации + permissions = [ + AdminPermission(), + SuperuserPermission(), + ViewStatsPermission(), + AdminPanelPermission(), + ManageUsersPermission(), + BroadcastPermission(), + SuperuserOnlyPermission(), + ViewQuestionsPermission(), + AskQuestionsPermission(), + AnswerQuestionsPermission(), + ] + + # Регистрируем все разрешения + for permission in permissions: + register_permission(permission) + logger.debug(f"Зарегистрировано разрешение: {permission.name}") + + registry = get_permission_registry() + total_permissions = len(registry.get_all()) + + logger.info(f"✅ Система разрешений инициализирована. Зарегистрировано {total_permissions} разрешений") + + return registry + + diff --git a/services/permissions/permissions.py b/services/permissions/permissions.py new file mode 100644 index 0000000..1f9fb61 --- /dev/null +++ b/services/permissions/permissions.py @@ -0,0 +1,196 @@ +""" +Конкретные реализации разрешений +Каждое разрешение - отдельный класс, что позволяет легко добавлять новые без изменения существующего кода +""" +from .base import Permission +from services.infrastructure.database import DatabaseService +from services.infrastructure.logger import get_logger + +logger = get_logger(__name__) + + +class AdminPermission(Permission): + """Разрешение для администраторов""" + + def __init__(self): + super().__init__( + name="admin", + description="Права администратора" + ) + + async def check(self, user_id: int, database: DatabaseService, config) -> bool: + """Проверка, является ли пользователь администратором""" + return user_id in config.ADMINS + + +class SuperuserPermission(Permission): + """Разрешение для суперпользователей""" + + def __init__(self): + super().__init__( + name="superuser", + description="Права суперпользователя" + ) + + async def check(self, user_id: int, database: DatabaseService, config) -> bool: + """Проверка, является ли пользователь суперпользователем""" + try: + user = await database.get_user(user_id) + return user.is_superuser if user else False + except Exception as e: + logger.error(f"Ошибка при проверке суперпользователя {user_id}: {e}") + return False + + +class ViewStatsPermission(Permission): + """Разрешение на просмотр статистики""" + + def __init__(self): + super().__init__( + name="view_stats", + description="Просмотр статистики" + ) + + async def check(self, user_id: int, database: DatabaseService, config) -> bool: + """Проверка права на просмотр статистики""" + # Администраторы и суперпользователи могут просматривать статистику + admin_permission = AdminPermission() + superuser_permission = SuperuserPermission() + + is_admin = await admin_permission.check(user_id, database, config) + is_superuser = await superuser_permission.check(user_id, database, config) + + return is_admin or is_superuser + + +class AdminPanelPermission(Permission): + """Разрешение на доступ к админ панели""" + + def __init__(self): + super().__init__( + name="admin_panel", + description="Доступ к админ панели" + ) + + async def check(self, user_id: int, database: DatabaseService, config) -> bool: + """Проверка права на доступ к админ панели""" + # Администраторы и суперпользователи могут использовать админ панель + admin_permission = AdminPermission() + superuser_permission = SuperuserPermission() + + is_admin = await admin_permission.check(user_id, database, config) + is_superuser = await superuser_permission.check(user_id, database, config) + + return is_admin or is_superuser + + +class ManageUsersPermission(Permission): + """Разрешение на управление пользователями""" + + def __init__(self): + super().__init__( + name="manage_users", + description="Управление пользователями" + ) + + async def check(self, user_id: int, database: DatabaseService, config) -> bool: + """Проверка права на управление пользователями""" + # Администраторы и суперпользователи могут управлять пользователями + admin_permission = AdminPermission() + superuser_permission = SuperuserPermission() + + is_admin = await admin_permission.check(user_id, database, config) + is_superuser = await superuser_permission.check(user_id, database, config) + + return is_admin or is_superuser + + +class BroadcastPermission(Permission): + """Разрешение на рассылку сообщений""" + + def __init__(self): + super().__init__( + name="broadcast", + description="Рассылка сообщений" + ) + + async def check(self, user_id: int, database: DatabaseService, config) -> bool: + """Проверка права на рассылку""" + # Только администраторы могут делать рассылку + admin_permission = AdminPermission() + return await admin_permission.check(user_id, database, config) + + +class SuperuserOnlyPermission(Permission): + """Разрешение только для суперпользователей""" + + def __init__(self): + super().__init__( + name="superuser_only", + description="Только для суперпользователей" + ) + + async def check(self, user_id: int, database: DatabaseService, config) -> bool: + """Проверка права только для суперпользователей""" + superuser_permission = SuperuserPermission() + return await superuser_permission.check(user_id, database, config) + + +class ViewQuestionsPermission(Permission): + """Разрешение на просмотр вопросов""" + + def __init__(self): + super().__init__( + name="view_questions", + description="Просмотр вопросов" + ) + + async def check(self, user_id: int, database: DatabaseService, config) -> bool: + """Проверка права на просмотр вопросов""" + # Все активные пользователи могут просматривать вопросы + try: + user = await database.get_user(user_id) + return user.is_active if user else False + except Exception as e: + logger.error(f"Ошибка при проверке активности пользователя {user_id}: {e}") + return False + + +class AskQuestionsPermission(Permission): + """Разрешение на задавание вопросов""" + + def __init__(self): + super().__init__( + name="ask_questions", + description="Задавание вопросов" + ) + + async def check(self, user_id: int, database: DatabaseService, config) -> bool: + """Проверка права на задавание вопросов""" + # Все активные пользователи могут задавать вопросы + try: + user = await database.get_user(user_id) + return user.is_active and not user.is_banned if user else False + except Exception as e: + logger.error(f"Ошибка при проверке права задавать вопросы для пользователя {user_id}: {e}") + return False + + +class AnswerQuestionsPermission(Permission): + """Разрешение на ответы на вопросы""" + + def __init__(self): + super().__init__( + name="answer_questions", + description="Ответы на вопросы" + ) + + async def check(self, user_id: int, database: DatabaseService, config) -> bool: + """Проверка права на ответы на вопросы""" + # Все активные пользователи могут отвечать на вопросы + try: + user = await database.get_user(user_id) + return user.is_active and not user.is_banned if user else False + except Exception as e: + logger.error(f"Ошибка при проверке права отвечать на вопросы для пользователя {user_id}: {e}") + return False diff --git a/services/permissions/registry.py b/services/permissions/registry.py new file mode 100644 index 0000000..dacf438 --- /dev/null +++ b/services/permissions/registry.py @@ -0,0 +1,66 @@ +""" +Глобальный реестр разрешений и фабричные функции +""" +from typing import Optional +from .base import PermissionRegistry, PermissionChecker, Permission +from services.infrastructure.database import DatabaseService +from services.infrastructure.logger import get_logger + +logger = get_logger(__name__) + +# Глобальный реестр разрешений +_permission_registry: Optional[PermissionRegistry] = None +_permission_checker: Optional[PermissionChecker] = None + + +def get_permission_registry() -> PermissionRegistry: + """ + Получение глобального реестра разрешений + + Returns: + Глобальный экземпляр реестра разрешений + """ + global _permission_registry + if _permission_registry is None: + _permission_registry = PermissionRegistry() + logger.info("Создан глобальный реестр разрешений") + return _permission_registry + + +def get_permission_checker() -> Optional[PermissionChecker]: + """ + Получение глобального проверщика разрешений + + Returns: + Глобальный экземпляр проверщика разрешений или None если не инициализирован + """ + return _permission_checker + + +def init_permission_checker(database: DatabaseService, config) -> PermissionChecker: + """ + Инициализация глобального проверщика разрешений + + Args: + database: Сервис базы данных + config: Конфигурация приложения + + Returns: + Инициализированный проверщик разрешений + """ + global _permission_checker + registry = get_permission_registry() + _permission_checker = PermissionChecker(registry, database, config) + logger.info("Инициализирован глобальный проверщик разрешений") + return _permission_checker + + +def register_permission(permission: Permission) -> None: + """ + Регистрация разрешения в глобальном реестре + + Args: + permission: Разрешение для регистрации + """ + registry = get_permission_registry() + registry.register(permission) diff --git a/services/rate_limiting/__init__.py b/services/rate_limiting/__init__.py new file mode 100644 index 0000000..bdaf018 --- /dev/null +++ b/services/rate_limiting/__init__.py @@ -0,0 +1,13 @@ +""" +Rate limiting сервисы +""" + +from .rate_limit_config import RateLimitSettings, get_rate_limit_config, get_adaptive_config +from .rate_limiter import RateLimitConfig, send_with_rate_limit, telegram_rate_limiter +from .rate_limit_service import RateLimitService + +__all__ = [ + 'RateLimitSettings', 'get_rate_limit_config', 'get_adaptive_config', + 'RateLimitConfig', 'send_with_rate_limit', 'telegram_rate_limiter', + 'RateLimitService' +] diff --git a/services/rate_limiting/rate_limit_config.py b/services/rate_limiting/rate_limit_config.py new file mode 100644 index 0000000..5f16e15 --- /dev/null +++ b/services/rate_limiting/rate_limit_config.py @@ -0,0 +1,150 @@ +""" +Конфигурация для rate limiting в AnonBot +""" +import os +from dataclasses import dataclass +from typing import Optional +from dotenv import load_dotenv + +# Загружаем переменные окружения +load_dotenv() + + +@dataclass +class RateLimitSettings: + """Настройки rate limiting для разных типов сообщений""" + + # Основные настройки + messages_per_second: float = float(os.getenv('RATE_LIMIT_MESSAGES_PER_SECOND', '0.5')) # Максимум 0.5 сообщений в секунду на чат + burst_limit: int = int(os.getenv('RATE_LIMIT_BURST_LIMIT', '2')) # Максимум 2 сообщения подряд + retry_after_multiplier: float = float(os.getenv('RATE_LIMIT_RETRY_MULTIPLIER', '1.5')) # Множитель для увеличения задержки при retry + max_retry_delay: float = float(os.getenv('RATE_LIMIT_MAX_RETRY_DELAY', '30.0')) # Максимальная задержка между попытками + max_retries: int = int(os.getenv('RATE_LIMIT_MAX_RETRIES', '3')) # Максимальное количество повторных попыток + + # Специальные настройки для разных типов сообщений + voice_message_delay: float = float(os.getenv('RATE_LIMIT_VOICE_DELAY', '2.0')) # Дополнительная задержка для голосовых сообщений + media_message_delay: float = float(os.getenv('RATE_LIMIT_MEDIA_DELAY', '1.5')) # Дополнительная задержка для медиа сообщений + text_message_delay: float = float(os.getenv('RATE_LIMIT_TEXT_DELAY', '1.0')) # Дополнительная задержка для текстовых сообщений + + # Настройки для разных типов чатов + private_chat_multiplier: float = float(os.getenv('RATE_LIMIT_PRIVATE_MULTIPLIER', '1.0')) # Множитель для приватных чатов + group_chat_multiplier: float = float(os.getenv('RATE_LIMIT_GROUP_MULTIPLIER', '0.8')) # Множитель для групповых чатов + channel_multiplier: float = float(os.getenv('RATE_LIMIT_CHANNEL_MULTIPLIER', '0.6')) # Множитель для каналов + + # Глобальные ограничения + global_messages_per_second: float = float(os.getenv('RATE_LIMIT_GLOBAL_MESSAGES_PER_SECOND', '10.0')) # Максимум 10 сообщений в секунду глобально + global_burst_limit: int = int(os.getenv('RATE_LIMIT_GLOBAL_BURST_LIMIT', '20')) # Максимум 20 сообщений подряд глобально + + +# Конфигурации для разных сценариев использования +# Основаны на официальных лимитах Telegram Bot API: +# - 1 сообщение в секунду в личных чатах +# - 20 сообщений в минуту в групповых чатах (0.33 в секунду) +# - 30 запросов в секунду глобально + +DEVELOPMENT_CONFIG = RateLimitSettings( + messages_per_second=0.8, # Более мягкие ограничения для разработки (80% от лимита) + burst_limit=3, # До 3 сообщений подряд + retry_after_multiplier=1.2, + max_retry_delay=15.0, + max_retries=2, + voice_message_delay=1.5, + media_message_delay=1.2, + text_message_delay=1.0 +) + +PRODUCTION_CONFIG = RateLimitSettings( + messages_per_second=0.5, # Консервативные ограничения (50% от лимита) + 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, # Дополнительная задержка для текста + global_messages_per_second=20.0, # 20 из 30 доступных запросов в секунду + global_burst_limit=15 # До 15 сообщений подряд глобально +) + +STRICT_CONFIG = RateLimitSettings( + messages_per_second=0.3, # Очень консервативные ограничения (30% от лимита) + burst_limit=1, # Только 1 сообщение подряд + retry_after_multiplier=2.0, + max_retry_delay=60.0, + max_retries=5, + voice_message_delay=3.0, + media_message_delay=2.5, + text_message_delay=2.0, + global_messages_per_second=10.0, # 10 из 30 доступных запросов в секунду + global_burst_limit=8 # До 8 сообщений подряд глобально +) + + +def get_rate_limit_config(environment: str = None) -> RateLimitSettings: + """ + Получает конфигурацию rate limiting в зависимости от окружения + + Args: + environment: Окружение ('development', 'production', 'strict') + Если не указано, берется из переменной окружения RATE_LIMIT_ENV + + Returns: + RateLimitSettings: Конфигурация для указанного окружения + """ + if environment is None: + environment = os.getenv('RATE_LIMIT_ENV', 'production') + + configs = { + "development": DEVELOPMENT_CONFIG, + "production": PRODUCTION_CONFIG, + "strict": STRICT_CONFIG + } + + return configs.get(environment, PRODUCTION_CONFIG) + + +def get_adaptive_config( + current_error_rate: float, + base_config: Optional[RateLimitSettings] = None +) -> RateLimitSettings: + """ + Получает адаптивную конфигурацию на основе текущего уровня ошибок + + Args: + current_error_rate: Текущий уровень ошибок (0.0 - 1.0) + base_config: Базовая конфигурация + + Returns: + RateLimitSettings: Адаптированная конфигурация + """ + if base_config is None: + base_config = PRODUCTION_CONFIG + + # Если уровень ошибок высокий, ужесточаем ограничения + if current_error_rate > 0.1: # Более 10% ошибок + return RateLimitSettings( + messages_per_second=base_config.messages_per_second * 0.5, + burst_limit=max(1, base_config.burst_limit - 1), + retry_after_multiplier=base_config.retry_after_multiplier * 1.5, + max_retry_delay=base_config.max_retry_delay * 1.5, + max_retries=base_config.max_retries + 1, + voice_message_delay=base_config.voice_message_delay * 1.5, + media_message_delay=base_config.media_message_delay * 1.3, + text_message_delay=base_config.text_message_delay * 1.2 + ) + + # Если уровень ошибок низкий, можно немного ослабить ограничения + elif current_error_rate < 0.01: # Менее 1% ошибок + return RateLimitSettings( + messages_per_second=base_config.messages_per_second * 1.2, + burst_limit=base_config.burst_limit + 1, + retry_after_multiplier=base_config.retry_after_multiplier * 0.9, + max_retry_delay=base_config.max_retry_delay * 0.8, + max_retries=max(1, base_config.max_retries - 1), + voice_message_delay=base_config.voice_message_delay * 0.8, + media_message_delay=base_config.media_message_delay * 0.9, + text_message_delay=base_config.text_message_delay * 0.9 + ) + + # Возвращаем базовую конфигурацию + return base_config diff --git a/services/rate_limiting/rate_limit_service.py b/services/rate_limiting/rate_limit_service.py new file mode 100644 index 0000000..9c581de --- /dev/null +++ b/services/rate_limiting/rate_limit_service.py @@ -0,0 +1,142 @@ +""" +Сервис для управления rate limiting в AnonBot +""" +from typing import Any, Callable, Dict, Optional + +from aiogram.exceptions import TelegramAPIError, TelegramRetryAfter + +from config.constants import MIN_REQUESTS_FOR_ADAPTATION, HIGH_ERROR_RATE_THRESHOLD, LOW_ERROR_RATE_THRESHOLD +from services.infrastructure.logger import get_logger +from .rate_limit_config import RateLimitSettings, get_adaptive_config, get_rate_limit_config +from .rate_limiter import send_with_rate_limit, telegram_rate_limiter + +logger = get_logger(__name__) + + +class RateLimitService: + """Сервис для управления rate limiting""" + + def __init__(self): + self.rate_limiter = telegram_rate_limiter + self.config = get_rate_limit_config() + self.stats = { + 'total_requests': 0, + 'successful_requests': 0, + 'failed_requests': 0, + 'retry_after_errors': 0, + 'other_errors': 0, + 'total_wait_time': 0.0 + } + + async def send_with_rate_limit( + self, + send_func: Callable, + chat_id: int, + *args, + **kwargs + ) -> Any: + """ + Отправляет сообщение с соблюдением rate limit + + Args: + send_func: Функция отправки (например, bot.send_message) + chat_id: ID чата + *args, **kwargs: Аргументы для функции отправки + + Returns: + Результат выполнения функции отправки + """ + self.stats['total_requests'] += 1 + logger.info(f"Обработка rate limit запроса для чата {chat_id}") + + try: + result, wait_time = await self.rate_limiter.send_with_rate_limit(send_func, chat_id, *args, **kwargs) + self.stats['successful_requests'] += 1 + self.stats['total_wait_time'] += wait_time + logger.info(f"Rate limited сообщение успешно отправлено в чат {chat_id}, время ожидания: {wait_time:.2f}с") + return result + + except TelegramRetryAfter as e: + self.stats['failed_requests'] += 1 + self.stats['retry_after_errors'] += 1 + logger.warning(f"Превышен rate limit для чата {chat_id}: {e}") + raise + + except TelegramAPIError as e: + self.stats['failed_requests'] += 1 + self.stats['other_errors'] += 1 + logger.error(f"Ошибка Telegram API для чата {chat_id}: {e}") + raise + + except Exception as e: + self.stats['failed_requests'] += 1 + self.stats['other_errors'] += 1 + logger.error(f"Неожиданная ошибка в rate limit сервисе для чата {chat_id}: {e}") + raise + + def get_stats(self) -> Dict[str, Any]: + """Получает статистику rate limiting""" + total = self.stats['total_requests'] + if total == 0: + return { + 'total_requests': 0, + 'successful_requests': 0, + 'failed_requests': 0, + 'success_rate': 0.0, + 'error_rate': 0.0, + 'retry_after_errors': 0, + 'other_errors': 0, + 'retry_after_rate': 0.0, + 'other_error_rate': 0.0, + 'average_wait_time': 0.0 + } + + return { + 'total_requests': total, + 'successful_requests': self.stats['successful_requests'], + 'failed_requests': self.stats['failed_requests'], + 'success_rate': self.stats['successful_requests'] / total, + 'error_rate': self.stats['failed_requests'] / total, + 'retry_after_errors': self.stats['retry_after_errors'], + 'other_errors': self.stats['other_errors'], + 'retry_after_rate': self.stats['retry_after_errors'] / total, + 'other_error_rate': self.stats['other_errors'] / total, + 'average_wait_time': self.stats['total_wait_time'] / total if total > 0 else 0.0 + } + + def reset_stats(self): + """Сбрасывает статистику""" + self.stats = { + 'total_requests': 0, + 'successful_requests': 0, + 'failed_requests': 0, + 'retry_after_errors': 0, + 'other_errors': 0, + 'total_wait_time': 0.0 + } + logger.info("Статистика rate limit сброшена") + + def update_config(self, new_config: RateLimitSettings): + """Обновляет конфигурацию rate limiting""" + self.config = new_config + logger.info(f"Конфигурация rate limit обновлена: {new_config}") + + def get_adaptive_config(self) -> RateLimitSettings: + """Получает адаптивную конфигурацию на основе текущей статистики""" + error_rate = self.stats['failed_requests'] / max(1, self.stats['total_requests']) + return get_adaptive_config(error_rate, self.config) + + def should_adapt_config(self) -> bool: + """Определяет, нужно ли адаптировать конфигурацию""" + if self.stats['total_requests'] < MIN_REQUESTS_FOR_ADAPTATION: # Недостаточно данных + return False + + error_rate = self.stats['failed_requests'] / self.stats['total_requests'] + return error_rate > HIGH_ERROR_RATE_THRESHOLD or error_rate < LOW_ERROR_RATE_THRESHOLD # Высокий или низкий уровень ошибок + + async def adapt_config_if_needed(self): + """Адаптирует конфигурацию если необходимо""" + if self.should_adapt_config(): + new_config = self.get_adaptive_config() + self.update_config(new_config) + logger.info("Конфигурация rate limit адаптирована на основе текущей производительности") diff --git a/services/rate_limiting/rate_limiter.py b/services/rate_limiting/rate_limiter.py new file mode 100644 index 0000000..214af14 --- /dev/null +++ b/services/rate_limiting/rate_limiter.py @@ -0,0 +1,230 @@ +""" +Rate limiter для предотвращения Flood control ошибок в Telegram Bot API +""" +import asyncio +import time +from typing import Dict, Optional, Any, Callable +from dataclasses import dataclass +from aiogram.exceptions import TelegramRetryAfter, TelegramAPIError + +from services.infrastructure.logger import get_logger +from .rate_limit_config import RateLimitSettings, get_rate_limit_config + +logger = get_logger(__name__) + + +@dataclass +class RateLimitConfig: + """Конфигурация для rate limiting""" + messages_per_second: float = 0.5 # Максимум 0.5 сообщений в секунду на чат + burst_limit: int = 3 # Максимум 3 сообщения подряд + retry_after_multiplier: float = 1.2 # Множитель для увеличения задержки при retry + max_retry_delay: float = 60.0 # Максимальная задержка между попытками + + +class ChatRateLimiter: + """Rate limiter для конкретного чата""" + + def __init__(self, config: RateLimitConfig): + self.config = config + self.last_send_time = 0.0 + self.burst_count = 0 + self.burst_reset_time = 0.0 + self.retry_delay = 1.0 + + async def wait_if_needed(self) -> None: + """Ждет если необходимо для соблюдения rate limit""" + current_time = time.time() + + # Сбрасываем счетчик burst если прошло достаточно времени + if current_time >= self.burst_reset_time: + self.burst_count = 0 + self.burst_reset_time = current_time + 1.0 + + # Проверяем burst limit + if self.burst_count >= self.config.burst_limit: + wait_time = self.burst_reset_time - current_time + if wait_time > 0: + logger.info(f"Достигнут лимит burst, ожидание {wait_time:.2f}с") + await asyncio.sleep(wait_time) + current_time = time.time() + self.burst_count = 0 + self.burst_reset_time = current_time + 1.0 + + # Проверяем минимальный интервал между сообщениями + time_since_last = current_time - self.last_send_time + min_interval = 1.0 / self.config.messages_per_second + + if time_since_last < min_interval: + wait_time = min_interval - time_since_last + logger.debug(f"Rate limiting: waiting {wait_time:.2f}s") + await asyncio.sleep(wait_time) + + # Обновляем время последней отправки + self.last_send_time = time.time() + self.burst_count += 1 + + +class GlobalRateLimiter: + """Глобальный rate limiter для всех чатов""" + + def __init__(self, config: RateLimitConfig): + self.config = config + self.chat_limiters: Dict[int, ChatRateLimiter] = {} + self.global_last_send = 0.0 + self.global_min_interval = 0.1 # Минимум 100ms между любыми сообщениями + + def get_chat_limiter(self, chat_id: int) -> ChatRateLimiter: + """Получает rate limiter для конкретного чата""" + if chat_id not in self.chat_limiters: + self.chat_limiters[chat_id] = ChatRateLimiter(self.config) + return self.chat_limiters[chat_id] + + async def wait_if_needed(self, chat_id: int) -> None: + """Ждет если необходимо для соблюдения глобального и чат-специфичного rate limit""" + current_time = time.time() + + # Глобальный rate limit + time_since_global = current_time - self.global_last_send + if time_since_global < self.global_min_interval: + wait_time = self.global_min_interval - time_since_global + logger.info(f"Применен глобальный rate limit для чата {chat_id}, ожидание {wait_time:.2f}с") + await asyncio.sleep(wait_time) + current_time = time.time() + + # Чат-специфичный rate limit + chat_limiter = self.get_chat_limiter(chat_id) + await chat_limiter.wait_if_needed() + + self.global_last_send = time.time() + + +class RetryHandler: + """Обработчик повторных попыток с экспоненциальной задержкой""" + + def __init__(self, config: RateLimitConfig): + self.config = config + + async def execute_with_retry( + self, + func: Callable, + chat_id: int, + *args, + max_retries: int = 3, + **kwargs + ) -> tuple[Any, float]: + """Выполняет функцию с повторными попытками при ошибках""" + retry_count = 0 + current_delay = self.config.retry_after_multiplier + total_wait_time = 0.0 + + while retry_count <= max_retries: + try: + result = await func(*args, **kwargs) + # Записываем успешный запрос + logger.debug(f"Rate limit запрос успешен для чата {chat_id}") + return result, total_wait_time + + except TelegramRetryAfter as e: + retry_count += 1 + if retry_count > max_retries: + logger.error(f"Превышено максимальное количество попыток для RetryAfter: {e}") + raise + + # Используем время ожидания от Telegram или наше увеличенное + wait_time = max(e.retry_after, current_delay) + wait_time = min(wait_time, self.config.max_retry_delay) + total_wait_time += wait_time + + logger.info(f"RetryAfter ошибка для чата {chat_id}, ожидание {wait_time:.2f}с (попытка {retry_count}/{max_retries})") + await asyncio.sleep(wait_time) + current_delay *= self.config.retry_after_multiplier + + except TelegramAPIError as e: + retry_count += 1 + if retry_count > max_retries: + logger.error(f"Превышено максимальное количество попыток для TelegramAPIError: {e}") + raise + + wait_time = min(current_delay, self.config.max_retry_delay) + total_wait_time += wait_time + logger.info(f"TelegramAPIError для чата {chat_id}, ожидание {wait_time:.2f}с (попытка {retry_count}/{max_retries}): {e}") + await asyncio.sleep(wait_time) + current_delay *= self.config.retry_after_multiplier + + except Exception as e: + # Для других ошибок не делаем retry + logger.error(f"Ошибка без повторных попыток: {e}") + raise + + +class TelegramRateLimiter: + """Основной класс для rate limiting в Telegram боте""" + + def __init__(self, config: Optional[RateLimitConfig] = None): + self.config = config or RateLimitConfig() + self.global_limiter = GlobalRateLimiter(self.config) + self.retry_handler = RetryHandler(self.config) + + async def send_with_rate_limit( + self, + send_func: Callable, + chat_id: int, + *args, + **kwargs + ) -> tuple[Any, float]: + """Отправляет сообщение с соблюдением rate limit и retry логики""" + + async def _send(): + await self.global_limiter.wait_if_needed(chat_id) + # Добавляем chat_id в kwargs для функции отправки + send_kwargs = kwargs.copy() + send_kwargs['chat_id'] = chat_id + return await send_func(*args, **send_kwargs) + + return await self.retry_handler.execute_with_retry(_send, chat_id) + + async def execute_with_rate_limit( + self, + handler_func: Callable, + chat_id: int + ) -> tuple[Any, float]: + """Выполняет обработчик с соблюдением rate limit (без добавления chat_id в kwargs)""" + + async def _execute(): + await self.global_limiter.wait_if_needed(chat_id) + return await handler_func() + + return await self.retry_handler.execute_with_retry(_execute, chat_id) + + +def _create_rate_limit_config(settings: RateLimitSettings) -> RateLimitConfig: + """Создает RateLimitConfig из RateLimitSettings""" + return RateLimitConfig( + messages_per_second=settings.messages_per_second, + burst_limit=settings.burst_limit, + retry_after_multiplier=settings.retry_after_multiplier, + max_retry_delay=settings.max_retry_delay + ) + + +# Получаем конфигурацию из настроек +_rate_limit_settings = get_rate_limit_config() +_default_config = _create_rate_limit_config(_rate_limit_settings) + +telegram_rate_limiter = TelegramRateLimiter(_default_config) + + +async def send_with_rate_limit(send_func: Callable, chat_id: int, *args, **kwargs) -> tuple[Any, float]: + """ + Удобная функция для отправки сообщений с rate limiting + + Args: + send_func: Функция отправки (например, bot.send_message) + chat_id: ID чата + *args, **kwargs: Аргументы для функции отправки + + Returns: + Кортеж (результат выполнения функции отправки, общее время ожидания) + """ + return await telegram_rate_limiter.send_with_rate_limit(send_func, chat_id, *args, **kwargs) diff --git a/services/utils.py b/services/utils.py new file mode 100644 index 0000000..0db86aa --- /dev/null +++ b/services/utils.py @@ -0,0 +1,402 @@ +""" +Сервис утилит для бота +""" +import asyncio +import hashlib +import secrets +from datetime import datetime +from typing import Optional, Tuple + +from config.constants import ANONYMOUS_TOKEN_LENGTH, DEFAULT_QUESTION_PREVIEW_LENGTH, DEFAULT_TEXT_TRUNCATE_LENGTH, MIN_QUESTION_LENGTH, MIN_ANSWER_LENGTH, SEPARATOR_LENGTH +from models.question import Question +from models.user import User +from services.infrastructure.database import DatabaseService +from services.infrastructure.logger import get_logger + +logger = get_logger(__name__) + + +class UtilsService: + """Сервис утилит для форматирования и валидации""" + + def __init__(self, database: DatabaseService): + self.database = database + + def generate_referral_link(self, bot_username: str, user_id: int) -> str: + """ + Генерация уникальной реферальной ссылки для пользователя + + Args: + bot_username: Имя бота (без @) + user_id: ID пользователя + + Returns: + Ссылка формата: t.me/bot_username?start=ref_{user_id} + """ + return f"https://t.me/{bot_username}?start=ref_{user_id}" + + def generate_anonymous_id(self) -> str: + """ + Генерация анонимного ID для отправителя вопроса + + Returns: + Случайная строка для идентификации анонимного пользователя + """ + return secrets.token_hex(ANONYMOUS_TOKEN_LENGTH) + + def format_user_info(self, user: User, show_stats: bool = False) -> str: + """ + Форматирование информации о пользователе + + Args: + user: Объект пользователя + show_stats: Показывать ли статистику + + Returns: + Отформатированная строка с информацией о пользователе + """ + info = f"👤 {user.display_name}\n" + + if user.full_name and user.username: + info += f"📝 {user.full_name}\n" + + if hasattr(user, 'is_premium') and user.is_premium: + info += "⭐ Premium пользователь\n" + + if hasattr(user, 'language_code') and user.language_code: + info += f"🌐 Язык: {user.language_code.upper()}\n" + + if user.created_at: + info += f"📅 Регистрация: {user.created_at.strftime('%d.%m.%Y %H:%M')}\n" + + if user.is_active: + info += "✅ Активен\n" + else: + info += "❌ Неактивен\n" + + if show_stats: + # Здесь можно добавить статистику пользователя + pass + + return info + + def format_user_display_name(self, user: User) -> str: + """ + Форматирование отображаемого имени пользователя для суперпользователей + + Args: + user: Объект пользователя + + Returns: + Строка в формате: {@username} {first_name} {last_name} + """ + parts = [] + + # Добавляем username если есть + if user.username: + parts.append(f"@{user.username}") + + # Добавляем имя + if user.first_name: + parts.append(user.first_name) + + # Добавляем фамилию если есть + if user.last_name: + parts.append(user.last_name) + + return " ".join(parts) if parts else "Неизвестный пользователь" + + def format_question_info(self, question: Question, show_answer: bool = False) -> str: + """ + Форматирование информации о вопросе + + Args: + question: Объект вопроса + show_answer: Показывать ли ответ + + Returns: + Отформатированная строка с информацией о вопросе + """ + # Используем user_question_number для отображения, если он есть + display_number = question.user_question_number if question.user_question_number is not None else question.id + info = f"❓ Вопрос #{display_number}\n\n" + + if question.is_anonymous: + info += "👤 Анонимный вопрос\n" + else: + info += f"👤 От: {question.from_user_id}\n" + + info += f"📝 Вопрос:\n{question.message_text}\n\n" + + if question.created_at: + info += f"📅 {question.created_at.strftime('%d.%m.%Y %H:%M')}\n" + + # Статус вопроса + status_emoji = { + 'pending': '⏳', + 'answered': '✅', + 'rejected': '❌', + 'deleted': '🗑️' + } + + status_text = { + 'pending': 'Ожидает ответа', + 'answered': 'Отвечен', + 'rejected': 'Отклонен', + 'deleted': 'Удален' + } + + info += f"{status_emoji.get(question.status.value, '❓')} {status_text.get(question.status.value, 'Неизвестно')}\n" + + if show_answer and question.answer_text: + info += f"\n💬 Ответ:\n{question.answer_text}\n" + + if question.answered_at: + info += f"\n📅 Ответ дан: {question.answered_at.strftime('%d.%m.%Y %H:%M')}" + + return info + + def format_questions_list(self, questions: list, show_answers: bool = False) -> str: + """ + Форматирование списка вопросов + + Args: + questions: Список вопросов + show_answers: Показывать ли ответы + + Returns: + Отформатированная строка со списком вопросов + """ + if not questions: + return "📭 Вопросов пока нет" + + info = f"📋 Список вопросов ({len(questions)}):\n\n" + + for i, question in enumerate(questions, 1): + info += f"{i}. {self.format_question_info(question, show_answers)}\n" + if i < len(questions): + info += "─" * SEPARATOR_LENGTH + "\n\n" + + return info + + def format_stats(self, stats: dict) -> str: + """ + Форматирование статистики + + Args: + stats: Словарь со статистикой + + Returns: + Отформатированная строка со статистикой + """ + info = "📊 Статистика бота:\n\n" + + # Статистика пользователей + if 'total_users' in stats: + info += "👥 Пользователи:\n" + info += f"• Всего: {stats.get('total_users', 0)}\n" + info += f"• Активных за неделю: {stats.get('active_week', 0)}\n" + info += f"• Активных сегодня: {stats.get('active_today', 0)}\n\n" + + # Статистика вопросов + if 'total_questions' in stats: + info += "❓ Вопросы:\n" + info += f"• Всего: {stats.get('total_questions', 0)}\n" + info += f"• Ожидают ответа: {stats.get('pending_questions', 0)}\n" + info += f"• Отвечено: {stats.get('answered_questions', 0)}\n" + info += f"• За сегодня: {stats.get('questions_today', 0)}\n" + info += f"• За неделю: {stats.get('questions_week', 0)}\n\n" + + # Дополнительная статистика + if 'total_questions_received' in stats: + info += "📈 Активность:\n" + info += f"• Получено вопросов: {stats.get('total_questions_received', 0)}\n" + info += f"• Отвечено вопросов: {stats.get('total_questions_answered', 0)}\n" + + return info + + def escape_html(self, text: str) -> str: + """ + Экранирование HTML символов + + Args: + text: Исходный текст + + Returns: + Экранированный текст + """ + return (text + .replace('&', '&') + .replace('<', '<') + .replace('>', '>') + .replace('"', '"') + .replace("'", ''')) + + + def is_valid_question_text(self, text: str, max_length: int = 1000) -> Tuple[bool, str]: + """ + Проверка валидности текста вопроса + + Args: + text: Текст вопроса + max_length: Максимальная длина + + Returns: + Кортеж (валидность, сообщение об ошибке) + """ + if not text or not text.strip(): + return False, "Вопрос не может быть пустым" + + if len(text.strip()) < MIN_QUESTION_LENGTH: + return False, f"Вопрос должен содержать минимум {MIN_QUESTION_LENGTH} символов" + + if len(text) > max_length: + return False, f"Вопрос слишком длинный (максимум {max_length} символов)" + + return True, "" + + def is_valid_answer_text(self, text: str, max_length: int = 2000) -> Tuple[bool, str]: + """ + Проверка валидности текста ответа + + Args: + text: Текст ответа + max_length: Максимальная длина + + Returns: + Кортеж (валидность, сообщение об ошибке) + """ + if not text or not text.strip(): + return False, "Ответ не может быть пустым" + + if len(text.strip()) < MIN_ANSWER_LENGTH: + return False, f"Ответ должен содержать минимум {MIN_ANSWER_LENGTH} символов" + + if len(text) > max_length: + return False, f"Ответ слишком длинный (максимум {max_length} символов)" + + return True, "" + + async def send_answer_to_author(self, bot, question: Question, answer_text: str): + """ + Отправляет ответ автору вопроса + + Args: + bot: Экземпляр бота + question: Объект вопроса + answer_text: Текст ответа + """ + try: + logger.info(f"send_answer_to_author вызвана для вопроса {question.id}, from_user_id: {question.from_user_id}") + + # Проверяем, есть ли ID автора (для анонимных вопросов может быть None) + if not question.from_user_id: + logger.warning(f"Нельзя отправить ответ автору вопроса {question.id}: from_user_id = None (анонимный вопрос)") + return + + # Формируем сообщение для автора + message_text = f"💬 Получен ответ на ваш вопрос!\n\n" + message_text += f"❓ Ваш вопрос:\n{question.message_text}\n\n" + message_text += f"✅ Ответ:\n{answer_text}\n\n" + + # Обрабатываем дату ответа (асинхронно) + if question.answered_at: + if isinstance(question.answered_at, str): + # Если дата пришла как строка, конвертируем в datetime + try: + # Используем asyncio для неблокирующего парсинга + loop = asyncio.get_event_loop() + answered_at = await loop.run_in_executor( + None, + lambda: datetime.fromisoformat(question.answered_at.replace('Z', '+00:00')) + ) + date_str = answered_at.strftime('%d.%m.%Y %H:%M') + except: + date_str = str(question.answered_at) + else: + date_str = question.answered_at.strftime('%d.%m.%Y %H:%M') + else: + date_str = "Неизвестно" + + message_text += f"📅 Дата ответа: {date_str}" + + # Отправляем сообщение автору + logger.info(f"Попытка отправить сообщение пользователю {question.from_user_id}") + await bot.send_message( + chat_id=question.from_user_id, + text=message_text, + parse_mode="HTML" + ) + + logger.info(f"✅ Ответ успешно отправлен автору вопроса {question.id} (пользователь {question.from_user_id})") + + except Exception as e: + logger.error(f"Ошибка при отправке ответа автору вопроса {question.id}: {e}") + # Не поднимаем исключение, чтобы не прерывать основной процесс + + +# Функции для обратной совместимости +def generate_referral_link(bot_username: str, user_id: int) -> str: + """Генерация уникальной реферальной ссылки для пользователя""" + return f"https://t.me/{bot_username}?start=ref_{user_id}" + + +def generate_anonymous_id() -> str: + """Генерация анонимного ID для отправителя вопроса""" + return secrets.token_hex(ANONYMOUS_TOKEN_LENGTH) + + +def format_user_info(user: User, show_stats: bool = False) -> str: + """Форматирование информации о пользователе""" + utils = UtilsService(None) # Временное решение для обратной совместимости + return utils.format_user_info(user, show_stats) + + +def format_user_display_name(user: User) -> str: + """Форматирование отображаемого имени пользователя для суперпользователей""" + utils = UtilsService(None) # Временное решение для обратной совместимости + return utils.format_user_display_name(user) + + +def format_question_info(question: Question, show_answer: bool = False) -> str: + """Форматирование информации о вопросе""" + utils = UtilsService(None) # Временное решение для обратной совместимости + return utils.format_question_info(question, show_answer) + + + + +def format_stats(stats: dict) -> str: + """Форматирование статистики""" + utils = UtilsService(None) # Временное решение для обратной совместимости + return utils.format_stats(stats) + + +def escape_html(text: str) -> str: + """Экранирование HTML символов""" + return (text + .replace('&', '&') + .replace('<', '<') + .replace('>', '>') + .replace('"', '"') + .replace("'", ''')) + + + + +def is_valid_question_text(text: str, max_length: int = 1000) -> Tuple[bool, str]: + """Проверка валидности текста вопроса""" + utils = UtilsService(None) # Временное решение для обратной совместимости + return utils.is_valid_question_text(text, max_length) + + +def is_valid_answer_text(text: str, max_length: int = 2000) -> Tuple[bool, str]: + """Проверка валидности текста ответа""" + utils = UtilsService(None) # Временное решение для обратной совместимости + return utils.is_valid_answer_text(text, max_length) + + +async def send_answer_to_author(bot, question: Question, answer_text: str): + """Отправляет ответ автору вопроса""" + utils = UtilsService(None) # Временное решение для обратной совместимости + await utils.send_answer_to_author(bot, question, answer_text) \ No newline at end of file diff --git a/services/validation/__init__.py b/services/validation/__init__.py new file mode 100644 index 0000000..885fd7a --- /dev/null +++ b/services/validation/__init__.py @@ -0,0 +1,6 @@ +""" +Модуль валидации входных данных +""" +from .input_validator import InputValidator, ValidationResult + +__all__ = ['InputValidator', 'ValidationResult'] diff --git a/services/validation/input_validator.py b/services/validation/input_validator.py new file mode 100644 index 0000000..ba1e6df --- /dev/null +++ b/services/validation/input_validator.py @@ -0,0 +1,359 @@ +""" +Централизованный валидатор входных данных для AnonBot +""" +import re +import html +from typing import Optional, Tuple, List +from dataclasses import dataclass + +from services.infrastructure.logger import get_logger +from config.constants import MIN_QUESTION_LENGTH, MIN_ANSWER_LENGTH + +logger = get_logger(__name__) + + +@dataclass +class ValidationResult: + """Результат валидации""" + is_valid: bool + error_message: str = "" + sanitized_value: Optional[str] = None + + def __bool__(self) -> bool: + return self.is_valid + + +class InputValidator: + """Централизованный валидатор входных данных""" + + # Константы для валидации + MIN_TELEGRAM_ID = 1 + MAX_TELEGRAM_ID = 2**63 - 1 + MIN_USERNAME_LENGTH = 1 + MAX_USERNAME_LENGTH = 32 + MAX_CALLBACK_DATA_LENGTH = 64 + MAX_TEXT_LENGTH = 4000 # Telegram limit + MAX_HTML_ENTITIES = 100 # Защита от HTML-спама + + # Регулярные выражения + USERNAME_PATTERN = re.compile(r'^[a-zA-Z0-9_]{1,32}$') + DEEP_LINK_PATTERN = re.compile(r'^[a-zA-Z0-9_-]{1,64}$') + CALLBACK_DATA_PATTERN = re.compile(r'^[a-zA-Z0-9_:-]{1,64}$') + + # Опасные HTML теги и атрибуты + DANGEROUS_TAGS = {'script', 'iframe', 'object', 'embed', 'form', 'input', 'button'} + DANGEROUS_ATTRIBUTES = {'onclick', 'onload', 'onerror', 'onmouseover', 'href', 'src'} + + def __init__(self): + logger.info("🔍 InputValidator инициализирован") + + def validate_telegram_id(self, user_id: int) -> ValidationResult: + """ + Валидация Telegram ID пользователя + + Args: + user_id: ID пользователя Telegram + + Returns: + ValidationResult с результатом валидации + """ + try: + if not isinstance(user_id, int): + return ValidationResult( + False, + f"Telegram ID должен быть числом, получен: {type(user_id).__name__}" + ) + + if user_id < self.MIN_TELEGRAM_ID: + return ValidationResult( + False, + f"Telegram ID должен быть больше {self.MIN_TELEGRAM_ID}" + ) + + if user_id > self.MAX_TELEGRAM_ID: + return ValidationResult( + False, + f"Telegram ID должен быть меньше {self.MAX_TELEGRAM_ID}" + ) + + logger.debug(f"✅ Telegram ID {user_id} прошел валидацию") + return ValidationResult(True, sanitized_value=str(user_id)) + + except Exception as e: + logger.error(f"❌ Ошибка валидации Telegram ID {user_id}: {e}") + return ValidationResult(False, f"Ошибка валидации Telegram ID: {str(e)}") + + def validate_username(self, username: str) -> ValidationResult: + """ + Валидация username пользователя + + Args: + username: Username пользователя (без @) + + Returns: + ValidationResult с результатом валидации + """ + try: + if not username: + return ValidationResult(True, sanitized_value="") # Username может быть пустым + + # Убираем @ если есть + username = username.lstrip('@') + + if len(username) < self.MIN_USERNAME_LENGTH: + return ValidationResult( + False, + f"Username должен содержать минимум {self.MIN_USERNAME_LENGTH} символ" + ) + + if len(username) > self.MAX_USERNAME_LENGTH: + return ValidationResult( + False, + f"Username не должен превышать {self.MAX_USERNAME_LENGTH} символов" + ) + + if not self.USERNAME_PATTERN.match(username): + return ValidationResult( + False, + "Username может содержать только латинские буквы, цифры и подчеркивания" + ) + + logger.debug(f"✅ Username '{username}' прошел валидацию") + return ValidationResult(True, sanitized_value=username) + + except Exception as e: + logger.error(f"❌ Ошибка валидации username '{username}': {e}") + return ValidationResult(False, f"Ошибка валидации username: {str(e)}") + + def validate_text_content( + self, + text: str, + min_length: int = 1, + max_length: int = MAX_TEXT_LENGTH, + content_type: str = "текст" + ) -> ValidationResult: + """ + Валидация текстового контента + + Args: + text: Текст для валидации + min_length: Минимальная длина + max_length: Максимальная длина + content_type: Тип контента для сообщений об ошибках + + Returns: + ValidationResult с результатом валидации + """ + try: + if not text: + return ValidationResult( + False, + f"{content_type.capitalize()} не может быть пустым" + ) + + # Проверяем длину до санитизации + if len(text) > max_length: + return ValidationResult( + False, + f"{content_type.capitalize()} слишком длинный (максимум {max_length} символов)" + ) + + # Санитизируем HTML + sanitized_text = self.sanitize_html(text) + + # Проверяем длину после санитизации + if len(sanitized_text.strip()) < min_length: + return ValidationResult( + False, + f"{content_type.capitalize()} должен содержать минимум {min_length} символов" + ) + + # Проверяем на спам (повторяющиеся символы) + if self._is_spam_text(sanitized_text): + return ValidationResult( + False, + f"{content_type.capitalize()} содержит слишком много повторяющихся символов" + ) + + logger.debug(f"✅ {content_type} прошел валидацию (длина: {len(sanitized_text)})") + return ValidationResult(True, sanitized_value=sanitized_text) + + except Exception as e: + logger.error(f"❌ Ошибка валидации {content_type}: {e}") + return ValidationResult(False, f"Ошибка валидации {content_type}: {str(e)}") + + def validate_question_text(self, text: str, max_length: int = 1000) -> ValidationResult: + """ + Валидация текста вопроса + + Args: + text: Текст вопроса + max_length: Максимальная длина вопроса + + Returns: + ValidationResult с результатом валидации + """ + return self.validate_text_content( + text, + min_length=MIN_QUESTION_LENGTH, + max_length=max_length, + content_type="вопрос" + ) + + def validate_answer_text(self, text: str, max_length: int = 2000) -> ValidationResult: + """ + Валидация текста ответа + + Args: + text: Текст ответа + max_length: Максимальная длина ответа + + Returns: + ValidationResult с результатом валидации + """ + return self.validate_text_content( + text, + min_length=MIN_ANSWER_LENGTH, + max_length=max_length, + content_type="ответ" + ) + + def validate_callback_data(self, data: str) -> ValidationResult: + """ + Валидация callback data + + Args: + data: Callback data для валидации + + Returns: + ValidationResult с результатом валидации + """ + try: + if not data: + return ValidationResult(False, "Callback data не может быть пустым") + + if len(data) > self.MAX_CALLBACK_DATA_LENGTH: + return ValidationResult( + False, + f"Callback data не должен превышать {self.MAX_CALLBACK_DATA_LENGTH} символов" + ) + + if not self.CALLBACK_DATA_PATTERN.match(data): + return ValidationResult( + False, + "Callback data содержит недопустимые символы" + ) + + logger.debug(f"✅ Callback data '{data}' прошел валидацию") + return ValidationResult(True, sanitized_value=data) + + except Exception as e: + logger.error(f"❌ Ошибка валидации callback data '{data}': {e}") + return ValidationResult(False, f"Ошибка валидации callback data: {str(e)}") + + def validate_deep_link(self, link: str) -> ValidationResult: + """ + Валидация deep link + + Args: + link: Deep link для валидации + + Returns: + ValidationResult с результатом валидации + """ + try: + if not link: + return ValidationResult(False, "Deep link не может быть пустым") + + if len(link) > 64: # Telegram deep link limit + return ValidationResult( + False, + "Deep link не должен превышать 64 символа" + ) + + if not self.DEEP_LINK_PATTERN.match(link): + return ValidationResult( + False, + "Deep link содержит недопустимые символы" + ) + + logger.debug(f"✅ Deep link '{link}' прошел валидацию") + return ValidationResult(True, sanitized_value=link) + + except Exception as e: + logger.error(f"❌ Ошибка валидации deep link '{link}': {e}") + return ValidationResult(False, f"Ошибка валидации deep link: {str(e)}") + + + def sanitize_html(self, text: str) -> str: + """ + Санитизация HTML в тексте + + Args: + text: Текст для санитизации + + Returns: + Санитизированный текст + """ + try: + if not text: + return "" + + # Экранируем HTML сущности + sanitized = html.escape(text, quote=True) + + # Проверяем количество HTML сущностей (защита от спама) + html_entities_count = len(re.findall(r'&[a-zA-Z0-9#]+;', sanitized)) + if html_entities_count > self.MAX_HTML_ENTITIES: + logger.warning(f"⚠️ Обнаружено много HTML сущностей в тексте: {html_entities_count}") + # Убираем лишние HTML сущности + sanitized = re.sub(r'&[a-zA-Z0-9#]+;', '', sanitized) + + return sanitized.strip() + + except Exception as e: + logger.error(f"❌ Ошибка санитизации HTML: {e}") + return text.strip() # Возвращаем исходный текст в случае ошибки + + def _is_spam_text(self, text: str) -> bool: + """ + Проверка текста на спам (повторяющиеся символы) + + Args: + text: Текст для проверки + + Returns: + True если текст похож на спам + """ + try: + if len(text) < 10: + return False + + # Проверяем повторяющиеся символы + char_counts = {} + for char in text: + char_counts[char] = char_counts.get(char, 0) + 1 + + # Если какой-то символ повторяется более 50% от длины текста + max_count = max(char_counts.values()) + if max_count > len(text) * 0.5: + return True + + # Проверяем повторяющиеся слова + words = text.split() + if len(words) > 3: + word_counts = {} + for word in words: + word_counts[word] = word_counts.get(word, 0) + 1 + + max_word_count = max(word_counts.values()) + if max_word_count > len(words) * 0.6: + return True + + return False + + except Exception as e: + logger.error(f"❌ Ошибка проверки на спам: {e}") + return False + + diff --git a/tests/IMPLEMENTATION_PLAN.md b/tests/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..9ac37fb --- /dev/null +++ b/tests/IMPLEMENTATION_PLAN.md @@ -0,0 +1,319 @@ +# 📋 План реализации тестов для AnonBot + +## 🎯 Общая информация + +**Всего файлов для тестирования: 25-30 файлов** +**Цель покрытия: 80%+** +**Время реализации: 6-9 дней** + +## 📊 Статус реализации + +### ✅ Создано (структура) +- [x] Базовая структура тестов +- [x] Конфигурация pytest +- [x] Фикстуры и моки +- [x] Все файлы тестов (заглушки) +- [x] Документация тестов +- [x] Примеры тестов + +### 🔄 В процессе +- [ ] Реализация unit тестов для моделей +- [ ] Реализация unit тестов для валидации +- [ ] Реализация unit тестов для авторизации +- [ ] Реализация unit тестов для CRUD +- [ ] Реализация unit тестов для бизнес-сервисов + +### ⏳ Планируется +- [ ] Реализация unit тестов для обработчиков +- [ ] Реализация unit тестов для middleware +- [ ] Реализация unit тестов для инфраструктурных сервисов +- [ ] Реализация unit тестов для утилит +- [ ] Реализация unit тестов для конфигурации +- [ ] Реализация интеграционных тестов + +## 🚀 Этапы реализации + +### Этап 1: Критически важные компоненты (3-4 дня) + +#### 1.1 Модели данных (1 день) +- [ ] `test_user.py` - полная реализация +- [ ] `test_question.py` - полная реализация +- [ ] `test_user_block.py` - полная реализация +- [ ] `test_user_settings.py` - полная реализация + +#### 1.2 Валидация (1 день) +- [ ] `test_input_validator.py` - полная реализация +- [ ] `test_validation_middleware.py` - полная реализация + +#### 1.3 Авторизация (1 день) +- [ ] `test_auth_service.py` - полная реализация +- [ ] `test_permissions.py` - полная реализация + +#### 1.4 CRUD операции (1 день) +- [ ] `test_crud.py` - полная реализация + +### Этап 2: Важные компоненты (2-3 дня) + +#### 2.1 Бизнес-сервисы (1 день) +- [ ] `test_user_service.py` - полная реализация +- [ ] `test_question_service.py` - полная реализация +- [ ] `test_message_service.py` - полная реализация +- [ ] `test_pagination_service.py` - полная реализация + +#### 2.2 Обработчики (1 день) +- [ ] `test_start.py` - полная реализация +- [ ] `test_questions.py` - полная реализация +- [ ] `test_answers.py` - полная реализация +- [ ] `test_admin.py` - полная реализация + +#### 2.3 Middleware (0.5 дня) +- [ ] `test_validation_middleware.py` - полная реализация +- [ ] `test_rate_limit_middleware.py` - полная реализация + +#### 2.4 Инфраструктурные сервисы (0.5 дня) +- [ ] `test_database.py` - полная реализация +- [ ] `test_metrics.py` - полная реализация + +### Этап 3: Дополнительные компоненты (1-2 дня) + +#### 3.1 Утилиты (0.5 дня) +- [ ] `test_utils.py` - полная реализация + +#### 3.2 Конфигурация (0.5 дня) +- [ ] `test_config.py` - полная реализация +- [ ] `test_constants.py` - полная реализация + +#### 3.3 Интеграционные тесты (1 день) +- [ ] `test_database_integration.py` - полная реализация +- [ ] `test_bot_integration.py` - полная реализация + +## 📝 Детальный план по файлам + +### Модели данных + +#### `test_user.py` +- [ ] `test_user_creation_basic()` - создание пользователя +- [ ] `test_user_creation_with_all_fields()` - создание со всеми полями +- [ ] `test_user_validation_telegram_id()` - валидация ID +- [ ] `test_user_display_name()` - отображение имени +- [ ] `test_user_profile_link_generation()` - генерация ссылки +- [ ] `test_user_html_escaping()` - HTML экранирование +- [ ] `test_user_serialization()` - сериализация +- [ ] `test_user_deserialization()` - десериализация + +#### `test_question.py` +- [ ] `test_question_creation_basic()` - создание вопроса +- [ ] `test_question_status_values()` - статусы вопросов +- [ ] `test_question_validation_message_text()` - валидация текста +- [ ] `test_question_mark_as_answered()` - отметка как отвеченный +- [ ] `test_question_formatting_methods()` - методы форматирования + +#### `test_user_block.py` +- [ ] `test_user_block_creation_basic()` - создание блокировки +- [ ] `test_user_block_validation_different_ids()` - валидация ID +- [ ] `test_user_block_created_at_timestamp()` - временная метка +- [ ] `test_user_block_serialization()` - сериализация + +#### `test_user_settings.py` +- [ ] `test_user_settings_creation_basic()` - создание настроек +- [ ] `test_user_settings_default_values()` - значения по умолчанию +- [ ] `test_user_settings_validation_language()` - валидация языка +- [ ] `test_user_settings_boolean_flags()` - булевы флаги + +### Валидация + +#### `test_input_validator.py` +- [ ] `test_validate_telegram_id_valid()` - валидация ID +- [ ] `test_validate_username_valid()` - валидация username +- [ ] `test_validate_text_content_valid()` - валидация текста +- [ ] `test_validate_deep_link_valid()` - валидация deep link +- [ ] `test_validate_callback_data_valid()` - валидация callback +- [ ] `test_sanitize_html_basic()` - HTML санитизация +- [ ] `test_is_spam_repeating_characters()` - спам-фильтры + +#### `test_validation_middleware.py` +- [ ] `test_validate_callback_query_valid()` - валидация callback +- [ ] `test_validate_message_valid()` - валидация сообщений +- [ ] `test_validation_error_handling()` - обработка ошибок +- [ ] `test_sanitized_data_injection()` - инъекция данных + +### Авторизация + +#### `test_auth_service.py` +- [ ] `test_is_admin_valid_admin()` - проверка админа +- [ ] `test_is_superuser_valid_superuser()` - проверка суперпользователя +- [ ] `test_get_user_role_admin()` - получение роли +- [ ] `test_has_permission_valid_permission()` - проверка разрешений + +#### `test_permissions.py` +- [ ] `test_admin_permission_check_valid_admin()` - проверка админского разрешения +- [ ] `test_superuser_permission_check_valid_superuser()` - проверка суперпользовательского разрешения +- [ ] `test_permission_registry_creation()` - создание реестра +- [ ] `test_require_permission_decorator()` - декоратор разрешений + +### CRUD операции + +#### `test_crud.py` +- [ ] `test_create_user_basic()` - создание пользователя +- [ ] `test_create_question_basic()` - создание вопроса +- [ ] `test_create_batch_users()` - batch создание +- [ ] `test_get_by_telegram_id_existing()` - получение по ID +- [ ] `test_update_user_existing()` - обновление +- [ ] `test_delete_user_existing()` - удаление +- [ ] `test_cursor_pagination()` - cursor пагинация + +### Бизнес-сервисы + +#### `test_user_service.py` +- [ ] `test_create_or_update_user_new_user()` - создание пользователя +- [ ] `test_get_user_by_id_existing()` - получение по ID +- [ ] `test_user_exists_true()` - проверка существования +- [ ] `test_format_user_info()` - форматирование + +#### `test_question_service.py` +- [ ] `test_create_question_basic()` - создание вопроса +- [ ] `test_answer_question_valid()` - ответ на вопрос +- [ ] `test_edit_answer_valid()` - редактирование ответа +- [ ] `test_delete_question_existing()` - удаление вопроса + +#### `test_message_service.py` +- [ ] `test_send_message_basic()` - отправка сообщения +- [ ] `test_send_message_with_keyboard()` - отправка с клавиатурой +- [ ] `test_send_error_message()` - отправка ошибки +- [ ] `test_format_message_basic()` - форматирование + +#### `test_pagination_service.py` +- [ ] `test_offset_pagination_basic()` - offset пагинация +- [ ] `test_cursor_pagination_basic()` - cursor пагинация +- [ ] `test_validate_pagination_params_valid()` - валидация параметров +- [ ] `test_format_pagination_info_basic()` - форматирование + +### Обработчики + +#### `test_start.py` +- [ ] `test_cmd_start_basic()` - команда /start +- [ ] `test_cmd_start_with_deep_link()` - /start с deep link +- [ ] `test_handle_deep_link_valid()` - обработка deep link +- [ ] `test_process_start_command_new_user()` - обработка для нового пользователя + +#### `test_questions.py` +- [ ] `test_process_anonymous_question_valid()` - обработка вопроса +- [ ] `test_my_questions_button_with_questions()` - кнопка вопросов +- [ ] `test_answer_question_callback_valid()` - callback ответа +- [ ] `test_format_questions_list_basic()` - форматирование списка + +#### `test_answers.py` +- [ ] `test_process_new_answer_valid()` - обработка ответа +- [ ] `test_view_question_callback_valid()` - просмотр вопроса +- [ ] `test_edit_answer_callback_valid()` - редактирование ответа +- [ ] `test_delete_answer_callback_valid()` - удаление ответа + +#### `test_admin.py` +- [ ] `test_admin_menu_basic()` - админское меню +- [ ] `test_admin_stats_basic()` - админская статистика +- [ ] `test_assign_superuser_callback_valid()` - назначение суперпользователя +- [ ] `test_permission_checking_admin_required()` - проверка прав + +### Middleware + +#### `test_validation_middleware.py` +- [ ] `test_validate_callback_query_valid()` - валидация callback +- [ ] `test_validate_message_valid()` - валидация сообщений +- [ ] `test_validation_error_handling()` - обработка ошибок +- [ ] `test_sanitized_data_injection()` - инъекция данных + +#### `test_rate_limit_middleware.py` +- [ ] `test_apply_rate_limit_to_message()` - применение rate limiting +- [ ] `test_skip_rate_limit_for_callback_query()` - пропуск для callback +- [ ] `test_handle_telegram_retry_after()` - обработка retry after +- [ ] `test_rate_limit_success()` - успешный rate limiting + +### Инфраструктурные сервисы + +#### `test_database.py` +- [ ] `test_database_service_initialization()` - инициализация +- [ ] `test_connect_to_database_success()` - подключение +- [ ] `test_create_tables_success()` - создание таблиц +- [ ] `test_connection_pool_management()` - управление пулом + +#### `test_metrics.py` +- [ ] `test_metrics_service_initialization()` - инициализация +- [ ] `test_create_counter_metric()` - создание счетчика +- [ ] `test_increment_counter()` - инкремент счетчика +- [ ] `test_export_metrics_prometheus_format()` - экспорт метрик + +### Утилиты + +#### `test_utils.py` +- [ ] `test_format_user_data_basic()` - форматирование данных пользователя +- [ ] `test_is_valid_question_text_valid()` - валидация текста вопроса +- [ ] `test_escape_html_basic()` - HTML экранирование +- [ ] `test_generate_profile_link()` - генерация ссылки + +### Конфигурация + +#### `test_config.py` +- [ ] `test_config_initialization()` - инициализация +- [ ] `test_load_config_from_env()` - загрузка из .env +- [ ] `test_config_validation_telegram_token()` - валидация токена +- [ ] `test_config_error_handling()` - обработка ошибок + +#### `test_constants.py` +- [ ] `test_question_constants()` - константы вопросов +- [ ] `test_answer_constants()` - константы ответов +- [ ] `test_validation_constants()` - константы валидации +- [ ] `test_constants_consistency()` - консистентность + +### Интеграционные тесты + +#### `test_database_integration.py` +- [ ] `test_full_user_lifecycle()` - полный жизненный цикл пользователя +- [ ] `test_full_question_lifecycle()` - полный жизненный цикл вопроса +- [ ] `test_database_transactions()` - транзакции +- [ ] `test_database_performance()` - производительность + +#### `test_bot_integration.py` +- [ ] `test_bot_initialization()` - инициализация бота +- [ ] `test_full_start_command_flow()` - полный поток /start +- [ ] `test_full_question_flow()` - полный поток вопроса +- [ ] `test_middleware_chain()` - цепочка middleware + +## 🎯 Критерии готовности + +### Unit тесты +- [ ] Все тесты проходят +- [ ] Покрытие кода 80%+ +- [ ] Все граничные случаи покрыты +- [ ] Обработка ошибок протестирована +- [ ] Производительность приемлема + +### Интеграционные тесты +- [ ] Все сценарии работают +- [ ] Интеграция компонентов протестирована +- [ ] End-to-end тесты проходят +- [ ] Производительность приемлема + +### Общие критерии +- [ ] Документация обновлена +- [ ] Примеры тестов созданы +- [ ] CI/CD настроен +- [ ] Отчеты о покрытии генерируются + +## 🚀 Следующие шаги + +1. **Начать с моделей данных** - это основа для всех остальных тестов +2. **Реализовать валидацию** - критически важно для безопасности +3. **Добавить авторизацию** - важно для контроля доступа +4. **Покрыть CRUD операции** - основа работы с данными +5. **Тестировать бизнес-сервисы** - основная логика приложения +6. **Добавить обработчики** - пользовательский интерфейс +7. **Покрыть middleware** - инфраструктурные компоненты +8. **Добавить интеграционные тесты** - полные сценарии + +## 📊 Метрики успеха + +- **Покрытие кода**: 80%+ +- **Время выполнения тестов**: < 30 секунд +- **Количество тестов**: 200+ unit тестов, 20+ интеграционных +- **Прохождение тестов**: 100% +- **Документация**: Полная и актуальная diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..a5b45f4 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,287 @@ +# 🧪 Тесты для AnonBot + +## 📁 Структура тестов + +``` +tests/ +├── __init__.py +├── conftest.py # Конфигурация pytest +├── requirements.txt # Тестовые зависимости +├── README.md # Документация тестов +├── unit/ # Unit тесты +│ ├── __init__.py +│ ├── models/ # Тесты моделей данных +│ │ ├── __init__.py +│ │ ├── test_user.py +│ │ ├── test_question.py +│ │ ├── test_user_block.py +│ │ └── test_user_settings.py +│ ├── services/ # Тесты сервисов +│ │ ├── __init__.py +│ │ ├── validation/ # Тесты валидации +│ │ │ ├── __init__.py +│ │ │ ├── test_input_validator.py +│ │ │ └── test_validation_middleware.py +│ │ ├── auth/ # Тесты авторизации +│ │ │ ├── __init__.py +│ │ │ ├── test_auth_service.py +│ │ │ └── test_permissions.py +│ │ ├── business/ # Тесты бизнес-сервисов +│ │ │ ├── __init__.py +│ │ │ ├── test_user_service.py +│ │ │ ├── test_question_service.py +│ │ │ ├── test_message_service.py +│ │ │ └── test_pagination_service.py +│ │ ├── infrastructure/ # Тесты инфраструктурных сервисов +│ │ │ ├── __init__.py +│ │ │ ├── test_database.py +│ │ │ └── test_metrics.py +│ │ └── test_utils.py # Тесты утилит +│ ├── database/ # Тесты CRUD операций +│ │ ├── __init__.py +│ │ └── test_crud.py +│ ├── handlers/ # Тесты обработчиков +│ │ ├── __init__.py +│ │ ├── test_start.py +│ │ ├── test_questions.py +│ │ ├── test_answers.py +│ │ └── test_admin.py +│ ├── middlewares/ # Тесты middleware +│ │ ├── __init__.py +│ │ ├── test_validation_middleware.py +│ │ └── test_rate_limit_middleware.py +│ └── config/ # Тесты конфигурации +│ ├── __init__.py +│ ├── test_config.py +│ └── test_constants.py +└── integration/ # Интеграционные тесты + ├── __init__.py + ├── test_database_integration.py + └── test_bot_integration.py +``` + +## 🚀 Запуск тестов + +### Установка зависимостей + +```bash +pip install -r tests/requirements.txt +``` + +### Запуск всех тестов + +```bash +pytest +``` + +### Запуск unit тестов + +```bash +pytest tests/unit/ +``` + +### Запуск интеграционных тестов + +```bash +pytest tests/integration/ +``` + +### Запуск тестов с покрытием + +```bash +pytest --cov=. --cov-report=html +``` + +### Запуск тестов по категориям + +```bash +# Тесты моделей +pytest -m models + +# Тесты сервисов +pytest -m services + +# Тесты БД +pytest -m database + +# Тесты авторизации +pytest -m auth + +# Тесты валидации +pytest -m validation +``` + +## 📊 Покрытие кода + +Цель покрытия: **80%+** + +### Приоритеты покрытия: + +1. **Критически важные компоненты (90%+)**: + - Модели данных + - Валидация + - Авторизация + - CRUD операции + - Бизнес-сервисы + +2. **Важные компоненты (80%+)**: + - Обработчики + - Middleware + - Инфраструктурные сервисы + +3. **Дополнительные компоненты (70%+)**: + - Утилиты + - Конфигурация + - Интеграционные тесты + +## 🎯 Что тестировать + +### Модели данных +- Создание объектов +- Валидация полей +- Методы форматирования +- HTML экранирование +- Сериализация/десериализация + +### Валидация +- Валидация Telegram ID +- Валидация текстового контента +- Валидация deep links +- Валидация callback data +- HTML санитизация +- Спам-фильтры + +### Авторизация +- Проверка ролей +- Система разрешений +- Проверка прав доступа +- Обработка ошибок + +### CRUD операции +- Создание, чтение, обновление, удаление +- Batch операции +- Cursor-based пагинация +- Обработка ошибок БД +- Транзакции + +### Бизнес-сервисы +- Создание/обновление пользователей +- Обработка вопросов и ответов +- Форматирование сообщений +- Пагинация +- Интеграция с БД + +### Обработчики +- Обработка команд +- Deep linking +- FSM состояния +- Callback обработчики +- Валидация входных данных + +### Middleware +- Валидация входящих данных +- Rate limiting +- Обработка ошибок +- Пропуск событий + +## 🔧 Настройка тестов + +### Переменные окружения + +Создайте `.env.test` файл: + +```env +# Тестовая конфигурация +TELEGRAM_BOT_TOKEN=test_token +DATABASE_PATH=:memory: +ADMINS=123456789,987654321 +LOG_LEVEL=DEBUG +METRICS_PORT=9090 +``` + +### Фикстуры + +Основные фикстуры в `conftest.py`: +- `mock_database` - мок для DatabaseService +- `mock_auth` - мок для AuthService +- `mock_validator` - мок для InputValidator +- `mock_utils` - мок для UtilsService +- `mock_config` - мок для конфигурации + +## 📝 Примеры тестов + +### Unit тест + +```python +def test_user_creation_basic(): + """Тест базового создания пользователя""" + user = User( + telegram_id=123456789, + first_name="Test", + chat_id=123456789, + profile_link="test_link" + ) + + assert user.telegram_id == 123456789 + assert user.first_name == "Test" + assert user.is_active is True + assert user.is_superuser is False +``` + +### Async тест + +```python +@pytest.mark.asyncio +async def test_create_user_async(): + """Тест асинхронного создания пользователя""" + user_service = UserService(mock_database) + user = await user_service.create_or_update_user(telegram_user, chat_id) + + assert user.telegram_id == telegram_user.id + assert user.first_name == telegram_user.first_name +``` + +### Тест с моками + +```python +def test_auth_service_is_admin(mock_config): + """Тест проверки администратора""" + auth_service = AuthService(mock_database, mock_config) + + assert auth_service.is_admin(123456789) is True + assert auth_service.is_admin(999999999) is False +``` + +## 🚨 Обработка ошибок + +Все тесты должны: +- Проверять как успешные, так и ошибочные сценарии +- Использовать соответствующие исключения +- Проверять логирование ошибок +- Тестировать восстановление после ошибок + +## 📈 Производительность + +Тесты должны: +- Выполняться быстро (< 1 секунды для unit тестов) +- Использовать моки для внешних зависимостей +- Тестировать производительность критических компонентов +- Включать benchmark тесты для БД операций + +## 🔍 Отладка + +Для отладки тестов: + +```bash +# Запуск с подробным выводом +pytest -v -s + +# Запуск конкретного теста +pytest tests/unit/models/test_user.py::TestUser::test_user_creation_basic + +# Запуск с остановкой на первой ошибке +pytest -x + +# Запуск с отладочным выводом +pytest --pdb +``` diff --git a/tests/SUMMARY.md b/tests/SUMMARY.md new file mode 100644 index 0000000..a0cb601 --- /dev/null +++ b/tests/SUMMARY.md @@ -0,0 +1,161 @@ +# 📋 Итоговая сводка по тестам AnonBot + +## ✅ Что создано + +### 📁 Структура тестов +``` +tests/ +├── __init__.py +├── conftest.py # Конфигурация pytest + фикстуры +├── requirements.txt # Тестовые зависимости +├── README.md # Документация тестов +├── IMPLEMENTATION_PLAN.md # План реализации +├── SUMMARY.md # Эта сводка +├── run_tests.sh # Скрипт запуска тестов +├── test_config.env # Тестовая конфигурация +├── unit/ # Unit тесты (25 файлов) +│ ├── models/ # 4 файла тестов моделей +│ ├── services/ # 8 файлов тестов сервисов +│ ├── database/ # 1 файл тестов CRUD +│ ├── handlers/ # 4 файла тестов обработчиков +│ ├── middlewares/ # 2 файла тестов middleware +│ └── config/ # 2 файла тестов конфигурации +└── integration/ # Интеграционные тесты (2 файла) +``` + +### 📊 Статистика файлов +- **Всего файлов тестов**: 30 +- **Unit тесты**: 25 файлов +- **Интеграционные тесты**: 2 файла +- **Конфигурационные файлы**: 3 файла +- **Документация**: 3 файла + +### 🎯 Покрытие компонентов + +#### Критически важные (90%+ покрытие) +- ✅ **Модели данных** (4 файла) +- ✅ **Валидация** (2 файла) +- ✅ **Авторизация** (2 файла) +- ✅ **CRUD операции** (1 файл) + +#### Важные (80%+ покрытие) +- ✅ **Бизнес-сервисы** (4 файла) +- ✅ **Обработчики** (4 файла) +- ✅ **Middleware** (2 файла) +- ✅ **Инфраструктурные сервисы** (2 файла) + +#### Дополнительные (70%+ покрытие) +- ✅ **Утилиты** (1 файл) +- ✅ **Конфигурация** (2 файла) +- ✅ **Интеграционные тесты** (2 файла) + +## 🚀 Готово к использованию + +### ✅ Создано и готово +1. **Полная структура тестов** - все директории и файлы +2. **Конфигурация pytest** - настройки, маркеры, покрытие +3. **Фикстуры и моки** - для всех основных сервисов +4. **Скрипт запуска тестов** - с различными опциями +5. **Документация** - подробные инструкции +6. **План реализации** - пошаговый план +7. **Примеры тестов** - демонстрация подхода + +### 🔄 Требует реализации +1. **Содержимое тестов** - все файлы содержат только заглушки с TODO +2. **Реальные тесты** - нужно написать код тестов +3. **Настройка CI/CD** - интеграция с системой сборки +4. **Benchmark тесты** - тесты производительности + +## 📝 Следующие шаги + +### 1. Начать реализацию тестов +```bash +# Установить зависимости +pip install -r tests/requirements.txt + +# Запустить пример теста +pytest tests/unit/models/test_user_example.py -v + +# Запустить все тесты (пока будут падать) +pytest tests/ -v +``` + +### 2. Реализовать по приоритетам +1. **Модели данных** - основа для всех остальных тестов +2. **Валидация** - критически важно для безопасности +3. **Авторизация** - важно для контроля доступа +4. **CRUD операции** - основа работы с данными +5. **Бизнес-сервисы** - основная логика приложения + +### 3. Использовать план реализации +Следуйте `IMPLEMENTATION_PLAN.md` для пошаговой реализации. + +## 🛠️ Технические детали + +### Технологии +- **pytest** - основной фреймворк +- **pytest-asyncio** - для async тестов +- **pytest-mock** - для моков +- **pytest-cov** - для покрытия кода +- **aiogram-test** - для тестирования бота + +### Конфигурация +- **pytest.ini** - настройки pytest +- **conftest.py** - фикстуры и моки +- **test_config.env** - тестовая конфигурация + +### Скрипты +- **run_tests.sh** - запуск тестов с различными опциями +- **requirements.txt** - тестовые зависимости + +## 📊 Ожидаемые результаты + +### После полной реализации +- **Покрытие кода**: 80%+ +- **Количество тестов**: 200+ unit тестов, 20+ интеграционных +- **Время выполнения**: < 30 секунд +- **Прохождение тестов**: 100% + +### Преимущества +- **Надежность** - выявление ошибок на раннем этапе +- **Рефакторинг** - безопасные изменения кода +- **Документация** - тесты как живая документация +- **Качество** - повышение качества кода + +## 🎯 Рекомендации + +### Для разработки +1. **Начните с моделей** - они проще всего и нужны везде +2. **Используйте примеры** - `test_user_example.py` показывает подход +3. **Следуйте плану** - `IMPLEMENTATION_PLAN.md` содержит детальный план +4. **Тестируйте часто** - запускайте тесты после каждого изменения + +### Для команды +1. **Изучите документацию** - `README.md` содержит подробные инструкции +2. **Используйте скрипты** - `run_tests.sh` упрощает запуск тестов +3. **Следуйте стандартам** - используйте существующие фикстуры и моки +4. **Документируйте изменения** - обновляйте тесты при изменении кода + +## 🚨 Важные замечания + +### Текущее состояние +- **Все файлы созданы** - структура готова +- **Содержимое пустое** - нужно написать код тестов +- **Конфигурация готова** - можно сразу начинать разработку + +### Ограничения +- **Нет реальных тестов** - только заглушки +- **Нет CI/CD** - нужно настроить отдельно +- **Нет benchmark тестов** - нужно добавить при необходимости + +### Следующие действия +1. **Реализовать тесты** - начать с моделей данных +2. **Настроить CI/CD** - интеграция с системой сборки +3. **Добавить benchmark тесты** - для тестирования производительности +4. **Обновить документацию** - по мере реализации тестов + +## 🎉 Заключение + +Структура тестов для AnonBot полностью готова к использованию. Все необходимые файлы созданы, конфигурация настроена, документация написана. Теперь можно приступать к реализации реальных тестов, следуя плану и используя созданные примеры. + +**Готово к разработке! 🚀** diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..1cceb03 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,3 @@ +""" +Тесты для AnonBot +""" diff --git a/tests/benchmark_db_performance.py b/tests/benchmark_db_performance.py new file mode 100644 index 0000000..97c95aa --- /dev/null +++ b/tests/benchmark_db_performance.py @@ -0,0 +1,483 @@ +""" +Benchmark тесты для проверки производительности БД +""" +import asyncio +import time +import statistics +from typing import List, Dict, Any +from dataclasses import dataclass + +from models.user import User +from models.question import Question, QuestionStatus +from services.infrastructure.database import DatabaseService +# from services.business.optimized_pagination_service import OptimizedPaginationService, PaginationCursor # Файл удален +from services.infrastructure.metrics import get_metrics_service + + +@dataclass +class BenchmarkResult: + """Результат benchmark теста""" + operation: str + duration: float + items_processed: int + items_per_second: float + memory_usage: float = 0.0 + error_count: int = 0 + + +class DatabaseBenchmark: + """Класс для проведения benchmark тестов БД""" + + def __init__(self, database: DatabaseService): + self.database = database + # self.pagination_service = OptimizedPaginationService() # Файл удален + self.metrics = get_metrics_service() + self.results: List[BenchmarkResult] = [] + + async def run_all_benchmarks(self) -> Dict[str, Any]: + """Запуск всех benchmark тестов""" + print("🚀 Запуск benchmark тестов производительности БД...") + + # Подготовка тестовых данных + await self._prepare_test_data() + + # Запуск тестов + benchmarks = [ + ("single_insert_users", self._benchmark_single_insert_users), + ("batch_insert_users", self._benchmark_batch_insert_users), + ("single_insert_questions", self._benchmark_single_insert_questions), + ("batch_insert_questions", self._benchmark_batch_insert_questions), + ("offset_pagination", self._benchmark_offset_pagination), + ("cursor_pagination", self._benchmark_cursor_pagination), + ("n_plus_one_query", self._benchmark_n_plus_one_query), + ("optimized_join_query", self._benchmark_optimized_join_query), + ] + + for name, benchmark_func in benchmarks: + try: + print(f"📊 Запуск теста: {name}") + result = await benchmark_func() + self.results.append(result) + print(f"✅ {name}: {result.items_per_second:.2f} items/sec") + except Exception as e: + print(f"❌ Ошибка в тесте {name}: {e}") + self.results.append(BenchmarkResult( + operation=name, + duration=0.0, + items_processed=0, + items_per_second=0.0, + error_count=1 + )) + + # Очистка тестовых данных + await self._cleanup_test_data() + + return self._generate_report() + + async def _prepare_test_data(self): + """Подготовка тестовых данных""" + print("📝 Подготовка тестовых данных...") + + # Создаем тестовых пользователей + self.test_users = [] + for i in range(1000): + user = User( + telegram_id=9000000 + i, + username=f"test_user_{i}", + first_name=f"Test{i}", + last_name="User", + chat_id=9000000 + i, + profile_link=f"test_link_{i}", + is_active=True, + is_superuser=False + ) + self.test_users.append(user) + + # Создаем тестовые вопросы + self.test_questions = [] + for i in range(5000): + question = Question( + from_user_id=9000000 + (i % 100), # Циклически используем пользователей + to_user_id=9000000 + ((i + 1) % 100), + message_text=f"Test question {i}", + status=QuestionStatus.PENDING + ) + self.test_questions.append(question) + + print(f"✅ Подготовлено {len(self.test_users)} пользователей и {len(self.test_questions)} вопросов") + + async def _cleanup_test_data(self): + """Очистка тестовых данных""" + print("🧹 Очистка тестовых данных...") + + # Удаляем тестовых пользователей (вопросы удалятся каскадно) + for user in self.test_users[:10]: # Удаляем только первых 10 для скорости + try: + await self.database.users.delete(user.telegram_id) + except: + pass # Игнорируем ошибки при очистке + + async def _benchmark_single_insert_users(self) -> BenchmarkResult: + """Benchmark одиночной вставки пользователей""" + start_time = time.time() + items_processed = 0 + + for user in self.test_users[:100]: # Тестируем на 100 пользователях + try: + await self.database.create_user(user) + items_processed += 1 + except Exception as e: + print(f"Ошибка при создании пользователя: {e}") + + duration = time.time() - start_time + return BenchmarkResult( + operation="single_insert_users", + duration=duration, + items_processed=items_processed, + items_per_second=items_processed / duration if duration > 0 else 0 + ) + + async def _benchmark_batch_insert_users(self) -> BenchmarkResult: + """Benchmark batch вставки пользователей""" + start_time = time.time() + + # Создаем новые пользователей для batch теста + batch_users = [] + for i in range(100): + user = User( + telegram_id=8000000 + i, + username=f"batch_user_{i}", + first_name=f"Batch{i}", + last_name="User", + chat_id=8000000 + i, + profile_link=f"batch_link_{i}", + is_active=True, + is_superuser=False + ) + batch_users.append(user) + + try: + await self.database.create_users_batch(batch_users) + items_processed = len(batch_users) + except Exception as e: + print(f"Ошибка при batch создании пользователей: {e}") + items_processed = 0 + + duration = time.time() - start_time + return BenchmarkResult( + operation="batch_insert_users", + duration=duration, + items_processed=items_processed, + items_per_second=items_processed / duration if duration > 0 else 0 + ) + + async def _benchmark_single_insert_questions(self) -> BenchmarkResult: + """Benchmark одиночной вставки вопросов""" + start_time = time.time() + items_processed = 0 + + for question in self.test_questions[:100]: # Тестируем на 100 вопросах + try: + await self.database.create_question(question) + items_processed += 1 + except Exception as e: + print(f"Ошибка при создании вопроса: {e}") + + duration = time.time() - start_time + return BenchmarkResult( + operation="single_insert_questions", + duration=duration, + items_processed=items_processed, + items_per_second=items_processed / duration if duration > 0 else 0 + ) + + async def _benchmark_batch_insert_questions(self) -> BenchmarkResult: + """Benchmark batch вставки вопросов""" + start_time = time.time() + + # Создаем новые вопросы для batch теста + batch_questions = [] + for i in range(100): + question = Question( + from_user_id=8000000 + (i % 10), + to_user_id=8000000 + ((i + 1) % 10), + message_text=f"Batch question {i}", + status=QuestionStatus.PENDING + ) + batch_questions.append(question) + + try: + await self.database.create_questions_batch(batch_questions) + items_processed = len(batch_questions) + except Exception as e: + print(f"Ошибка при batch создании вопросов: {e}") + items_processed = 0 + + duration = time.time() - start_time + return BenchmarkResult( + operation="batch_insert_questions", + duration=duration, + items_processed=items_processed, + items_per_second=items_processed / duration if duration > 0 else 0 + ) + + async def _benchmark_offset_pagination(self) -> BenchmarkResult: + """Benchmark offset-based пагинации""" + start_time = time.time() + items_processed = 0 + + # Тестируем пагинацию с offset + for offset in range(0, 1000, 50): # 20 страниц по 50 элементов + try: + questions = await self.database.get_user_questions( + to_user_id=9000000, # Используем первого тестового пользователя + limit=50, + offset=offset + ) + items_processed += len(questions) + except Exception as e: + print(f"Ошибка при offset пагинации: {e}") + + duration = time.time() - start_time + return BenchmarkResult( + operation="offset_pagination", + duration=duration, + items_processed=items_processed, + items_per_second=items_processed / duration if duration > 0 else 0 + ) + + async def _benchmark_cursor_pagination(self) -> BenchmarkResult: + """Benchmark cursor-based пагинации""" + start_time = time.time() + items_processed = 0 + + try: + # Тестируем cursor-based пагинацию + cursor = None + for _ in range(20): # 20 страниц + result = await self.pagination_service.paginate_questions( + database=self.database, + to_user_id=9000000, + cursor=cursor, + limit=50 + ) + items_processed += len(result.items) + cursor = result.cursor + + if not result.has_next: + break + except Exception as e: + print(f"Ошибка при cursor пагинации: {e}") + + duration = time.time() - start_time + return BenchmarkResult( + operation="cursor_pagination", + duration=duration, + items_processed=items_processed, + items_per_second=items_processed / duration if duration > 0 else 0 + ) + + async def _benchmark_n_plus_one_query(self) -> BenchmarkResult: + """Benchmark N+1 запроса (неоптимизированная версия)""" + start_time = time.time() + items_processed = 0 + + try: + # Получаем вопросы + questions = await self.database.get_user_questions( + to_user_id=9000000, + limit=100 + ) + + # Для каждого вопроса делаем отдельный запрос к БД (N+1 проблема) + for question in questions: + try: + if question.from_user_id: + user = await self.database.get_user(question.from_user_id) + if user: + items_processed += 1 + except Exception as e: + print(f"Ошибка при получении пользователя: {e}") + except Exception as e: + print(f"Ошибка при N+1 запросе: {e}") + + duration = time.time() - start_time + return BenchmarkResult( + operation="n_plus_one_query", + duration=duration, + items_processed=items_processed, + items_per_second=items_processed / duration if duration > 0 else 0 + ) + + async def _benchmark_optimized_join_query(self) -> BenchmarkResult: + """Benchmark оптимизированного JOIN запроса""" + start_time = time.time() + items_processed = 0 + + try: + # Используем оптимизированный запрос с JOIN + questions_with_authors = await self.database.get_user_questions_with_authors( + user_id=9000000, + limit=100 + ) + items_processed = len(questions_with_authors) + except Exception as e: + print(f"Ошибка при оптимизированном JOIN запросе: {e}") + + duration = time.time() - start_time + return BenchmarkResult( + operation="optimized_join_query", + duration=duration, + items_processed=items_processed, + items_per_second=items_processed / duration if duration > 0 else 0 + ) + + def _generate_report(self) -> Dict[str, Any]: + """Генерация отчета по результатам benchmark""" + if not self.results: + return {"error": "Нет результатов для анализа"} + + # Группируем результаты по типам операций + operation_groups = {} + for result in self.results: + if result.operation not in operation_groups: + operation_groups[result.operation] = [] + operation_groups[result.operation].append(result) + + # Анализируем производительность + analysis = {} + for operation, results in operation_groups.items(): + if results: + avg_performance = statistics.mean([r.items_per_second for r in results]) + max_performance = max([r.items_per_second for r in results]) + min_performance = min([r.items_per_second for r in results]) + + analysis[operation] = { + "avg_items_per_second": round(avg_performance, 2), + "max_items_per_second": round(max_performance, 2), + "min_items_per_second": round(min_performance, 2), + "total_tests": len(results), + "error_rate": sum(1 for r in results if r.error_count > 0) / len(results) + } + + # Сравнение производительности + comparisons = {} + if "single_insert_users" in analysis and "batch_insert_users" in analysis: + single_perf = analysis["single_insert_users"]["avg_items_per_second"] + batch_perf = analysis["batch_insert_users"]["avg_items_per_second"] + comparisons["batch_vs_single_users"] = { + "batch_performance": batch_perf, + "single_performance": single_perf, + "improvement_factor": round(batch_perf / single_perf, 2) if single_perf > 0 else 0 + } + + if "offset_pagination" in analysis and "cursor_pagination" in analysis: + offset_perf = analysis["offset_pagination"]["avg_items_per_second"] + cursor_perf = analysis["cursor_pagination"]["avg_items_per_second"] + comparisons["cursor_vs_offset_pagination"] = { + "cursor_performance": cursor_perf, + "offset_performance": offset_perf, + "improvement_factor": round(cursor_perf / offset_perf, 2) if offset_perf > 0 else 0 + } + + if "n_plus_one_query" in analysis and "optimized_join_query" in analysis: + n_plus_one_perf = analysis["n_plus_one_query"]["avg_items_per_second"] + join_perf = analysis["optimized_join_query"]["avg_items_per_second"] + comparisons["join_vs_n_plus_one"] = { + "join_performance": join_perf, + "n_plus_one_performance": n_plus_one_perf, + "improvement_factor": round(join_perf / n_plus_one_perf, 2) if n_plus_one_perf > 0 else 0 + } + + return { + "summary": { + "total_benchmarks": len(self.results), + "successful_benchmarks": len([r for r in self.results if r.error_count == 0]), + "failed_benchmarks": len([r for r in self.results if r.error_count > 0]) + }, + "performance_analysis": analysis, + "performance_comparisons": comparisons, + "recommendations": self._generate_recommendations(analysis, comparisons) + } + + def _generate_recommendations(self, analysis: Dict, comparisons: Dict) -> List[str]: + """Генерация рекомендаций по оптимизации""" + recommendations = [] + + # Рекомендации по batch операциям + if "batch_vs_single_users" in comparisons: + improvement = comparisons["batch_vs_single_users"]["improvement_factor"] + if improvement > 2: + recommendations.append(f"✅ Batch операции показывают улучшение в {improvement}x раз - рекомендуется использовать для массовых вставок") + else: + recommendations.append("⚠️ Batch операции не показывают значительного улучшения - возможно, стоит пересмотреть реализацию") + + # Рекомендации по пагинации + if "cursor_vs_offset_pagination" in comparisons: + improvement = comparisons["cursor_vs_offset_pagination"]["improvement_factor"] + if improvement > 1.5: + recommendations.append(f"✅ Cursor-based пагинация показывает улучшение в {improvement}x раз - рекомендуется для больших таблиц") + else: + recommendations.append("⚠️ Cursor-based пагинация не показывает значительного улучшения - возможно, данных недостаточно для демонстрации преимуществ") + + # Рекомендации по JOIN запросам + if "join_vs_n_plus_one" in comparisons: + improvement = comparisons["join_vs_n_plus_one"]["improvement_factor"] + if improvement > 5: + recommendations.append(f"✅ JOIN запросы показывают улучшение в {improvement}x раз - критически важно избегать N+1 проблем") + else: + recommendations.append("⚠️ JOIN запросы не показывают ожидаемого улучшения - возможно, нужно больше данных для тестирования") + + # Общие рекомендации + if not recommendations: + recommendations.append("📊 Недостаточно данных для генерации рекомендаций - увеличьте объем тестовых данных") + + return recommendations + + +async def run_database_benchmark(): + """Запуск benchmark тестов БД""" + try: + # Инициализация БД + database = DatabaseService() + await database.init() + + # Запуск benchmark + benchmark = DatabaseBenchmark(database) + results = await benchmark.run_all_benchmarks() + + # Вывод результатов + print("\n" + "="*80) + print("📊 РЕЗУЛЬТАТЫ BENCHMARK ТЕСТОВ ПРОИЗВОДИТЕЛЬНОСТИ БД") + print("="*80) + + print(f"\n📈 Общая статистика:") + print(f" Всего тестов: {results['summary']['total_benchmarks']}") + print(f" Успешных: {results['summary']['successful_benchmarks']}") + print(f" Неудачных: {results['summary']['failed_benchmarks']}") + + print(f"\n⚡ Анализ производительности:") + for operation, stats in results['performance_analysis'].items(): + print(f" {operation}:") + print(f" Средняя производительность: {stats['avg_items_per_second']} items/sec") + print(f" Максимальная: {stats['max_items_per_second']} items/sec") + print(f" Минимальная: {stats['min_items_per_second']} items/sec") + print(f" Ошибок: {stats['error_rate']:.1%}") + + print(f"\n🔄 Сравнения производительности:") + for comparison, stats in results['performance_comparisons'].items(): + print(f" {comparison}:") + print(f" Улучшение в {stats['improvement_factor']}x раз") + + print(f"\n💡 Рекомендации:") + for recommendation in results['recommendations']: + print(f" {recommendation}") + + print("\n" + "="*80) + + except Exception as e: + print(f"❌ Ошибка при запуске benchmark: {e}") + + +if __name__ == "__main__": + asyncio.run(run_database_benchmark()) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..7da5d63 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,65 @@ +""" +Конфигурация pytest для AnonBot +""" +import pytest +import asyncio +from unittest.mock import AsyncMock, MagicMock +from typing import Generator + +# Импорты для фикстур +from config import config +from services.infrastructure.database import DatabaseService +from services.auth.auth_new import AuthService +from services.validation import InputValidator +from services.utils import UtilsService +from services.rate_limiting.rate_limit_service import RateLimitService + + +@pytest.fixture(scope="session") +def event_loop(): + """Создание event loop для async тестов""" + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() + + +@pytest.fixture +def mock_database(): + """Мок для DatabaseService""" + return AsyncMock(spec=DatabaseService) + + +@pytest.fixture +def mock_auth(): + """Мок для AuthService""" + return AsyncMock(spec=AuthService) + + +@pytest.fixture +def mock_validator(): + """Мок для InputValidator""" + return MagicMock(spec=InputValidator) + + +@pytest.fixture +def mock_utils(): + """Мок для UtilsService""" + return MagicMock(spec=UtilsService) + + +@pytest.fixture +def mock_rate_limit_service(): + """Мок для RateLimitService""" + return AsyncMock(spec=RateLimitService) + + +@pytest.fixture +def mock_config(): + """Мок для конфигурации""" + mock_config = MagicMock() + mock_config.ADMINS = [123456789, 987654321] + mock_config.MAX_QUESTION_LENGTH = 1000 + mock_config.MAX_ANSWER_LENGTH = 2000 + mock_config.MIN_QUESTION_LENGTH = 10 + mock_config.MIN_ANSWER_LENGTH = 5 + return mock_config diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..48e4c6e --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1,3 @@ +""" +Интеграционные тесты для AnonBot +""" diff --git a/tests/integration/test_bot_integration.py b/tests/integration/test_bot_integration.py new file mode 100644 index 0000000..52eeaff --- /dev/null +++ b/tests/integration/test_bot_integration.py @@ -0,0 +1,78 @@ +""" +Интеграционные тесты для бота + +Что тестировать: +- Полные сценарии работы бота +- Интеграция всех компонентов +- End-to-end тесты +- Обработка реальных сообщений +- FSM состояния +- Middleware цепочка +- Обработка ошибок +- Производительность +""" +import pytest +from unittest.mock import AsyncMock, MagicMock +from aiogram.types import Message, User, Chat, CallbackQuery +from aiogram.fsm.context import FSMContext +from bot import Bot +from loader import BotLoader + + +class TestBotIntegration: + """Интеграционные тесты для бота""" + + def test_bot_initialization(self): + """Тест инициализации бота""" + # TODO: Реализовать тест + pass + + def test_bot_loader_initialization(self): + """Тест инициализации BotLoader""" + # TODO: Реализовать тест + pass + + def test_full_start_command_flow(self): + """Тест полного потока команды /start""" + # TODO: Реализовать тест + pass + + def test_full_question_flow(self): + """Тест полного потока создания вопроса""" + # TODO: Реализовать тест + pass + + def test_full_answer_flow(self): + """Тест полного потока ответа на вопрос""" + # TODO: Реализовать тест + pass + + def test_full_admin_flow(self): + """Тест полного потока админских функций""" + # TODO: Реализовать тест + pass + + def test_middleware_chain(self): + """Тест цепочки middleware""" + # TODO: Реализовать тест + pass + + def test_fsm_state_management(self): + """Тест управления FSM состояниями""" + # TODO: Реализовать тест + pass + + def test_error_handling_chain(self): + """Тест цепочки обработки ошибок""" + # TODO: Реализовать тест + pass + + def test_bot_performance(self): + """Тест производительности бота""" + # TODO: Реализовать тест + pass + + def test_bot_concurrent_requests(self): + """Тест конкурентных запросов к боту""" + # TODO: Реализовать тест + pass diff --git a/tests/integration/test_database_integration.py b/tests/integration/test_database_integration.py new file mode 100644 index 0000000..6876451 --- /dev/null +++ b/tests/integration/test_database_integration.py @@ -0,0 +1,74 @@ +""" +Интеграционные тесты для базы данных + +Что тестировать: +- Полные сценарии работы с БД +- Интеграция CRUD операций +- Транзакции +- Connection pooling +- Производительность +- Обработка ошибок +- Восстановление после ошибок +""" +import pytest +from unittest.mock import AsyncMock, MagicMock +from services.infrastructure.database import DatabaseService +from database.crud import UserCRUD, QuestionCRUD, UserBlockCRUD, UserSettingsCRUD +from models.user import User +from models.question import Question, QuestionStatus +from models.user_block import UserBlock +from models.user_settings import UserSettings + + +class TestDatabaseIntegration: + """Интеграционные тесты для базы данных""" + + def test_full_user_lifecycle(self): + """Тест полного жизненного цикла пользователя""" + # TODO: Реализовать тест + pass + + def test_full_question_lifecycle(self): + """Тест полного жизненного цикла вопроса""" + # TODO: Реализовать тест + pass + + def test_user_question_relationship(self): + """Тест связи пользователя и вопроса""" + # TODO: Реализовать тест + pass + + def test_user_block_relationship(self): + """Тест связи пользователя и блокировки""" + # TODO: Реализовать тест + pass + + def test_user_settings_relationship(self): + """Тест связи пользователя и настроек""" + # TODO: Реализовать тест + pass + + def test_database_transactions(self): + """Тест транзакций БД""" + # TODO: Реализовать тест + pass + + def test_database_connection_pooling(self): + """Тест пула подключений БД""" + # TODO: Реализовать тест + pass + + def test_database_performance(self): + """Тест производительности БД""" + # TODO: Реализовать тест + pass + + def test_database_error_recovery(self): + """Тест восстановления после ошибок БД""" + # TODO: Реализовать тест + pass + + def test_database_concurrent_access(self): + """Тест конкурентного доступа к БД""" + # TODO: Реализовать тест + pass diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..fd4fa5c --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,8 @@ +# Тестовые зависимости для AnonBot +pytest>=7.4.0 +pytest-asyncio>=0.21.0 +pytest-mock>=3.11.0 +pytest-cov>=4.1.0 +pytest-xdist>=3.3.0 +aiogram-test>=0.1.0 +asynctest>=0.13.0 diff --git a/tests/run_tests.sh b/tests/run_tests.sh new file mode 100755 index 0000000..4e68d31 --- /dev/null +++ b/tests/run_tests.sh @@ -0,0 +1,166 @@ +#!/bin/bash + +# Скрипт для запуска тестов AnonBot + +set -e + +echo "🧪 Запуск тестов AnonBot" +echo "=========================" + +# Проверяем, что мы в правильной директории +if [ ! -f "pytest.ini" ]; then + echo "❌ Ошибка: pytest.ini не найден. Запустите скрипт из корневой директории проекта." + exit 1 +fi + +# Устанавливаем тестовые зависимости +echo "📦 Установка тестовых зависимостей..." +pip install -r tests/requirements.txt + +# Создаем тестовую БД в памяти +export DATABASE_PATH=":memory:" +export TELEGRAM_BOT_TOKEN="test_token" +export ADMINS="123456789,987654321" +export LOG_LEVEL="DEBUG" + +echo "🔧 Настройка тестового окружения..." + +# Функция для запуска тестов с покрытием +run_tests_with_coverage() { + local test_path="$1" + local test_name="$2" + + echo "📊 Запуск $test_name с покрытием..." + pytest "$test_path" \ + --cov=. \ + --cov-report=html \ + --cov-report=term-missing \ + --cov-fail-under=80 \ + -v +} + +# Функция для запуска тестов без покрытия +run_tests() { + local test_path="$1" + local test_name="$2" + + echo "🚀 Запуск $test_name..." + pytest "$test_path" -v +} + +# Функция для запуска тестов по маркерам +run_tests_by_marker() { + local marker="$1" + local test_name="$2" + + echo "🏷️ Запуск $test_name (маркер: $marker)..." + pytest -m "$marker" -v +} + +# Основное меню +case "${1:-all}" in + "all") + echo "🎯 Запуск всех тестов..." + run_tests_with_coverage "tests/" "всех тестов" + ;; + + "unit") + echo "🔬 Запуск unit тестов..." + run_tests_with_coverage "tests/unit/" "unit тестов" + ;; + + "integration") + echo "🔗 Запуск интеграционных тестов..." + run_tests "tests/integration/" "интеграционных тестов" + ;; + + "models") + echo "📊 Запуск тестов моделей..." + run_tests "tests/unit/models/" "тестов моделей" + ;; + + "validation") + echo "✅ Запуск тестов валидации..." + run_tests "tests/unit/services/validation/" "тестов валидации" + ;; + + "auth") + echo "🔐 Запуск тестов авторизации..." + run_tests "tests/unit/services/auth/" "тестов авторизации" + ;; + + "crud") + echo "💾 Запуск тестов CRUD..." + run_tests "tests/unit/database/" "тестов CRUD" + ;; + + "services") + echo "⚙️ Запуск тестов сервисов..." + run_tests "tests/unit/services/" "тестов сервисов" + ;; + + "handlers") + echo "🎮 Запуск тестов обработчиков..." + run_tests "tests/unit/handlers/" "тестов обработчиков" + ;; + + "middleware") + echo "🔧 Запуск тестов middleware..." + run_tests "tests/unit/middlewares/" "тестов middleware" + ;; + + "config") + echo "⚙️ Запуск тестов конфигурации..." + run_tests "tests/unit/config/" "тестов конфигурации" + ;; + + "coverage") + echo "📊 Генерация отчета о покрытии..." + pytest --cov=. --cov-report=html --cov-report=term-missing -v + echo "📁 Отчет сохранен в htmlcov/index.html" + ;; + + "fast") + echo "⚡ Быстрый запуск тестов..." + pytest -x -v --tb=short + ;; + + "debug") + echo "🐛 Запуск тестов в режиме отладки..." + pytest -v -s --pdb --tb=long + ;; + + "help") + echo "📖 Доступные команды:" + echo " all - Запуск всех тестов с покрытием" + echo " unit - Запуск unit тестов с покрытием" + echo " integration - Запуск интеграционных тестов" + echo " models - Запуск тестов моделей" + echo " validation - Запуск тестов валидации" + echo " auth - Запуск тестов авторизации" + echo " crud - Запуск тестов CRUD" + echo " services - Запуск тестов сервисов" + echo " handlers - Запуск тестов обработчиков" + echo " middleware - Запуск тестов middleware" + echo " config - Запуск тестов конфигурации" + echo " coverage - Генерация отчета о покрытии" + echo " fast - Быстрый запуск тестов" + echo " debug - Запуск в режиме отладки" + echo " help - Показать эту справку" + echo "" + echo "Примеры использования:" + echo " ./tests/run_tests.sh all" + echo " ./tests/run_tests.sh unit" + echo " ./tests/run_tests.sh models" + echo " ./tests/run_tests.sh coverage" + ;; + + *) + echo "❌ Неизвестная команда: $1" + echo "Используйте './tests/run_tests.sh help' для справки" + exit 1 + ;; +esac + +echo "" +echo "✅ Тесты завершены!" diff --git a/tests/test_config.env b/tests/test_config.env new file mode 100644 index 0000000..2fb5ef3 --- /dev/null +++ b/tests/test_config.env @@ -0,0 +1,43 @@ +# Тестовая конфигурация для AnonBot +# Этот файл используется для тестов + +# Telegram Bot Token (тестовый) +TELEGRAM_BOT_TOKEN=test_token_1234567890 + +# База данных (в памяти для тестов) +DATABASE_PATH=:memory: + +# Администраторы (тестовые ID) +ADMINS=123456789,987654321 + +# Уровень логирования +LOG_LEVEL=DEBUG + +# Порт для метрик +METRICS_PORT=9090 + +# Rate limiting (тестовые значения) +RATE_LIMIT_MESSAGES_PER_MINUTE=60 +RATE_LIMIT_CALLBACKS_PER_MINUTE=30 + +# Валидация (тестовые значения) +MIN_QUESTION_LENGTH=10 +MAX_QUESTION_LENGTH=1000 +MIN_ANSWER_LENGTH=5 +MAX_ANSWER_LENGTH=2000 + +# Пагинация (тестовые значения) +DEFAULT_PAGE_SIZE=10 +MAX_PAGE_SIZE=50 + +# Логирование (тестовые значения) +LOG_TO_FILE=false +LOG_FILE_PATH=logs/test.log + +# Метрики (тестовые значения) +ENABLE_METRICS=true +METRICS_HOST=localhost + +# HTTP сервер (тестовые значения) +HTTP_HOST=localhost +HTTP_PORT=8080 diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e98e0a7 --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1,3 @@ +""" +Unit тесты для AnonBot +""" diff --git a/tests/unit/config/__init__.py b/tests/unit/config/__init__.py new file mode 100644 index 0000000..b052ef7 --- /dev/null +++ b/tests/unit/config/__init__.py @@ -0,0 +1,3 @@ +""" +Unit тесты для конфигурации +""" diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py new file mode 100644 index 0000000..62beb3f --- /dev/null +++ b/tests/unit/config/test_config.py @@ -0,0 +1,85 @@ +""" +Тесты для конфигурации + +Что тестировать: +- Загрузка конфигурации из .env +- Валидация обязательных параметров +- Валидация типов данных +- Валидация диапазонов значений +- Обработка отсутствующих параметров +- Обработка невалидных значений +- Обработка ошибок загрузки +- Интеграция с dotenv +""" +import pytest +from unittest.mock import patch, MagicMock +from config.config import config, load_config + + +class TestConfig: + """Тесты для конфигурации""" + + def test_config_initialization(self): + """Тест инициализации конфигурации""" + # TODO: Реализовать тест + pass + + def test_load_config_from_env(self): + """Тест загрузки конфигурации из .env""" + # TODO: Реализовать тест + pass + + def test_load_config_missing_required_params(self): + """Тест загрузки конфигурации с отсутствующими обязательными параметрами""" + # TODO: Реализовать тест + pass + + def test_load_config_invalid_types(self): + """Тест загрузки конфигурации с невалидными типами""" + # TODO: Реализовать тест + pass + + def test_load_config_invalid_ranges(self): + """Тест загрузки конфигурации с невалидными диапазонами""" + # TODO: Реализовать тест + pass + + def test_config_validation_telegram_token(self): + """Тест валидации Telegram токена""" + # TODO: Реализовать тест + pass + + def test_config_validation_database_path(self): + """Тест валидации пути к БД""" + # TODO: Реализовать тест + pass + + def test_config_validation_admins_list(self): + """Тест валидации списка админов""" + # TODO: Реализовать тест + pass + + def test_config_validation_rate_limits(self): + """Тест валидации лимитов rate limiting""" + # TODO: Реализовать тест + pass + + def test_config_validation_logging_level(self): + """Тест валидации уровня логирования""" + # TODO: Реализовать тест + pass + + def test_config_validation_metrics_port(self): + """Тест валидации порта метрик""" + # TODO: Реализовать тест + pass + + def test_config_error_handling(self): + """Тест обработки ошибок конфигурации""" + # TODO: Реализовать тест + pass + + def test_config_default_values(self): + """Тест значений по умолчанию""" + # TODO: Реализовать тест + pass diff --git a/tests/unit/config/test_constants.py b/tests/unit/config/test_constants.py new file mode 100644 index 0000000..f6f0d96 --- /dev/null +++ b/tests/unit/config/test_constants.py @@ -0,0 +1,65 @@ +""" +Тесты для констант + +Что тестировать: +- Значения констант +- Валидация констант +- Консистентность констант +- Обработка изменений констант +""" +import pytest +from config.constants import * + + +class TestConstants: + """Тесты для констант""" + + def test_question_constants(self): + """Тест констант для вопросов""" + # TODO: Реализовать тест + pass + + def test_answer_constants(self): + """Тест констант для ответов""" + # TODO: Реализовать тест + pass + + def test_user_constants(self): + """Тест констант для пользователей""" + # TODO: Реализовать тест + pass + + def test_validation_constants(self): + """Тест констант для валидации""" + # TODO: Реализовать тест + pass + + def test_rate_limit_constants(self): + """Тест констант для rate limiting""" + # TODO: Реализовать тест + pass + + def test_database_constants(self): + """Тест констант для БД""" + # TODO: Реализовать тест + pass + + def test_logging_constants(self): + """Тест констант для логирования""" + # TODO: Реализовать тест + pass + + def test_metrics_constants(self): + """Тест констант для метрик""" + # TODO: Реализовать тест + pass + + def test_constants_consistency(self): + """Тест консистентности констант""" + # TODO: Реализовать тест + pass + + def test_constants_validation(self): + """Тест валидации констант""" + # TODO: Реализовать тест + pass diff --git a/tests/unit/database/__init__.py b/tests/unit/database/__init__.py new file mode 100644 index 0000000..9721ab9 --- /dev/null +++ b/tests/unit/database/__init__.py @@ -0,0 +1,3 @@ +""" +Unit тесты для базы данных +""" diff --git a/tests/unit/database/test_crud.py b/tests/unit/database/test_crud.py new file mode 100644 index 0000000..7735db5 --- /dev/null +++ b/tests/unit/database/test_crud.py @@ -0,0 +1,314 @@ +""" +Тесты для CRUD операций + +Что тестировать: +- UserCRUD (создание, обновление, удаление, получение) +- QuestionCRUD (создание, обновление, удаление, получение) +- UserBlockCRUD (блокировки пользователей) +- UserSettingsCRUD (настройки пользователей) +- Batch операции (create_batch для пользователей и вопросов) +- Cursor-based пагинация +- Обработка ошибок БД +- Валидация входных данных +- Транзакции +- Connection pooling +- SQL injection защита +""" +import pytest +from unittest.mock import AsyncMock, MagicMock +from datetime import datetime +from database.crud import UserCRUD, QuestionCRUD, UserBlockCRUD, UserSettingsCRUD +from models.user import User +from models.question import Question, QuestionStatus +from models.user_block import UserBlock +from models.user_settings import UserSettings + + +class TestUserCRUD: + """Тесты для UserCRUD""" + + def test_create_user_basic(self): + """Тест базового создания пользователя""" + # TODO: Реализовать тест + pass + + def test_create_user_with_all_fields(self): + """Тест создания пользователя со всеми полями""" + # TODO: Реализовать тест + pass + + def test_create_user_duplicate_telegram_id(self): + """Тест создания пользователя с дублирующимся telegram_id""" + # TODO: Реализовать тест + pass + + def test_create_user_duplicate_profile_link(self): + """Тест создания пользователя с дублирующимся profile_link""" + # TODO: Реализовать тест + pass + + def test_create_batch_users(self): + """Тест batch создания пользователей""" + # TODO: Реализовать тест + pass + + def test_create_batch_users_empty_list(self): + """Тест batch создания пустого списка пользователей""" + # TODO: Реализовать тест + pass + + def test_get_by_telegram_id_existing(self): + """Тест получения пользователя по telegram_id - существующий""" + # TODO: Реализовать тест + pass + + def test_get_by_telegram_id_nonexistent(self): + """Тест получения пользователя по telegram_id - несуществующий""" + # TODO: Реализовать тест + pass + + def test_get_by_profile_link_existing(self): + """Тест получения пользователя по profile_link - существующий""" + # TODO: Реализовать тест + pass + + def test_get_by_profile_link_nonexistent(self): + """Тест получения пользователя по profile_link - несуществующий""" + # TODO: Реализовать тест + pass + + def test_update_user_existing(self): + """Тест обновления существующего пользователя""" + # TODO: Реализовать тест + pass + + def test_update_user_nonexistent(self): + """Тест обновления несуществующего пользователя""" + # TODO: Реализовать тест + pass + + def test_delete_user_existing(self): + """Тест удаления существующего пользователя""" + # TODO: Реализовать тест + pass + + def test_delete_user_nonexistent(self): + """Тест удаления несуществующего пользователя""" + # TODO: Реализовать тест + pass + + def test_get_all_users(self): + """Тест получения всех пользователей""" + # TODO: Реализовать тест + pass + + def test_get_all_users_cursor_pagination(self): + """Тест cursor-based пагинации пользователей""" + # TODO: Реализовать тест + pass + + def test_get_all_users_asc(self): + """Тест получения пользователей в порядке возрастания""" + # TODO: Реализовать тест + pass + + def test_get_stats(self): + """Тест получения статистики пользователей""" + # TODO: Реализовать тест + pass + + +class TestQuestionCRUD: + """Тесты для QuestionCRUD""" + + def test_create_question_basic(self): + """Тест базового создания вопроса""" + # TODO: Реализовать тест + pass + + def test_create_question_with_answer(self): + """Тест создания вопроса с ответом""" + # TODO: Реализовать тест + pass + + def test_create_question_anonymous(self): + """Тест создания анонимного вопроса""" + # TODO: Реализовать тест + pass + + def test_create_batch_questions(self): + """Тест batch создания вопросов""" + # TODO: Реализовать тест + pass + + def test_get_by_id_existing(self): + """Тест получения вопроса по ID - существующий""" + # TODO: Реализовать тест + pass + + def test_get_by_id_nonexistent(self): + """Тест получения вопроса по ID - несуществующий""" + # TODO: Реализовать тест + pass + + def test_get_by_to_user(self): + """Тест получения вопросов для пользователя""" + # TODO: Реализовать тест + pass + + def test_get_by_to_user_with_status_filter(self): + """Тест получения вопросов с фильтром по статусу""" + # TODO: Реализовать тест + pass + + def test_get_by_to_user_with_authors(self): + """Тест получения вопросов с информацией об авторах""" + # TODO: Реализовать тест + pass + + def test_get_by_to_user_cursor_pagination(self): + """Тест cursor-based пагинации вопросов""" + # TODO: Реализовать тест + pass + + def test_get_by_to_user_asc(self): + """Тест получения вопросов в порядке возрастания""" + # TODO: Реализовать тест + pass + + def test_update_question_existing(self): + """Тест обновления существующего вопроса""" + # TODO: Реализовать тест + pass + + def test_update_question_nonexistent(self): + """Тест обновления несуществующего вопроса""" + # TODO: Реализовать тест + pass + + def test_delete_question_existing(self): + """Тест удаления существующего вопроса""" + # TODO: Реализовать тест + pass + + def test_delete_question_nonexistent(self): + """Тест удаления несуществующего вопроса""" + # TODO: Реализовать тест + pass + + def test_get_stats(self): + """Тест получения статистики вопросов""" + # TODO: Реализовать тест + pass + + +class TestUserBlockCRUD: + """Тесты для UserBlockCRUD""" + + def test_create_block_basic(self): + """Тест базового создания блокировки""" + # TODO: Реализовать тест + pass + + def test_create_block_duplicate(self): + """Тест создания дублирующейся блокировки""" + # TODO: Реализовать тест + pass + + def test_create_block_self_block(self): + """Тест попытки заблокировать самого себя""" + # TODO: Реализовать тест + pass + + def test_get_block_existing(self): + """Тест получения существующей блокировки""" + # TODO: Реализовать тест + pass + + def test_get_block_nonexistent(self): + """Тест получения несуществующей блокировки""" + # TODO: Реализовать тест + pass + + def test_delete_block_existing(self): + """Тест удаления существующей блокировки""" + # TODO: Реализовать тест + pass + + def test_delete_block_nonexistent(self): + """Тест удаления несуществующей блокировки""" + # TODO: Реализовать тест + pass + + def test_get_blocks_by_blocker(self): + """Тест получения блокировок по блокирующему""" + # TODO: Реализовать тест + pass + + def test_get_blocks_by_blocked(self): + """Тест получения блокировок по заблокированному""" + # TODO: Реализовать тест + pass + + +class TestUserSettingsCRUD: + """Тесты для UserSettingsCRUD""" + + def test_create_settings_basic(self): + """Тест базового создания настроек""" + # TODO: Реализовать тест + pass + + def test_create_settings_duplicate_user(self): + """Тест создания дублирующихся настроек для пользователя""" + # TODO: Реализовать тест + pass + + def test_get_by_user_id_existing(self): + """Тест получения настроек по user_id - существующие""" + # TODO: Реализовать тест + pass + + def test_get_by_user_id_nonexistent(self): + """Тест получения настроек по user_id - несуществующие""" + # TODO: Реализовать тест + pass + + def test_update_settings_existing(self): + """Тест обновления существующих настроек""" + # TODO: Реализовать тест + pass + + def test_update_settings_nonexistent(self): + """Тест обновления несуществующих настроек""" + # TODO: Реализовать тест + pass + + def test_delete_settings_existing(self): + """Тест удаления существующих настроек""" + # TODO: Реализовать тест + pass + + def test_delete_settings_nonexistent(self): + """Тест удаления несуществующих настроек""" + # TODO: Реализовать тест + pass + + +class TestDatabaseErrors: + """Тесты для обработки ошибок БД""" + + def test_connection_error_handling(self): + """Тест обработки ошибок подключения""" + # TODO: Реализовать тест + pass + + def test_sql_error_handling(self): + """Тест обработки SQL ошибок""" + # TODO: Реализовать тест + pass + + def test_transaction_rollback(self): + """Тест отката транзакций при ошибках""" + # TODO: Реализовать тест + pass diff --git a/tests/unit/handlers/__init__.py b/tests/unit/handlers/__init__.py new file mode 100644 index 0000000..9560124 --- /dev/null +++ b/tests/unit/handlers/__init__.py @@ -0,0 +1,3 @@ +""" +Unit тесты для обработчиков +""" diff --git a/tests/unit/handlers/test_admin.py b/tests/unit/handlers/test_admin.py new file mode 100644 index 0000000..39ce863 --- /dev/null +++ b/tests/unit/handlers/test_admin.py @@ -0,0 +1,143 @@ +""" +Тесты для админских обработчиков + +Что тестировать: +- Обработка админских команд +- Управление пользователями +- Назначение/снятие суперпользователей +- Статистика бота +- Управление rate limiting +- Callback обработчики для админки +- Проверка прав доступа +- Форматирование админских данных +- Обработка ошибок +- Интеграция с сервисами +""" +import pytest +from unittest.mock import AsyncMock, MagicMock +from aiogram.types import Message, User, Chat, CallbackQuery +from aiogram.fsm.context import FSMContext +from handlers.admin import ( + admin_menu, admin_stats, admin_users, + assign_superuser_callback, confirm_superuser_callback, + remove_superuser_callback, admin_rate_limit_menu +) + + +class TestAdminHandlers: + """Тесты для админских обработчиков""" + + def test_admin_menu_basic(self): + """Тест базового админского меню""" + # TODO: Реализовать тест + pass + + def test_admin_menu_non_admin_user(self): + """Тест админского меню для не-админа""" + # TODO: Реализовать тест + pass + + def test_admin_stats_basic(self): + """Тест базовой админской статистики""" + # TODO: Реализовать тест + pass + + def test_admin_stats_with_data(self): + """Тест админской статистики с данными""" + # TODO: Реализовать тест + pass + + def test_admin_users_basic(self): + """Тест базового списка пользователей""" + # TODO: Реализовать тест + pass + + def test_admin_users_pagination(self): + """Тест пагинации списка пользователей""" + # TODO: Реализовать тест + pass + + def test_assign_superuser_callback_valid(self): + """Тест callback 'Назначить суперпользователя' - валидный""" + # TODO: Реализовать тест + pass + + def test_assign_superuser_callback_invalid_user_id(self): + """Тест callback 'Назначить суперпользователя' - невалидный ID пользователя""" + # TODO: Реализовать тест + pass + + def test_assign_superuser_callback_nonexistent_user(self): + """Тест callback 'Назначить суперпользователя' - несуществующий пользователь""" + # TODO: Реализовать тест + pass + + def test_confirm_superuser_callback_valid(self): + """Тест callback 'Подтвердить суперпользователя' - валидный""" + # TODO: Реализовать тест + pass + + def test_confirm_superuser_callback_invalid_user_id(self): + """Тест callback 'Подтвердить суперпользователя' - невалидный ID пользователя""" + # TODO: Реализовать тест + pass + + def test_remove_superuser_callback_valid(self): + """Тест callback 'Снять суперпользователя' - валидный""" + # TODO: Реализовать тест + pass + + def test_remove_superuser_callback_invalid_user_id(self): + """Тест callback 'Снять суперпользователя' - невалидный ID пользователя""" + # TODO: Реализовать тест + pass + + def test_admin_rate_limit_menu_basic(self): + """Тест базового меню rate limiting""" + # TODO: Реализовать тест + pass + + def test_admin_rate_limit_menu_with_stats(self): + """Тест меню rate limiting со статистикой""" + # TODO: Реализовать тест + pass + + def test_permission_checking_admin_required(self): + """Тест проверки прав - требуется админ""" + # TODO: Реализовать тест + pass + + def test_permission_checking_superuser_required(self): + """Тест проверки прав - требуется суперпользователь""" + # TODO: Реализовать тест + pass + + def test_format_admin_stats_basic(self): + """Тест базового форматирования админской статистики""" + # TODO: Реализовать тест + pass + + def test_format_users_list_basic(self): + """Тест базового форматирования списка пользователей""" + # TODO: Реализовать тест + pass + + def test_format_users_list_with_pagination(self): + """Тест форматирования списка пользователей с пагинацией""" + # TODO: Реализовать тест + pass + + def test_error_handling_admin(self): + """Тест обработки ошибок в админке""" + # TODO: Реализовать тест + pass + + def test_integration_with_auth_service(self): + """Тест интеграции с AuthService""" + # TODO: Реализовать тест + pass + + def test_integration_with_database_service(self): + """Тест интеграции с DatabaseService""" + # TODO: Реализовать тест + pass diff --git a/tests/unit/handlers/test_answers.py b/tests/unit/handlers/test_answers.py new file mode 100644 index 0000000..e2398da --- /dev/null +++ b/tests/unit/handlers/test_answers.py @@ -0,0 +1,127 @@ +""" +Тесты для обработчиков ответов + +Что тестировать: +- Обработка новых ответов +- Редактирование ответов +- Просмотр вопросов +- Callback обработчики для ответов +- FSM состояния для ответов +- Валидация текста ответов +- Форматирование ответов +- Обработка ошибок +- Интеграция с сервисами +""" +import pytest +from unittest.mock import AsyncMock, MagicMock +from aiogram.types import Message, User, Chat, CallbackQuery +from aiogram.fsm.context import FSMContext +from handlers.answers import ( + process_new_answer, process_edited_answer, + view_question_callback, edit_answer_callback, + delete_answer_callback +) + + +class TestAnswerHandlers: + """Тесты для обработчиков ответов""" + + def test_process_new_answer_valid(self): + """Тест обработки валидного нового ответа""" + # TODO: Реализовать тест + pass + + def test_process_new_answer_invalid_text(self): + """Тест обработки невалидного текста ответа""" + # TODO: Реализовать тест + pass + + def test_process_new_answer_too_long(self): + """Тест обработки слишком длинного ответа""" + # TODO: Реализовать тест + pass + + def test_process_new_answer_too_short(self): + """Тест обработки слишком короткого ответа""" + # TODO: Реализовать тест + pass + + def test_process_new_answer_spam(self): + """Тест обработки спам-ответа""" + # TODO: Реализовать тест + pass + + def test_process_edited_answer_valid(self): + """Тест обработки валидного редактированного ответа""" + # TODO: Реализовать тест + pass + + def test_process_edited_answer_invalid_text(self): + """Тест обработки невалидного текста редактированного ответа""" + # TODO: Реализовать тест + pass + + def test_view_question_callback_valid(self): + """Тест callback 'Просмотр вопроса' - валидный""" + # TODO: Реализовать тест + pass + + def test_view_question_callback_invalid_question_id(self): + """Тест callback 'Просмотр вопроса' - невалидный ID вопроса""" + # TODO: Реализовать тест + pass + + def test_view_question_callback_nonexistent_question(self): + """Тест callback 'Просмотр вопроса' - несуществующий вопрос""" + # TODO: Реализовать тест + pass + + def test_edit_answer_callback_valid(self): + """Тест callback 'Редактировать ответ' - валидный""" + # TODO: Реализовать тест + pass + + def test_edit_answer_callback_invalid_question_id(self): + """Тест callback 'Редактировать ответ' - невалидный ID вопроса""" + # TODO: Реализовать тест + pass + + def test_delete_answer_callback_valid(self): + """Тест callback 'Удалить ответ' - валидный""" + # TODO: Реализовать тест + pass + + def test_delete_answer_callback_invalid_question_id(self): + """Тест callback 'Удалить ответ' - невалидный ID вопроса""" + # TODO: Реализовать тест + pass + + def test_format_answer_info_basic(self): + """Тест базового форматирования информации об ответе""" + # TODO: Реализовать тест + pass + + def test_format_answer_info_with_question(self): + """Тест форматирования информации об ответе с вопросом""" + # TODO: Реализовать тест + pass + + def test_fsm_state_management_answers(self): + """Тест управления FSM состояниями для ответов""" + # TODO: Реализовать тест + pass + + def test_validation_integration_answers(self): + """Тест интеграции с валидацией для ответов""" + # TODO: Реализовать тест + pass + + def test_error_handling_answers(self): + """Тест обработки ошибок в ответах""" + # TODO: Реализовать тест + pass + + def test_integration_with_question_service(self): + """Тест интеграции с QuestionService""" + # TODO: Реализовать тест + pass diff --git a/tests/unit/handlers/test_questions.py b/tests/unit/handlers/test_questions.py new file mode 100644 index 0000000..f1df709 --- /dev/null +++ b/tests/unit/handlers/test_questions.py @@ -0,0 +1,142 @@ +""" +Тесты для обработчиков вопросов + +Что тестировать: +- Обработка анонимных вопросов +- Отображение списка вопросов +- Пагинация вопросов +- Callback обработчики (ответить, отклонить, удалить) +- FSM состояния для вопросов +- Валидация текста вопросов +- Форматирование списка вопросов +- Обработка ошибок +- Интеграция с сервисами +""" +import pytest +from unittest.mock import AsyncMock, MagicMock +from aiogram.types import Message, User, Chat, CallbackQuery +from aiogram.fsm.context import FSMContext +from handlers.questions import ( + process_anonymous_question, my_questions_button, + answer_question_callback, reject_question_callback, + delete_question_callback, block_user_callback +) + + +class TestQuestionHandlers: + """Тесты для обработчиков вопросов""" + + def test_process_anonymous_question_valid(self): + """Тест обработки валидного анонимного вопроса""" + # TODO: Реализовать тест + pass + + def test_process_anonymous_question_invalid_text(self): + """Тест обработки невалидного текста вопроса""" + # TODO: Реализовать тест + pass + + def test_process_anonymous_question_too_long(self): + """Тест обработки слишком длинного вопроса""" + # TODO: Реализовать тест + pass + + def test_process_anonymous_question_too_short(self): + """Тест обработки слишком короткого вопроса""" + # TODO: Реализовать тест + pass + + def test_process_anonymous_question_spam(self): + """Тест обработки спам-вопроса""" + # TODO: Реализовать тест + pass + + def test_my_questions_button_with_questions(self): + """Тест кнопки 'Мои вопросы' с существующими вопросами""" + # TODO: Реализовать тест + pass + + def test_my_questions_button_no_questions(self): + """Тест кнопки 'Мои вопросы' без вопросов""" + # TODO: Реализовать тест + pass + + def test_my_questions_button_pagination(self): + """Тест пагинации в списке вопросов""" + # TODO: Реализовать тест + pass + + def test_answer_question_callback_valid(self): + """Тест callback 'Ответить' - валидный""" + # TODO: Реализовать тест + pass + + def test_answer_question_callback_invalid_question_id(self): + """Тест callback 'Ответить' - невалидный ID вопроса""" + # TODO: Реализовать тест + pass + + def test_answer_question_callback_nonexistent_question(self): + """Тест callback 'Ответить' - несуществующий вопрос""" + # TODO: Реализовать тест + pass + + def test_reject_question_callback_valid(self): + """Тест callback 'Отклонить' - валидный""" + # TODO: Реализовать тест + pass + + def test_reject_question_callback_invalid_question_id(self): + """Тест callback 'Отклонить' - невалидный ID вопроса""" + # TODO: Реализовать тест + pass + + def test_delete_question_callback_valid(self): + """Тест callback 'Удалить' - валидный""" + # TODO: Реализовать тест + pass + + def test_delete_question_callback_invalid_question_id(self): + """Тест callback 'Удалить' - невалидный ID вопроса""" + # TODO: Реализовать тест + pass + + def test_block_user_callback_valid(self): + """Тест callback 'Заблокировать пользователя' - валидный""" + # TODO: Реализовать тест + pass + + def test_block_user_callback_invalid_user_id(self): + """Тест callback 'Заблокировать пользователя' - невалидный ID пользователя""" + # TODO: Реализовать тест + pass + + def test_format_questions_list_basic(self): + """Тест базового форматирования списка вопросов""" + # TODO: Реализовать тест + pass + + def test_format_questions_list_with_authors(self): + """Тест форматирования списка вопросов с авторами""" + # TODO: Реализовать тест + pass + + def test_format_questions_list_empty(self): + """Тест форматирования пустого списка вопросов""" + # TODO: Реализовать тест + pass + + def test_fsm_state_management_questions(self): + """Тест управления FSM состояниями для вопросов""" + # TODO: Реализовать тест + pass + + def test_validation_integration(self): + """Тест интеграции с валидацией""" + # TODO: Реализовать тест + pass + + def test_error_handling_questions(self): + """Тест обработки ошибок в вопросах""" + # TODO: Реализовать тест + pass diff --git a/tests/unit/handlers/test_start.py b/tests/unit/handlers/test_start.py new file mode 100644 index 0000000..97dbed3 --- /dev/null +++ b/tests/unit/handlers/test_start.py @@ -0,0 +1,104 @@ +""" +Тесты для обработчиков /start + +Что тестировать: +- Обработка команды /start без аргументов +- Обработка команды /start с deep link +- Обработка команды /help +- Создание/обновление пользователя +- Генерация приветственного сообщения +- Обработка deep links +- Валидация входных данных +- FSM состояния +- Обработка ошибок +- Интеграция с сервисами +""" +import pytest +from unittest.mock import AsyncMock, MagicMock +from aiogram.types import Message, User, Chat, CallbackQuery +from aiogram.fsm.context import FSMContext +from handlers.start import cmd_start, cmd_help, handle_deep_link, _process_start_command + + +class TestStartHandlers: + """Тесты для обработчиков /start""" + + def test_cmd_start_basic(self): + """Тест базовой команды /start""" + # TODO: Реализовать тест + pass + + def test_cmd_start_with_deep_link(self): + """Тест команды /start с deep link""" + # TODO: Реализовать тест + pass + + def test_cmd_start_database_error(self): + """Тест обработки ошибки БД в команде /start""" + # TODO: Реализовать тест + pass + + def test_cmd_help_basic(self): + """Тест базовой команды /help""" + # TODO: Реализовать тест + pass + + def test_cmd_help_with_user_info(self): + """Тест команды /help с информацией о пользователе""" + # TODO: Реализовать тест + pass + + def test_handle_deep_link_valid(self): + """Тест обработки валидного deep link""" + # TODO: Реализовать тест + pass + + def test_handle_deep_link_invalid(self): + """Тест обработки невалидного deep link""" + # TODO: Реализовать тест + pass + + def test_handle_deep_link_nonexistent_user(self): + """Тест обработки deep link для несуществующего пользователя""" + # TODO: Реализовать тест + pass + + def test_process_start_command_new_user(self): + """Тест обработки команды /start для нового пользователя""" + # TODO: Реализовать тест + pass + + def test_process_start_command_existing_user(self): + """Тест обработки команды /start для существующего пользователя""" + # TODO: Реализовать тест + pass + + def test_process_start_command_validation_error(self): + """Тест обработки ошибки валидации в команде /start""" + # TODO: Реализовать тест + pass + + def test_welcome_message_generation(self): + """Тест генерации приветственного сообщения""" + # TODO: Реализовать тест + pass + + def test_welcome_message_with_referral_link(self): + """Тест приветственного сообщения со ссылкой для рефералов""" + # TODO: Реализовать тест + pass + + def test_fsm_state_management(self): + """Тест управления FSM состояниями""" + # TODO: Реализовать тест + pass + + def test_error_handling_global(self): + """Тест глобальной обработки ошибок""" + # TODO: Реализовать тест + pass + + def test_integration_with_services(self): + """Тест интеграции с сервисами""" + # TODO: Реализовать тест + pass diff --git a/tests/unit/middlewares/__init__.py b/tests/unit/middlewares/__init__.py new file mode 100644 index 0000000..3582c5b --- /dev/null +++ b/tests/unit/middlewares/__init__.py @@ -0,0 +1,3 @@ +""" +Unit тесты для middleware +""" diff --git a/tests/unit/middlewares/test_rate_limit_middleware.py b/tests/unit/middlewares/test_rate_limit_middleware.py new file mode 100644 index 0000000..2c42493 --- /dev/null +++ b/tests/unit/middlewares/test_rate_limit_middleware.py @@ -0,0 +1,83 @@ +""" +Тесты для RateLimitMiddleware + +Что тестировать: +- Инициализация middleware +- Применение rate limiting к сообщениям +- Пропуск других типов событий +- Обработка ошибок rate limiting +- Интеграция с telegram_rate_limiter +- Логирование rate limit событий +- Производительность middleware +- Обработка TelegramRetryAfter +- Обработка TelegramAPIError +""" +import pytest +from unittest.mock import AsyncMock, MagicMock +from aiogram.types import Message, User, Chat, CallbackQuery, Update +from aiogram.exceptions import TelegramRetryAfter, TelegramAPIError +from middlewares.rate_limit_middleware import RateLimitMiddleware + + +class TestRateLimitMiddleware: + """Тесты для RateLimitMiddleware""" + + def test_middleware_initialization(self): + """Тест инициализации middleware""" + # TODO: Реализовать тест + pass + + def test_apply_rate_limit_to_message(self): + """Тест применения rate limiting к сообщению""" + # TODO: Реализовать тест + pass + + def test_skip_rate_limit_for_callback_query(self): + """Тест пропуска rate limiting для CallbackQuery""" + # TODO: Реализовать тест + pass + + def test_skip_rate_limit_for_update(self): + """Тест пропуска rate limiting для Update""" + # TODO: Реализовать тест + pass + + def test_handle_telegram_retry_after(self): + """Тест обработки TelegramRetryAfter""" + # TODO: Реализовать тест + pass + + def test_handle_telegram_api_error(self): + """Тест обработки TelegramAPIError""" + # TODO: Реализовать тест + pass + + def test_rate_limit_success(self): + """Тест успешного rate limiting""" + # TODO: Реализовать тест + pass + + def test_rate_limit_exceeded(self): + """Тест превышения rate limit""" + # TODO: Реализовать тест + pass + + def test_middleware_with_none_message(self): + """Тест middleware с None сообщением""" + # TODO: Реализовать тест + pass + + def test_middleware_with_none_chat_id(self): + """Тест middleware с None chat_id""" + # TODO: Реализовать тест + pass + + def test_performance_with_high_frequency(self): + """Тест производительности при высокой частоте""" + # TODO: Реализовать тест + pass + + def test_integration_with_telegram_rate_limiter(self): + """Тест интеграции с telegram_rate_limiter""" + # TODO: Реализовать тест + pass diff --git a/tests/unit/middlewares/test_validation_middleware.py b/tests/unit/middlewares/test_validation_middleware.py new file mode 100644 index 0000000..dfa59eb --- /dev/null +++ b/tests/unit/middlewares/test_validation_middleware.py @@ -0,0 +1,94 @@ +""" +Тесты для ValidationMiddleware + +Что тестировать: +- Инициализация middleware +- Валидация CallbackQuery +- Валидация Message +- Обработка ошибок валидации +- Пропуск невалидных данных +- Логирование ошибок +- Интеграция с InputValidator +- Обработка различных типов событий +- Возврат санитизированных данных +- Производительность middleware +""" +import pytest +from unittest.mock import AsyncMock, MagicMock +from aiogram.types import CallbackQuery, Message, User, Chat, Update +from middlewares.validation_middleware import ValidationMiddleware, ValidationError +from services.validation import InputValidator, ValidationResult + + +class TestValidationMiddleware: + """Тесты для ValidationMiddleware""" + + def test_middleware_initialization(self): + """Тест инициализации middleware""" + # TODO: Реализовать тест + pass + + def test_validate_callback_query_valid(self): + """Тест валидации корректного CallbackQuery""" + # TODO: Реализовать тест + pass + + def test_validate_callback_query_invalid(self): + """Тест валидации некорректного CallbackQuery""" + # TODO: Реализовать тест + pass + + def test_validate_message_valid(self): + """Тест валидации корректного Message""" + # TODO: Реализовать тест + pass + + def test_validate_message_invalid(self): + """Тест валидации некорректного Message""" + # TODO: Реализовать тест + pass + + def test_validation_error_handling(self): + """Тест обработки ошибок валидации""" + # TODO: Реализовать тест + pass + + def test_validation_error_response(self): + """Тест ответа на ошибку валидации""" + # TODO: Реализовать тест + pass + + def test_unsupported_event_type(self): + """Тест обработки неподдерживаемого типа события""" + # TODO: Реализовать тест + pass + + def test_sanitized_data_injection(self): + """Тест инъекции санитизированных данных""" + # TODO: Реализовать тест + pass + + def test_validator_injection(self): + """Тест инъекции валидатора в данные""" + # TODO: Реализовать тест + pass + + def test_handler_continuation_on_valid_data(self): + """Тест продолжения обработки при валидных данных""" + # TODO: Реализовать тест + pass + + def test_handler_stop_on_invalid_data(self): + """Тест остановки обработки при невалидных данных""" + # TODO: Реализовать тест + pass + + def test_performance_with_large_data(self): + """Тест производительности с большими данными""" + # TODO: Реализовать тест + pass + + def test_middleware_with_none_validator(self): + """Тест middleware с None валидатором""" + # TODO: Реализовать тест + pass diff --git a/tests/unit/models/__init__.py b/tests/unit/models/__init__.py new file mode 100644 index 0000000..2a8db07 --- /dev/null +++ b/tests/unit/models/__init__.py @@ -0,0 +1,3 @@ +""" +Unit тесты для моделей данных +""" diff --git a/tests/unit/models/test_question.py b/tests/unit/models/test_question.py new file mode 100644 index 0000000..c8d8e4e --- /dev/null +++ b/tests/unit/models/test_question.py @@ -0,0 +1,126 @@ +""" +Тесты для модели Question и QuestionStatus + +Что тестировать: +- Создание объекта Question +- QuestionStatus enum (все значения) +- Валидация полей (message_text, answer_text, etc.) +- Методы форматирования +- Статусы вопросов (pending, answered, rejected, deleted) +- Временные поля (created_at, answered_at) +- Анонимность (is_anonymous) +- Связи с пользователями (from_user_id, to_user_id) +- Методы изменения статуса +- Валидация длины текста +""" +import pytest +from datetime import datetime +from models.question import Question, QuestionStatus + + +class TestQuestionStatus: + """Тесты для enum QuestionStatus""" + + def test_question_status_values(self): + """Тест всех значений QuestionStatus""" + # TODO: Реализовать тест + pass + + def test_question_status_string_values(self): + """Тест строковых значений QuestionStatus""" + # TODO: Реализовать тест + pass + + +class TestQuestion: + """Тесты для модели Question""" + + def test_question_creation_basic(self): + """Тест базового создания вопроса""" + # TODO: Реализовать тест + pass + + def test_question_creation_with_all_fields(self): + """Тест создания вопроса со всеми полями""" + # TODO: Реализовать тест + pass + + def test_question_validation_message_text_required(self): + """Тест обязательности message_text""" + # TODO: Реализовать тест + pass + + def test_question_validation_to_user_id_required(self): + """Тест обязательности to_user_id""" + # TODO: Реализовать тест + pass + + def test_question_default_status(self): + """Тест статуса по умолчанию""" + # TODO: Реализовать тест + pass + + def test_question_default_anonymous(self): + """Тест анонимности по умолчанию""" + # TODO: Реализовать тест + pass + + def test_question_default_is_read(self): + """Тест флага is_read по умолчанию""" + # TODO: Реализовать тест + pass + + def test_question_created_at_timestamp(self): + """Тест временной метки создания""" + # TODO: Реализовать тест + pass + + def test_question_answer_timestamp(self): + """Тест временной метки ответа""" + # TODO: Реализовать тест + pass + + def test_question_mark_as_answered(self): + """Тест метода mark_as_answered""" + # TODO: Реализовать тест + pass + + def test_question_mark_as_rejected(self): + """Тест метода mark_as_rejected""" + # TODO: Реализовать тест + pass + + def test_question_mark_as_deleted(self): + """Тест метода mark_as_deleted""" + # TODO: Реализовать тест + pass + + def test_question_mark_as_read(self): + """Тест метода mark_as_read""" + # TODO: Реализовать тест + pass + + def test_question_formatting_methods(self): + """Тест методов форматирования""" + # TODO: Реализовать тест + pass + + def test_question_validation_message_length(self): + """Тест валидации длины сообщения""" + # TODO: Реализовать тест + pass + + def test_question_validation_answer_length(self): + """Тест валидации длины ответа""" + # TODO: Реализовать тест + pass + + def test_question_serialization(self): + """Тест сериализации вопроса""" + # TODO: Реализовать тест + pass + + def test_question_deserialization(self): + """Тест десериализации вопроса""" + # TODO: Реализовать тест + pass diff --git a/tests/unit/models/test_user.py b/tests/unit/models/test_user.py new file mode 100644 index 0000000..c1c5822 --- /dev/null +++ b/tests/unit/models/test_user.py @@ -0,0 +1,119 @@ +""" +Тесты для модели User + +Что тестировать: +- Создание объекта User +- Валидация полей (telegram_id, username, first_name, etc.) +- Методы форматирования (get_display_name, get_profile_link) +- HTML экранирование (escape_html) +- Сериализация/десериализация +- Обработка None значений +- Валидация email (если есть) +- Проверка is_active, is_superuser флагов +""" +import pytest +from datetime import datetime +from models.user import User, escape_html + + +class TestUser: + """Тесты для модели User""" + + def test_user_creation_basic(self): + """Тест базового создания пользователя""" + # TODO: Реализовать тест + pass + + def test_user_creation_with_all_fields(self): + """Тест создания пользователя со всеми полями""" + # TODO: Реализовать тест + pass + + def test_user_creation_minimal_required_fields(self): + """Тест создания пользователя с минимальными обязательными полями""" + # TODO: Реализовать тест + pass + + def test_user_validation_telegram_id(self): + """Тест валидации telegram_id""" + # TODO: Реализовать тест + pass + + def test_user_validation_username(self): + """Тест валидации username""" + # TODO: Реализовать тест + pass + + def test_user_validation_first_name_required(self): + """Тест обязательности first_name""" + # TODO: Реализовать тест + pass + + def test_user_display_name(self): + """Тест метода get_display_name""" + # TODO: Реализовать тест + pass + + def test_user_profile_link_generation(self): + """Тест генерации ссылки профиля""" + # TODO: Реализовать тест + pass + + def test_user_html_escaping(self): + """Тест HTML экранирования""" + # TODO: Реализовать тест + pass + + def test_user_serialization(self): + """Тест сериализации пользователя""" + # TODO: Реализовать тест + pass + + def test_user_deserialization(self): + """Тест десериализации пользователя""" + # TODO: Реализовать тест + pass + + def test_user_none_handling(self): + """Тест обработки None значений""" + # TODO: Реализовать тест + pass + + def test_user_is_active_flag(self): + """Тест флага is_active""" + # TODO: Реализовать тест + pass + + def test_user_is_superuser_flag(self): + """Тест флага is_superuser""" + # TODO: Реализовать тест + pass + + def test_user_ban_fields(self): + """Тест полей бана (banned_until, ban_reason)""" + # TODO: Реализовать тест + pass + + +class TestEscapeHtml: + """Тесты для функции escape_html""" + + def test_escape_html_basic(self): + """Тест базового HTML экранирования""" + # TODO: Реализовать тест + pass + + def test_escape_html_special_characters(self): + """Тест экранирования специальных символов""" + # TODO: Реализовать тест + pass + + def test_escape_html_none_input(self): + """Тест обработки None входных данных""" + # TODO: Реализовать тест + pass + + def test_escape_html_empty_string(self): + """Тест обработки пустой строки""" + # TODO: Реализовать тест + pass diff --git a/tests/unit/models/test_user_block.py b/tests/unit/models/test_user_block.py new file mode 100644 index 0000000..ee9aa20 --- /dev/null +++ b/tests/unit/models/test_user_block.py @@ -0,0 +1,74 @@ +""" +Тесты для модели UserBlock + +Что тестировать: +- Создание объекта UserBlock +- Валидация полей (blocker_id, blocked_id) +- Временные поля (created_at) +- Уникальность пары (blocker_id, blocked_id) +- Валидация ID пользователей +- Сериализация/десериализация +- Обработка None значений +""" +import pytest +from datetime import datetime +from models.user_block import UserBlock + + +class TestUserBlock: + """Тесты для модели UserBlock""" + + def test_user_block_creation_basic(self): + """Тест базового создания блокировки""" + # TODO: Реализовать тест + pass + + def test_user_block_creation_with_timestamp(self): + """Тест создания блокировки с временной меткой""" + # TODO: Реализовать тест + pass + + def test_user_block_validation_blocker_id_required(self): + """Тест обязательности blocker_id""" + # TODO: Реализовать тест + pass + + def test_user_block_validation_blocked_id_required(self): + """Тест обязательности blocked_id""" + # TODO: Реализовать тест + pass + + def test_user_block_validation_different_ids(self): + """Тест валидации разных ID (нельзя заблокировать себя)""" + # TODO: Реализовать тест + pass + + def test_user_block_validation_positive_ids(self): + """Тест валидации положительных ID""" + # TODO: Реализовать тест + pass + + def test_user_block_created_at_timestamp(self): + """Тест временной метки создания""" + # TODO: Реализовать тест + pass + + def test_user_block_serialization(self): + """Тест сериализации блокировки""" + # TODO: Реализовать тест + pass + + def test_user_block_deserialization(self): + """Тест десериализации блокировки""" + # TODO: Реализовать тест + pass + + def test_user_block_equality(self): + """Тест сравнения блокировок""" + # TODO: Реализовать тест + pass + + def test_user_block_string_representation(self): + """Тест строкового представления""" + # TODO: Реализовать тест + pass diff --git a/tests/unit/models/test_user_example.py b/tests/unit/models/test_user_example.py new file mode 100644 index 0000000..2d8aa47 --- /dev/null +++ b/tests/unit/models/test_user_example.py @@ -0,0 +1,252 @@ +""" +Пример теста для модели User + +Этот файл демонстрирует, как должны выглядеть реальные тесты. +Остальные файлы содержат только заглушки с TODO комментариями. +""" +import pytest +from datetime import datetime +from models.user import User, escape_html + + +class TestUserExample: + """Пример тестов для модели User""" + + def test_user_creation_basic(self): + """Тест базового создания пользователя""" + # Arrange + telegram_id = 123456789 + first_name = "Test" + chat_id = 123456789 + profile_link = "test_link" + + # Act + user = User( + telegram_id=telegram_id, + first_name=first_name, + chat_id=chat_id, + profile_link=profile_link + ) + + # Assert + assert user.telegram_id == telegram_id + assert user.first_name == first_name + assert user.chat_id == chat_id + assert user.profile_link == profile_link + assert user.is_active is True + assert user.is_superuser is False + assert user.created_at is not None + assert user.updated_at is not None + + def test_user_creation_with_all_fields(self): + """Тест создания пользователя со всеми полями""" + # Arrange + telegram_id = 123456789 + username = "testuser" + first_name = "Test" + last_name = "User" + chat_id = 123456789 + profile_link = "test_link" + is_active = True + is_superuser = False + created_at = datetime.now() + updated_at = datetime.now() + banned_until = None + ban_reason = None + + # Act + user = User( + telegram_id=telegram_id, + username=username, + first_name=first_name, + last_name=last_name, + chat_id=chat_id, + profile_link=profile_link, + is_active=is_active, + is_superuser=is_superuser, + created_at=created_at, + updated_at=updated_at, + banned_until=banned_until, + ban_reason=ban_reason + ) + + # Assert + assert user.telegram_id == telegram_id + assert user.username == username + assert user.first_name == first_name + assert user.last_name == last_name + assert user.chat_id == chat_id + assert user.profile_link == profile_link + assert user.is_active == is_active + assert user.is_superuser == is_superuser + assert user.created_at == created_at + assert user.updated_at == updated_at + assert user.banned_until == banned_until + assert user.ban_reason == ban_reason + + def test_user_validation_telegram_id_positive(self): + """Тест валидации положительного Telegram ID""" + # Arrange + telegram_id = 123456789 + + # Act + user = User( + telegram_id=telegram_id, + first_name="Test", + chat_id=123456789, + profile_link="test_link" + ) + + # Assert + assert user.telegram_id == telegram_id + assert user.telegram_id > 0 + + def test_user_display_name_with_username(self): + """Тест метода get_display_name с username""" + # Arrange + user = User( + telegram_id=123456789, + username="testuser", + first_name="Test", + last_name="User", + chat_id=123456789, + profile_link="test_link" + ) + + # Act + display_name = user.get_display_name() + + # Assert + assert display_name == "@testuser" + + def test_user_display_name_without_username(self): + """Тест метода get_display_name без username""" + # Arrange + user = User( + telegram_id=123456789, + first_name="Test", + last_name="User", + chat_id=123456789, + profile_link="test_link" + ) + + # Act + display_name = user.get_display_name() + + # Assert + assert display_name == "Test User" + + def test_user_display_name_first_name_only(self): + """Тест метода get_display_name только с first_name""" + # Arrange + user = User( + telegram_id=123456789, + first_name="Test", + chat_id=123456789, + profile_link="test_link" + ) + + # Act + display_name = user.get_display_name() + + # Assert + assert display_name == "Test" + + def test_user_profile_link_generation(self): + """Тест генерации ссылки профиля""" + # Arrange + user = User( + telegram_id=123456789, + first_name="Test", + chat_id=123456789, + profile_link="test_link" + ) + + # Act + profile_link = user.get_profile_link() + + # Assert + assert profile_link == "test_link" + assert profile_link.startswith("https://t.me/") + + def test_user_string_representation(self): + """Тест строкового представления пользователя""" + # Arrange + user = User( + telegram_id=123456789, + username="testuser", + first_name="Test", + last_name="User", + chat_id=123456789, + profile_link="test_link" + ) + + # Act + str_repr = str(user) + + # Assert + assert "User" in str_repr + assert "123456789" in str_repr + assert "testuser" in str_repr + + +class TestEscapeHtmlExample: + """Пример тестов для функции escape_html""" + + def test_escape_html_basic(self): + """Тест базового HTML экранирования""" + # Arrange + text = "" + + # Act + escaped = escape_html(text) + + # Assert + assert escaped == "<script>alert('xss')</script>" + assert "<" not in escaped + assert ">" not in escaped + assert "'" not in escaped + + def test_escape_html_special_characters(self): + """Тест экранирования специальных символов""" + # Arrange + text = "Test & < > \" '" + + # Act + escaped = escape_html(text) + + # Assert + assert escaped == "Test & < > " '" + + def test_escape_html_none_input(self): + """Тест обработки None входных данных""" + # Arrange + text = None + + # Act + escaped = escape_html(text) + + # Assert + assert escaped is None + + def test_escape_html_empty_string(self): + """Тест обработки пустой строки""" + # Arrange + text = "" + + # Act + escaped = escape_html(text) + + # Assert + assert escaped == "" + + def test_escape_html_already_escaped(self): + """Тест обработки уже экранированного текста""" + # Arrange + text = "<script>" + + # Act + escaped = escape_html(text) + + # Assert + assert escaped == "&lt;script&gt;" diff --git a/tests/unit/models/test_user_settings.py b/tests/unit/models/test_user_settings.py new file mode 100644 index 0000000..cf17b0e --- /dev/null +++ b/tests/unit/models/test_user_settings.py @@ -0,0 +1,91 @@ +""" +Тесты для модели UserSettings + +Что тестировать: +- Создание объекта UserSettings +- Валидация полей (user_id, allow_questions, etc.) +- Булевы флаги (allow_questions, notify_new_questions, notify_answers) +- Языковые настройки (language) +- Временные поля (created_at, updated_at) +- Связь с пользователем (user_id) +- Сериализация/десериализация +- Обработка None значений +- Валидация языка +""" +import pytest +from datetime import datetime +from models.user_settings import UserSettings + + +class TestUserSettings: + """Тесты для модели UserSettings""" + + def test_user_settings_creation_basic(self): + """Тест базового создания настроек""" + # TODO: Реализовать тест + pass + + def test_user_settings_creation_with_all_fields(self): + """Тест создания настроек со всеми полями""" + # TODO: Реализовать тест + pass + + def test_user_settings_validation_user_id_required(self): + """Тест обязательности user_id""" + # TODO: Реализовать тест + pass + + def test_user_settings_default_allow_questions(self): + """Тест значения по умолчанию для allow_questions""" + # TODO: Реализовать тест + pass + + def test_user_settings_default_notify_new_questions(self): + """Тест значения по умолчанию для notify_new_questions""" + # TODO: Реализовать тест + pass + + def test_user_settings_default_notify_answers(self): + """Тест значения по умолчанию для notify_answers""" + # TODO: Реализовать тест + pass + + def test_user_settings_default_language(self): + """Тест языка по умолчанию""" + # TODO: Реализовать тест + pass + + def test_user_settings_validation_language(self): + """Тест валидации языка""" + # TODO: Реализовать тест + pass + + def test_user_settings_created_at_timestamp(self): + """Тест временной метки создания""" + # TODO: Реализовать тест + pass + + def test_user_settings_updated_at_timestamp(self): + """Тест временной метки обновления""" + # TODO: Реализовать тест + pass + + def test_user_settings_serialization(self): + """Тест сериализации настроек""" + # TODO: Реализовать тест + pass + + def test_user_settings_deserialization(self): + """Тест десериализации настроек""" + # TODO: Реализовать тест + pass + + def test_user_settings_boolean_flags(self): + """Тест булевых флагов""" + # TODO: Реализовать тест + pass + + def test_user_settings_none_handling(self): + """Тест обработки None значений""" + # TODO: Реализовать тест + pass diff --git a/tests/unit/services/__init__.py b/tests/unit/services/__init__.py new file mode 100644 index 0000000..9938d60 --- /dev/null +++ b/tests/unit/services/__init__.py @@ -0,0 +1,3 @@ +""" +Unit тесты для сервисов +""" diff --git a/tests/unit/services/auth/__init__.py b/tests/unit/services/auth/__init__.py new file mode 100644 index 0000000..6fb3167 --- /dev/null +++ b/tests/unit/services/auth/__init__.py @@ -0,0 +1,3 @@ +""" +Unit тесты для сервисов авторизации +""" diff --git a/tests/unit/services/auth/test_auth_service.py b/tests/unit/services/auth/test_auth_service.py new file mode 100644 index 0000000..acc6814 --- /dev/null +++ b/tests/unit/services/auth/test_auth_service.py @@ -0,0 +1,111 @@ +""" +Тесты для AuthService + +Что тестировать: +- Проверка администраторов (is_admin) +- Проверка суперпользователей (is_superuser) +- Получение роли пользователя (get_user_role) +- Проверка разрешений (has_permission) +- Обработка ошибок БД +- Интеграция с системой разрешений +- Кэширование результатов +- Граничные случаи (несуществующие пользователи) +- Валидация входных параметров +""" +import pytest +from unittest.mock import AsyncMock, MagicMock +from services.auth.auth_new import AuthService + + +class TestAuthService: + """Тесты для AuthService""" + + def test_is_admin_valid_admin(self): + """Тест проверки администратора - валидный админ""" + # TODO: Реализовать тест + pass + + def test_is_admin_invalid_admin(self): + """Тест проверки администратора - не админ""" + # TODO: Реализовать тест + pass + + def test_is_admin_none_user_id(self): + """Тест проверки администратора - None user_id""" + # TODO: Реализовать тест + pass + + def test_is_superuser_valid_superuser(self): + """Тест проверки суперпользователя - валидный суперпользователь""" + # TODO: Реализовать тест + pass + + def test_is_superuser_invalid_superuser(self): + """Тест проверки суперпользователя - не суперпользователь""" + # TODO: Реализовать тест + pass + + def test_is_superuser_nonexistent_user(self): + """Тест проверки суперпользователя - несуществующий пользователь""" + # TODO: Реализовать тест + pass + + def test_is_superuser_database_error(self): + """Тест проверки суперпользователя - ошибка БД""" + # TODO: Реализовать тест + pass + + def test_get_user_role_admin(self): + """Тест получения роли - администратор""" + # TODO: Реализовать тест + pass + + def test_get_user_role_superuser(self): + """Тест получения роли - суперпользователь""" + # TODO: Реализовать тест + pass + + def test_get_user_role_regular_user(self): + """Тест получения роли - обычный пользователь""" + # TODO: Реализовать тест + pass + + def test_get_user_role_nonexistent_user(self): + """Тест получения роли - несуществующий пользователь""" + # TODO: Реализовать тест + pass + + def test_has_permission_valid_permission(self): + """Тест проверки разрешения - валидное разрешение""" + # TODO: Реализовать тест + pass + + def test_has_permission_invalid_permission(self): + """Тест проверки разрешения - невалидное разрешение""" + # TODO: Реализовать тест + pass + + def test_has_permission_nonexistent_user(self): + """Тест проверки разрешения - несуществующий пользователь""" + # TODO: Реализовать тест + pass + + def test_has_permission_database_error(self): + """Тест проверки разрешения - ошибка БД""" + # TODO: Реализовать тест + pass + + def test_auth_service_initialization(self): + """Тест инициализации AuthService""" + # TODO: Реализовать тест + pass + + def test_auth_service_with_none_database(self): + """Тест AuthService с None базой данных""" + # TODO: Реализовать тест + pass + + def test_auth_service_with_none_config(self): + """Тест AuthService с None конфигурацией""" + # TODO: Реализовать тест + pass diff --git a/tests/unit/services/auth/test_permissions.py b/tests/unit/services/auth/test_permissions.py new file mode 100644 index 0000000..2e15886 --- /dev/null +++ b/tests/unit/services/auth/test_permissions.py @@ -0,0 +1,139 @@ +""" +Тесты для системы разрешений + +Что тестировать: +- Базовый класс Permission +- Конкретные разрешения (AdminPermission, SuperuserPermission) +- Реестр разрешений (PermissionRegistry) +- Декораторы проверки разрешений +- Инициализация разрешений +- Проверка разрешений для разных ролей +- Обработка ошибок в разрешениях +- Кэширование результатов проверки +- Интеграция с AuthService +""" +import pytest +from unittest.mock import AsyncMock, MagicMock +from services.permissions.base import Permission, PermissionRegistry +from services.permissions.permissions import AdminPermission, SuperuserPermission +from services.permissions.decorators import require_permission +from services.permissions.init_permissions import init_all_permissions + + +class TestPermission: + """Тесты для базового класса Permission""" + + def test_permission_creation(self): + """Тест создания разрешения""" + # TODO: Реализовать тест + pass + + def test_permission_abstract_method(self): + """Тест абстрактного метода check""" + # TODO: Реализовать тест + pass + + def test_permission_string_representation(self): + """Тест строкового представления разрешения""" + # TODO: Реализовать тест + pass + + +class TestAdminPermission: + """Тесты для AdminPermission""" + + def test_admin_permission_check_valid_admin(self): + """Тест проверки разрешения - валидный админ""" + # TODO: Реализовать тест + pass + + def test_admin_permission_check_invalid_admin(self): + """Тест проверки разрешения - не админ""" + # TODO: Реализовать тест + pass + + def test_admin_permission_check_none_user_id(self): + """Тест проверки разрешения - None user_id""" + # TODO: Реализовать тест + pass + + +class TestSuperuserPermission: + """Тесты для SuperuserPermission""" + + def test_superuser_permission_check_valid_superuser(self): + """Тест проверки разрешения - валидный суперпользователь""" + # TODO: Реализовать тест + pass + + def test_superuser_permission_check_invalid_superuser(self): + """Тест проверки разрешения - не суперпользователь""" + # TODO: Реализовать тест + pass + + def test_superuser_permission_check_database_error(self): + """Тест проверки разрешения - ошибка БД""" + # TODO: Реализовать тест + pass + + +class TestPermissionRegistry: + """Тесты для PermissionRegistry""" + + def test_permission_registry_creation(self): + """Тест создания реестра разрешений""" + # TODO: Реализовать тест + pass + + def test_register_permission(self): + """Тест регистрации разрешения""" + # TODO: Реализовать тест + pass + + def test_get_permission_existing(self): + """Тест получения существующего разрешения""" + # TODO: Реализовать тест + pass + + def test_get_permission_nonexistent(self): + """Тест получения несуществующего разрешения""" + # TODO: Реализовать тест + pass + + def test_list_permissions(self): + """Тест получения списка разрешений""" + # TODO: Реализовать тест + pass + + +class TestRequirePermissionDecorator: + """Тесты для декоратора require_permission""" + + def test_require_permission_valid_permission(self): + """Тест декоратора - валидное разрешение""" + # TODO: Реализовать тест + pass + + def test_require_permission_invalid_permission(self): + """Тест декоратора - невалидное разрешение""" + # TODO: Реализовать тест + pass + + def test_require_permission_error_message(self): + """Тест декоратора - сообщение об ошибке""" + # TODO: Реализовать тест + pass + + +class TestInitPermissions: + """Тесты для инициализации разрешений""" + + def test_init_all_permissions(self): + """Тест инициализации всех разрешений""" + # TODO: Реализовать тест + pass + + def test_get_available_permissions(self): + """Тест получения доступных разрешений""" + # TODO: Реализовать тест + pass diff --git a/tests/unit/services/business/__init__.py b/tests/unit/services/business/__init__.py new file mode 100644 index 0000000..20b1a6a --- /dev/null +++ b/tests/unit/services/business/__init__.py @@ -0,0 +1,3 @@ +""" +Unit тесты для бизнес-сервисов +""" diff --git a/tests/unit/services/business/test_message_service.py b/tests/unit/services/business/test_message_service.py new file mode 100644 index 0000000..db51504 --- /dev/null +++ b/tests/unit/services/business/test_message_service.py @@ -0,0 +1,102 @@ +""" +Тесты для MessageService + +Что тестировать: +- Отправка сообщений (send_message) +- Отправка сообщений с клавиатурой +- Отправка сообщений об ошибках +- Форматирование сообщений +- Валидация входных данных +- Обработка ошибок отправки +- Интеграция с ботом +- Логирование операций +- Rate limiting интеграция +""" +import pytest +from unittest.mock import AsyncMock, MagicMock +from aiogram.types import Message, InlineKeyboardMarkup, ReplyKeyboardMarkup +from services.business.message_service import MessageService + + +class TestMessageService: + """Тесты для MessageService""" + + def test_send_message_basic(self): + """Тест базовой отправки сообщения""" + # TODO: Реализовать тест + pass + + def test_send_message_with_inline_keyboard(self): + """Тест отправки сообщения с inline клавиатурой""" + # TODO: Реализовать тест + pass + + def test_send_message_with_reply_keyboard(self): + """Тест отправки сообщения с reply клавиатурой""" + # TODO: Реализовать тест + pass + + def test_send_message_with_parse_mode(self): + """Тест отправки сообщения с режимом парсинга""" + # TODO: Реализовать тест + pass + + def test_send_error_message(self): + """Тест отправки сообщения об ошибке""" + # TODO: Реализовать тест + pass + + def test_send_error_message_with_keyboard(self): + """Тест отправки сообщения об ошибке с клавиатурой""" + # TODO: Реализовать тест + pass + + def test_format_message_basic(self): + """Тест базового форматирования сообщения""" + # TODO: Реализовать тест + pass + + def test_format_message_with_placeholders(self): + """Тест форматирования сообщения с плейсхолдерами""" + # TODO: Реализовать тест + pass + + def test_format_message_html_escaping(self): + """Тест HTML экранирования в сообщениях""" + # TODO: Реализовать тест + pass + + def test_validate_message_text_valid(self): + """Тест валидации корректного текста сообщения""" + # TODO: Реализовать тест + pass + + def test_validate_message_text_invalid(self): + """Тест валидации некорректного текста сообщения""" + # TODO: Реализовать тест + pass + + def test_send_message_telegram_error(self): + """Тест обработки ошибки Telegram API""" + # TODO: Реализовать тест + pass + + def test_send_message_network_error(self): + """Тест обработки сетевой ошибки""" + # TODO: Реализовать тест + pass + + def test_send_message_rate_limit_error(self): + """Тест обработки ошибки rate limiting""" + # TODO: Реализовать тест + pass + + def test_message_service_initialization(self): + """Тест инициализации MessageService""" + # TODO: Реализовать тест + pass + + def test_message_service_with_none_bot(self): + """Тест MessageService с None ботом""" + # TODO: Реализовать тест + pass diff --git a/tests/unit/services/business/test_pagination_service.py b/tests/unit/services/business/test_pagination_service.py new file mode 100644 index 0000000..2c033be --- /dev/null +++ b/tests/unit/services/business/test_pagination_service.py @@ -0,0 +1,115 @@ +""" +Тесты для PaginationService + +Что тестировать: +- Offset-based пагинация +- Cursor-based пагинация +- Валидация параметров пагинации +- Форматирование результатов пагинации +- Обработка граничных случаев +- Обработка ошибок БД +- Интеграция с другими сервисами +- Производительность пагинации +""" +import pytest +from unittest.mock import AsyncMock, MagicMock +from services.business.pagination_service import PaginationService + + +class TestPaginationService: + """Тесты для PaginationService""" + + def test_offset_pagination_basic(self): + """Тест базовой offset пагинации""" + # TODO: Реализовать тест + pass + + def test_offset_pagination_first_page(self): + """Тест первой страницы offset пагинации""" + # TODO: Реализовать тест + pass + + def test_offset_pagination_middle_page(self): + """Тест средней страницы offset пагинации""" + # TODO: Реализовать тест + pass + + def test_offset_pagination_last_page(self): + """Тест последней страницы offset пагинации""" + # TODO: Реализовать тест + pass + + def test_offset_pagination_empty_result(self): + """Тест пустого результата offset пагинации""" + # TODO: Реализовать тест + pass + + def test_cursor_pagination_basic(self): + """Тест базовой cursor пагинации""" + # TODO: Реализовать тест + pass + + def test_cursor_pagination_first_page(self): + """Тест первой страницы cursor пагинации""" + # TODO: Реализовать тест + pass + + def test_cursor_pagination_next_page(self): + """Тест следующей страницы cursor пагинации""" + # TODO: Реализовать тест + pass + + def test_cursor_pagination_previous_page(self): + """Тест предыдущей страницы cursor пагинации""" + # TODO: Реализовать тест + pass + + def test_cursor_pagination_empty_result(self): + """Тест пустого результата cursor пагинации""" + # TODO: Реализовать тест + pass + + def test_validate_pagination_params_valid(self): + """Тест валидации корректных параметров пагинации""" + # TODO: Реализовать тест + pass + + def test_validate_pagination_params_invalid_page(self): + """Тест валидации некорректной страницы""" + # TODO: Реализовать тест + pass + + def test_validate_pagination_params_invalid_per_page(self): + """Тест валидации некорректного количества элементов на странице""" + # TODO: Реализовать тест + pass + + def test_format_pagination_info_basic(self): + """Тест базового форматирования информации о пагинации""" + # TODO: Реализовать тест + pass + + def test_format_pagination_info_with_navigation(self): + """Тест форматирования с навигацией""" + # TODO: Реализовать тест + pass + + def test_format_pagination_info_first_page(self): + """Тест форматирования первой страницы""" + # TODO: Реализовать тест + pass + + def test_format_pagination_info_last_page(self): + """Тест форматирования последней страницы""" + # TODO: Реализовать тест + pass + + def test_pagination_service_initialization(self): + """Тест инициализации PaginationService""" + # TODO: Реализовать тест + pass + + def test_pagination_database_error(self): + """Тест обработки ошибки БД при пагинации""" + # TODO: Реализовать тест + pass diff --git a/tests/unit/services/business/test_question_service.py b/tests/unit/services/business/test_question_service.py new file mode 100644 index 0000000..4e2bbb2 --- /dev/null +++ b/tests/unit/services/business/test_question_service.py @@ -0,0 +1,171 @@ +""" +Тесты для QuestionService + +Что тестировать: +- Создание вопроса (create_question) +- Получение вопросов пользователя (get_user_questions) +- Получение вопроса по ID (get_question_by_id) +- Ответ на вопрос (answer_question) +- Редактирование ответа (edit_answer) +- Удаление вопроса (delete_question) +- Валидация текста вопроса и ответа +- Отправка ответа автору +- Форматирование информации о вопросе +- Обработка ошибок БД +- Интеграция с другими сервисами +- Логирование операций +""" +import pytest +from unittest.mock import AsyncMock, MagicMock +from services.business.question_service import QuestionService +from models.question import Question, QuestionStatus +from models.user import User + + +class TestQuestionService: + """Тесты для QuestionService""" + + def test_create_question_basic(self): + """Тест базового создания вопроса""" + # TODO: Реализовать тест + pass + + def test_create_question_with_validation(self): + """Тест создания вопроса с валидацией""" + # TODO: Реализовать тест + pass + + def test_create_question_invalid_text(self): + """Тест создания вопроса с невалидным текстом""" + # TODO: Реализовать тест + pass + + def test_create_question_database_error(self): + """Тест обработки ошибки БД при создании вопроса""" + # TODO: Реализовать тест + pass + + def test_get_user_questions_existing(self): + """Тест получения вопросов пользователя - существующие""" + # TODO: Реализовать тест + pass + + def test_get_user_questions_nonexistent(self): + """Тест получения вопросов пользователя - несуществующие""" + # TODO: Реализовать тест + pass + + def test_get_user_questions_with_pagination(self): + """Тест получения вопросов с пагинацией""" + # TODO: Реализовать тест + pass + + def test_get_user_questions_with_status_filter(self): + """Тест получения вопросов с фильтром по статусу""" + # TODO: Реализовать тест + pass + + def test_get_question_by_id_existing(self): + """Тест получения вопроса по ID - существующий""" + # TODO: Реализовать тест + pass + + def test_get_question_by_id_nonexistent(self): + """Тест получения вопроса по ID - несуществующий""" + # TODO: Реализовать тест + pass + + def test_answer_question_valid(self): + """Тест ответа на вопрос - валидный ответ""" + # TODO: Реализовать тест + pass + + def test_answer_question_invalid_text(self): + """Тест ответа на вопрос - невалидный текст""" + # TODO: Реализовать тест + pass + + def test_answer_question_nonexistent_question(self): + """Тест ответа на несуществующий вопрос""" + # TODO: Реализовать тест + pass + + def test_answer_question_already_answered(self): + """Тест ответа на уже отвеченный вопрос""" + # TODO: Реализовать тест + pass + + def test_edit_answer_valid(self): + """Тест редактирования ответа - валидный ответ""" + # TODO: Реализовать тест + pass + + def test_edit_answer_invalid_text(self): + """Тест редактирования ответа - невалидный текст""" + # TODO: Реализовать тест + pass + + def test_edit_answer_nonexistent_question(self): + """Тест редактирования ответа несуществующего вопроса""" + # TODO: Реализовать тест + pass + + def test_delete_question_existing(self): + """Тест удаления существующего вопроса""" + # TODO: Реализовать тест + pass + + def test_delete_question_nonexistent(self): + """Тест удаления несуществующего вопроса""" + # TODO: Реализовать тест + pass + + def test_validate_question_text_valid(self): + """Тест валидации корректного текста вопроса""" + # TODO: Реализовать тест + pass + + def test_validate_question_text_invalid(self): + """Тест валидации некорректного текста вопроса""" + # TODO: Реализовать тест + pass + + def test_validate_answer_text_valid(self): + """Тест валидации корректного текста ответа""" + # TODO: Реализовать тест + pass + + def test_validate_answer_text_invalid(self): + """Тест валидации некорректного текста ответа""" + # TODO: Реализовать тест + pass + + def test_send_answer_to_author_success(self): + """Тест успешной отправки ответа автору""" + # TODO: Реализовать тест + pass + + def test_send_answer_to_author_failure(self): + """Тест неудачной отправки ответа автору""" + # TODO: Реализовать тест + pass + + def test_format_question_info_basic(self): + """Тест базового форматирования информации о вопросе""" + # TODO: Реализовать тест + pass + + def test_format_question_info_with_answer(self): + """Тест форматирования информации с ответом""" + # TODO: Реализовать тест + pass + + def test_format_question_info_anonymous(self): + """Тест форматирования информации об анонимном вопросе""" + # TODO: Реализовать тест + pass + + def test_question_service_initialization(self): + """Тест инициализации QuestionService""" + # TODO: Реализовать тест + pass diff --git a/tests/unit/services/business/test_user_service.py b/tests/unit/services/business/test_user_service.py new file mode 100644 index 0000000..b3d7054 --- /dev/null +++ b/tests/unit/services/business/test_user_service.py @@ -0,0 +1,103 @@ +""" +Тесты для UserService + +Что тестировать: +- Создание пользователя (create_or_update_user) +- Обновление пользователя +- Получение пользователя по ID +- Получение пользователя по profile_link +- Проверка существования пользователя +- Форматирование данных пользователя +- Валидация входных данных +- Обработка ошибок БД +- Интеграция с другими сервисами +- Логирование операций +""" +import pytest +from unittest.mock import AsyncMock, MagicMock +from services.business.user_service import UserService +from models.user import User + + +class TestUserService: + """Тесты для UserService""" + + def test_create_or_update_user_new_user(self): + """Тест создания нового пользователя""" + # TODO: Реализовать тест + pass + + def test_create_or_update_user_existing_user(self): + """Тест обновления существующего пользователя""" + # TODO: Реализовать тест + pass + + def test_create_or_update_user_with_telegram_user(self): + """Тест создания пользователя из Telegram User""" + # TODO: Реализовать тест + pass + + def test_create_or_update_user_database_error(self): + """Тест обработки ошибки БД при создании пользователя""" + # TODO: Реализовать тест + pass + + def test_get_user_by_id_existing(self): + """Тест получения пользователя по ID - существующий""" + # TODO: Реализовать тест + pass + + def test_get_user_by_id_nonexistent(self): + """Тест получения пользователя по ID - несуществующий""" + # TODO: Реализовать тест + pass + + def test_get_user_by_profile_link_existing(self): + """Тест получения пользователя по profile_link - существующий""" + # TODO: Реализовать тест + pass + + def test_get_user_by_profile_link_nonexistent(self): + """Тест получения пользователя по profile_link - несуществующий""" + # TODO: Реализовать тест + pass + + def test_user_exists_true(self): + """Тест проверки существования пользователя - существует""" + # TODO: Реализовать тест + pass + + def test_user_exists_false(self): + """Тест проверки существования пользователя - не существует""" + # TODO: Реализовать тест + pass + + def test_format_user_info(self): + """Тест форматирования информации о пользователе""" + # TODO: Реализовать тест + pass + + def test_format_user_info_with_none_values(self): + """Тест форматирования информации с None значениями""" + # TODO: Реализовать тест + pass + + def test_validate_user_data_valid(self): + """Тест валидации корректных данных пользователя""" + # TODO: Реализовать тест + pass + + def test_validate_user_data_invalid(self): + """Тест валидации некорректных данных пользователя""" + # TODO: Реализовать тест + pass + + def test_user_service_initialization(self): + """Тест инициализации UserService""" + # TODO: Реализовать тест + pass + + def test_user_service_with_none_database(self): + """Тест UserService с None базой данных""" + # TODO: Реализовать тест + pass diff --git a/tests/unit/services/infrastructure/__init__.py b/tests/unit/services/infrastructure/__init__.py new file mode 100644 index 0000000..d5155cb --- /dev/null +++ b/tests/unit/services/infrastructure/__init__.py @@ -0,0 +1,3 @@ +""" +Unit тесты для инфраструктурных сервисов +""" diff --git a/tests/unit/services/infrastructure/test_database.py b/tests/unit/services/infrastructure/test_database.py new file mode 100644 index 0000000..cad23c1 --- /dev/null +++ b/tests/unit/services/infrastructure/test_database.py @@ -0,0 +1,96 @@ +""" +Тесты для DatabaseService + +Что тестировать: +- Инициализация сервиса +- Подключение к БД +- Создание таблиц +- CRUD операции через сервис +- Connection pooling +- Обработка ошибок БД +- Транзакции +- Производительность +- Интеграция с CRUD классами +""" +import pytest +from unittest.mock import AsyncMock, MagicMock +from services.infrastructure.database import DatabaseService + + +class TestDatabaseService: + """Тесты для DatabaseService""" + + def test_database_service_initialization(self): + """Тест инициализации DatabaseService""" + # TODO: Реализовать тест + pass + + def test_database_service_with_none_db_path(self): + """Тест DatabaseService с None путем к БД""" + # TODO: Реализовать тест + pass + + def test_connect_to_database_success(self): + """Тест успешного подключения к БД""" + # TODO: Реализовать тест + pass + + def test_connect_to_database_failure(self): + """Тест неудачного подключения к БД""" + # TODO: Реализовать тест + pass + + def test_create_tables_success(self): + """Тест успешного создания таблиц""" + # TODO: Реализовать тест + pass + + def test_create_tables_failure(self): + """Тест неудачного создания таблиц""" + # TODO: Реализовать тест + pass + + def test_connection_pool_management(self): + """Тест управления пулом подключений""" + # TODO: Реализовать тест + pass + + def test_connection_pool_exhaustion(self): + """Тест исчерпания пула подключений""" + # TODO: Реализовать тест + pass + + def test_database_health_check(self): + """Тест проверки здоровья БД""" + # TODO: Реализовать тест + pass + + def test_database_health_check_failure(self): + """Тест неудачной проверки здоровья БД""" + # TODO: Реализовать тест + pass + + def test_transaction_management(self): + """Тест управления транзакциями""" + # TODO: Реализовать тест + pass + + def test_transaction_rollback(self): + """Тест отката транзакций""" + # TODO: Реализовать тест + pass + + def test_database_metrics_collection(self): + """Тест сбора метрик БД""" + # TODO: Реализовать тест + pass + + def test_database_performance_monitoring(self): + """Тест мониторинга производительности БД""" + # TODO: Реализовать тест + pass + + def test_database_service_cleanup(self): + """Тест очистки ресурсов DatabaseService""" + # TODO: Реализовать тест + pass diff --git a/tests/unit/services/infrastructure/test_metrics.py b/tests/unit/services/infrastructure/test_metrics.py new file mode 100644 index 0000000..2e30c44 --- /dev/null +++ b/tests/unit/services/infrastructure/test_metrics.py @@ -0,0 +1,97 @@ +""" +Тесты для MetricsService + +Что тестировать: +- Инициализация сервиса +- Создание метрик (Counters, Histograms, Gauges, Info) +- Инкремент счетчиков +- Обновление гистограмм +- Обновление gauges +- Обновление info метрик +- Экспорт метрик в Prometheus формате +- Обработка ошибок +- Производительность +- Интеграция с Prometheus +""" +import pytest +from unittest.mock import AsyncMock, MagicMock +from services.infrastructure.metrics import MetricsService, get_metrics_service + + +class TestMetricsService: + """Тесты для MetricsService""" + + def test_metrics_service_initialization(self): + """Тест инициализации MetricsService""" + # TODO: Реализовать тест + pass + + def test_metrics_service_singleton(self): + """Тест singleton паттерна для MetricsService""" + # TODO: Реализовать тест + pass + + def test_create_counter_metric(self): + """Тест создания Counter метрики""" + # TODO: Реализовать тест + pass + + def test_create_histogram_metric(self): + """Тест создания Histogram метрики""" + # TODO: Реализовать тест + pass + + def test_create_gauge_metric(self): + """Тест создания Gauge метрики""" + # TODO: Реализовать тест + pass + + def test_create_info_metric(self): + """Тест создания Info метрики""" + # TODO: Реализовать тест + pass + + def test_increment_counter(self): + """Тест инкремента счетчика""" + # TODO: Реализовать тест + pass + + def test_observe_histogram(self): + """Тест наблюдения гистограммы""" + # TODO: Реализовать тест + pass + + def test_set_gauge(self): + """Тест установки gauge""" + # TODO: Реализовать тест + pass + + def test_update_info(self): + """Тест обновления info метрики""" + # TODO: Реализовать тест + pass + + def test_export_metrics_prometheus_format(self): + """Тест экспорта метрик в формате Prometheus""" + # TODO: Реализовать тест + pass + + def test_export_metrics_with_labels(self): + """Тест экспорта метрик с лейблами""" + # TODO: Реализовать тест + pass + + def test_metrics_collection_performance(self): + """Тест производительности сбора метрик""" + # TODO: Реализовать тест + pass + + def test_metrics_error_handling(self): + """Тест обработки ошибок в метриках""" + # TODO: Реализовать тест + pass + + def test_metrics_service_cleanup(self): + """Тест очистки ресурсов MetricsService""" + # TODO: Реализовать тест + pass diff --git a/tests/unit/services/test_utils.py b/tests/unit/services/test_utils.py new file mode 100644 index 0000000..629690b --- /dev/null +++ b/tests/unit/services/test_utils.py @@ -0,0 +1,99 @@ +""" +Тесты для UtilsService + +Что тестировать: +- Форматирование данных +- Валидация текста +- HTML экранирование +- Отправка сообщений +- Генерация ссылок +- Обработка ошибок +- Интеграция с другими сервисами +""" +import pytest +from unittest.mock import AsyncMock, MagicMock +from services.utils import UtilsService + + +class TestUtilsService: + """Тесты для UtilsService""" + + def test_utils_service_initialization(self): + """Тест инициализации UtilsService""" + # TODO: Реализовать тест + pass + + def test_format_user_data_basic(self): + """Тест базового форматирования данных пользователя""" + # TODO: Реализовать тест + pass + + def test_format_user_data_with_none_values(self): + """Тест форматирования данных с None значениями""" + # TODO: Реализовать тест + pass + + def test_format_question_data_basic(self): + """Тест базового форматирования данных вопроса""" + # TODO: Реализовать тест + pass + + def test_format_question_data_with_answer(self): + """Тест форматирования данных вопроса с ответом""" + # TODO: Реализовать тест + pass + + def test_is_valid_question_text_valid(self): + """Тест валидации корректного текста вопроса""" + # TODO: Реализовать тест + pass + + def test_is_valid_question_text_invalid(self): + """Тест валидации некорректного текста вопроса""" + # TODO: Реализовать тест + pass + + def test_is_valid_answer_text_valid(self): + """Тест валидации корректного текста ответа""" + # TODO: Реализовать тест + pass + + def test_is_valid_answer_text_invalid(self): + """Тест валидации некорректного текста ответа""" + # TODO: Реализовать тест + pass + + def test_escape_html_basic(self): + """Тест базового HTML экранирования""" + # TODO: Реализовать тест + pass + + def test_escape_html_special_characters(self): + """Тест HTML экранирования специальных символов""" + # TODO: Реализовать тест + pass + + def test_generate_profile_link(self): + """Тест генерации ссылки профиля""" + # TODO: Реализовать тест + pass + + def test_generate_question_link(self): + """Тест генерации ссылки вопроса""" + # TODO: Реализовать тест + pass + + def test_send_answer_to_author_success(self): + """Тест успешной отправки ответа автору""" + # TODO: Реализовать тест + pass + + def test_send_answer_to_author_failure(self): + """Тест неудачной отправки ответа автору""" + # TODO: Реализовать тест + pass + + def test_utils_service_error_handling(self): + """Тест обработки ошибок в UtilsService""" + # TODO: Реализовать тест + pass diff --git a/tests/unit/services/validation/__init__.py b/tests/unit/services/validation/__init__.py new file mode 100644 index 0000000..567b04a --- /dev/null +++ b/tests/unit/services/validation/__init__.py @@ -0,0 +1,3 @@ +""" +Unit тесты для сервисов валидации +""" diff --git a/tests/unit/services/validation/test_input_validator.py b/tests/unit/services/validation/test_input_validator.py new file mode 100644 index 0000000..64c1aec --- /dev/null +++ b/tests/unit/services/validation/test_input_validator.py @@ -0,0 +1,216 @@ +""" +Тесты для InputValidator + +Что тестировать: +- Валидация Telegram ID (диапазон, тип, граничные значения) +- Валидация username (формат, длина, символы) +- Валидация текстового контента (длина, HTML санитизация, спам-фильтры) +- Валидация deep links (формат, длина, структура) +- Валидация callback data (формат, длина, безопасность) +- Валидация параметров пагинации (диапазон, тип) +- HTML санитизация (экранирование тегов) +- Спам-фильтры (повторяющиеся символы/слова) +- Обработка None и пустых значений +- Граничные случаи +- ValidationResult объекты +""" +import pytest +from services.validation import InputValidator, ValidationResult + + +class TestValidationResult: + """Тесты для класса ValidationResult""" + + def test_validation_result_creation_valid(self): + """Тест создания валидного результата""" + # TODO: Реализовать тест + pass + + def test_validation_result_creation_invalid(self): + """Тест создания невалидного результата""" + # TODO: Реализовать тест + pass + + def test_validation_result_boolean_conversion(self): + """Тест булевого преобразования""" + # TODO: Реализовать тест + pass + + def test_validation_result_string_representation(self): + """Тест строкового представления""" + # TODO: Реализовать тест + pass + + +class TestInputValidator: + """Тесты для класса InputValidator""" + + def test_validate_telegram_id_valid(self): + """Тест валидации корректного Telegram ID""" + # TODO: Реализовать тест + pass + + def test_validate_telegram_id_invalid_negative(self): + """Тест валидации отрицательного Telegram ID""" + # TODO: Реализовать тест + pass + + def test_validate_telegram_id_invalid_zero(self): + """Тест валидации нулевого Telegram ID""" + # TODO: Реализовать тест + pass + + def test_validate_telegram_id_invalid_too_large(self): + """Тест валидации слишком большого Telegram ID""" + # TODO: Реализовать тест + pass + + def test_validate_telegram_id_invalid_type(self): + """Тест валидации неправильного типа Telegram ID""" + # TODO: Реализовать тест + pass + + def test_validate_username_valid(self): + """Тест валидации корректного username""" + # TODO: Реализовать тест + pass + + def test_validate_username_invalid_too_short(self): + """Тест валидации слишком короткого username""" + # TODO: Реализовать тест + pass + + def test_validate_username_invalid_too_long(self): + """Тест валидации слишком длинного username""" + # TODO: Реализовать тест + pass + + def test_validate_username_invalid_characters(self): + """Тест валидации username с недопустимыми символами""" + # TODO: Реализовать тест + pass + + def test_validate_username_empty(self): + """Тест валидации пустого username""" + # TODO: Реализовать тест + pass + + def test_validate_text_content_valid(self): + """Тест валидации корректного текстового контента""" + # TODO: Реализовать тест + pass + + def test_validate_text_content_too_short(self): + """Тест валидации слишком короткого текста""" + # TODO: Реализовать тест + pass + + def test_validate_text_content_too_long(self): + """Тест валидации слишком длинного текста""" + # TODO: Реализовать тест + pass + + def test_validate_text_content_spam_detection(self): + """Тест обнаружения спама""" + # TODO: Реализовать тест + pass + + def test_validate_text_content_html_sanitization(self): + """Тест HTML санитизации""" + # TODO: Реализовать тест + pass + + def test_validate_question_text_valid(self): + """Тест валидации корректного текста вопроса""" + # TODO: Реализовать тест + pass + + def test_validate_question_text_invalid(self): + """Тест валидации некорректного текста вопроса""" + # TODO: Реализовать тест + pass + + def test_validate_answer_text_valid(self): + """Тест валидации корректного текста ответа""" + # TODO: Реализовать тест + pass + + def test_validate_answer_text_invalid(self): + """Тест валидации некорректного текста ответа""" + # TODO: Реализовать тест + pass + + def test_validate_deep_link_valid(self): + """Тест валидации корректного deep link""" + # TODO: Реализовать тест + pass + + def test_validate_deep_link_invalid_format(self): + """Тест валидации некорректного формата deep link""" + # TODO: Реализовать тест + pass + + def test_validate_deep_link_invalid_anonymous_id(self): + """Тест валидации некорректного анонимного ID""" + # TODO: Реализовать тест + pass + + def test_validate_callback_data_valid(self): + """Тест валидации корректного callback data""" + # TODO: Реализовать тест + pass + + def test_validate_callback_data_invalid_too_long(self): + """Тест валидации слишком длинного callback data""" + # TODO: Реализовать тест + pass + + def test_validate_callback_data_invalid_characters(self): + """Тест валидации callback data с недопустимыми символами""" + # TODO: Реализовать тест + pass + + def test_validate_pagination_params_valid(self): + """Тест валидации корректных параметров пагинации""" + # TODO: Реализовать тест + pass + + def test_validate_pagination_params_invalid_page(self): + """Тест валидации некорректной страницы""" + # TODO: Реализовать тест + pass + + def test_validate_pagination_params_invalid_per_page(self): + """Тест валидации некорректного количества элементов на странице""" + # TODO: Реализовать тест + pass + + def test_sanitize_html_basic(self): + """Тест базовой HTML санитизации""" + # TODO: Реализовать тест + pass + + def test_sanitize_html_special_characters(self): + """Тест санитизации специальных символов""" + # TODO: Реализовать тест + pass + + def test_is_spam_repeating_characters(self): + """Тест обнаружения спама с повторяющимися символами""" + # TODO: Реализовать тест + pass + + def test_is_spam_normal_text(self): + """Тест нормального текста (не спам)""" + # TODO: Реализовать тест + pass + + def test_none_input_handling(self): + """Тест обработки None входных данных""" + # TODO: Реализовать тест + pass + + def test_empty_string_handling(self): + """Тест обработки пустых строк""" + # TODO: Реализовать тест + pass diff --git a/tests/unit/services/validation/test_validation_middleware.py b/tests/unit/services/validation/test_validation_middleware.py new file mode 100644 index 0000000..0ba713b --- /dev/null +++ b/tests/unit/services/validation/test_validation_middleware.py @@ -0,0 +1,102 @@ +""" +Тесты для ValidationMiddleware + +Что тестировать: +- Инициализация middleware +- Валидация CallbackQuery +- Валидация Message +- Обработка ошибок валидации +- Пропуск невалидных данных +- Логирование ошибок +- Интеграция с InputValidator +- Обработка различных типов событий +- Возврат санитизированных данных +""" +import pytest +from unittest.mock import AsyncMock, MagicMock +from aiogram.types import CallbackQuery, Message, User, Chat +from middlewares.validation_middleware import ValidationMiddleware, ValidationError +from services.validation import InputValidator + + +class TestValidationMiddleware: + """Тесты для ValidationMiddleware""" + + def test_middleware_initialization(self): + """Тест инициализации middleware""" + # TODO: Реализовать тест + pass + + def test_validate_callback_query_valid(self): + """Тест валидации корректного CallbackQuery""" + # TODO: Реализовать тест + pass + + def test_validate_callback_query_invalid(self): + """Тест валидации некорректного CallbackQuery""" + # TODO: Реализовать тест + pass + + def test_validate_message_valid(self): + """Тест валидации корректного Message""" + # TODO: Реализовать тест + pass + + def test_validate_message_invalid(self): + """Тест валидации некорректного Message""" + # TODO: Реализовать тест + pass + + def test_validation_error_handling(self): + """Тест обработки ошибок валидации""" + # TODO: Реализовать тест + pass + + def test_validation_error_response(self): + """Тест ответа на ошибку валидации""" + # TODO: Реализовать тест + pass + + def test_unsupported_event_type(self): + """Тест обработки неподдерживаемого типа события""" + # TODO: Реализовать тест + pass + + def test_sanitized_data_injection(self): + """Тест инъекции санитизированных данных""" + # TODO: Реализовать тест + pass + + def test_validator_injection(self): + """Тест инъекции валидатора в данные""" + # TODO: Реализовать тест + pass + + def test_handler_continuation_on_valid_data(self): + """Тест продолжения обработки при валидных данных""" + # TODO: Реализовать тест + pass + + def test_handler_stop_on_invalid_data(self): + """Тест остановки обработки при невалидных данных""" + # TODO: Реализовать тест + pass + + +class TestValidationError: + """Тесты для ValidationError""" + + def test_validation_error_creation(self): + """Тест создания ValidationError""" + # TODO: Реализовать тест + pass + + def test_validation_error_with_field(self): + """Тест создания ValidationError с полем""" + # TODO: Реализовать тест + pass + + def test_validation_error_inheritance(self): + """Тест наследования от Exception""" + # TODO: Реализовать тест + pass