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 == "<script>"
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