Implement user-specific question numbering and update database schema. Added triggers for automatic question numbering and adjustments upon deletion. Enhanced CRUD operations to manage user_question_number effectively.

This commit is contained in:
2025-09-06 18:35:12 +03:00
parent 50be010026
commit 596a2fa813
111 changed files with 16847 additions and 65 deletions

73
.dockerignore Normal file
View File

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

63
.env_example Normal file
View File

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

28
.gitignore vendored
View File

@@ -55,21 +55,6 @@ cover/
*.mo *.mo
*.pot *.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
.pybuilder/ .pybuilder/
@@ -169,3 +154,16 @@ cython_debug/
# PyPI configuration file # PyPI configuration file
.pypirc .pypirc
# Database files
*.db
*.db-shm
*.db-wal
database/*.db
database/*.db-shm
database/*.db-wal
# Logs
logs/
*.log

44
Dockerfile Normal file
View File

@@ -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"]

1730
README.md

File diff suppressed because it is too large Load Diff

80
bot.py Normal file
View File

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

8
config/__init__.py Normal file
View File

@@ -0,0 +1,8 @@
"""
Конфигурационный модуль AnonBot
"""
from .config import config, Config
from .constants import *
__all__ = ['config', 'Config']

59
config/config.py Normal file
View File

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

133
config/constants.py Normal file
View File

@@ -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"]

View File

@@ -331,21 +331,31 @@ class QuestionCRUD(BaseCRUD):
"""Создание нового вопроса""" """Создание нового вопроса"""
logger.info(f"❓ Создание вопроса от {question.from_user_id} к {question.to_user_id}") logger.info(f"❓ Создание вопроса от {question.from_user_id} к {question.to_user_id}")
async with self.get_connection() as conn: 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(""" cursor = await conn.execute("""
INSERT INTO questions INSERT INTO questions
(from_user_id, to_user_id, message_text, answer_text, is_anonymous, (from_user_id, to_user_id, message_text, answer_text, is_anonymous,
message_id, created_at, answered_at, is_read, status) message_id, created_at, answered_at, is_read, status, user_question_number)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", ( """, (
question.from_user_id, question.to_user_id, question.message_text, question.from_user_id, question.to_user_id, question.message_text,
question.answer_text, question.is_anonymous, question.message_id, question.answer_text, question.is_anonymous, question.message_id,
question.created_at.isoformat() if question.created_at else None, question.created_at.isoformat() if question.created_at else None,
question.answered_at.isoformat() if question.answered_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 question.id = cursor.lastrowid
await conn.commit() await conn.commit()
logger.info(f"✅ Вопрос создан с ID: {question.id}") logger.info(f"✅ Вопрос создан с ID: {question.id}, номер для пользователя: {question.user_question_number}")
return question return question
async def create_batch(self, questions: List[Question]) -> List[Question]: async def create_batch(self, questions: List[Question]) -> List[Question]:
@@ -356,6 +366,27 @@ class QuestionCRUD(BaseCRUD):
logger.info(f"📦 Создание {len(questions)} вопросов batch операцией") logger.info(f"📦 Создание {len(questions)} вопросов batch операцией")
async with self.get_connection() as conn: async with self.get_connection() as conn:
try: 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 вставки
batch_data = [] batch_data = []
for question in questions: for question in questions:
@@ -364,15 +395,15 @@ class QuestionCRUD(BaseCRUD):
question.answer_text, question.is_anonymous, question.message_id, question.answer_text, question.is_anonymous, question.message_id,
question.created_at.isoformat() if question.created_at else None, question.created_at.isoformat() if question.created_at else None,
question.answered_at.isoformat() if question.answered_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 вставку # Выполняем batch вставку
cursor = await conn.executemany(""" cursor = await conn.executemany("""
INSERT INTO questions INSERT INTO questions
(from_user_id, to_user_id, message_text, answer_text, is_anonymous, (from_user_id, to_user_id, message_text, answer_text, is_anonymous,
message_id, created_at, answered_at, is_read, status) message_id, created_at, answered_at, is_read, status, user_question_number)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", batch_data) """, batch_data)
# Обновляем ID для всех созданных вопросов # Обновляем ID для всех созданных вопросов
@@ -393,7 +424,11 @@ class QuestionCRUD(BaseCRUD):
"""Получение вопроса по ID""" """Получение вопроса по ID"""
async with self.get_connection() as conn: async with self.get_connection() as conn:
async with conn.execute(""" 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: """, (question_id,)) as cursor:
row = await cursor.fetchone() row = await cursor.fetchone()
if row: if row:
@@ -408,7 +443,7 @@ class QuestionCRUD(BaseCRUD):
SELECT SELECT
q.id, q.from_user_id, q.to_user_id, q.message_text, q.answer_text, 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_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 FROM questions q
WHERE q.to_user_id = ? WHERE q.to_user_id = ?
""" """
@@ -418,7 +453,7 @@ class QuestionCRUD(BaseCRUD):
query += " AND q.status = ?" query += " AND q.status = ?"
params.append(status.value) 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]) params.extend([limit, offset])
async with conn.execute(query, params) as cursor: async with conn.execute(query, params) as cursor:
@@ -455,7 +490,7 @@ class QuestionCRUD(BaseCRUD):
query += " AND q.status = ?" query += " AND q.status = ?"
params.append(status.value) 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]) params.extend([limit, offset])
async with conn.execute(query, params) as cursor: async with conn.execute(query, params) as cursor:
@@ -467,9 +502,6 @@ class QuestionCRUD(BaseCRUD):
if question is None: if question is None:
print(f"Предупреждение: вопрос не создан для строки {row[:11]}") print(f"Предупреждение: вопрос не создан для строки {row[:11]}")
continue continue
except Exception as e:
print(f"Ошибка при создании вопроса из строки {row[:11]}: {e}")
continue
author = None author = None
if row[11]: # Если есть author_id if row[11]: # Если есть author_id
@@ -489,6 +521,9 @@ class QuestionCRUD(BaseCRUD):
ban_reason=row[23] ban_reason=row[23]
) )
result.append((question, author)) result.append((question, author))
except Exception as e:
print(f"Ошибка при создании вопроса из строки {row[:11]}: {e}")
continue
return result return result
async def get_by_to_user_cursor( async def get_by_to_user_cursor(
@@ -506,7 +541,7 @@ class QuestionCRUD(BaseCRUD):
SELECT SELECT
q.id, q.from_user_id, q.to_user_id, q.message_text, q.answer_text, 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_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 FROM questions q
WHERE q.to_user_id = ? WHERE q.to_user_id = ?
AND (q.created_at < ? OR (q.created_at = ? AND q.id < ?)) AND (q.created_at < ? OR (q.created_at = ? AND q.id < ?))
@@ -519,7 +554,7 @@ class QuestionCRUD(BaseCRUD):
SELECT SELECT
q.id, q.from_user_id, q.to_user_id, q.message_text, q.answer_text, 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_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 FROM questions q
WHERE q.to_user_id = ? WHERE q.to_user_id = ?
AND (q.created_at > ? OR (q.created_at = ? AND q.id > ?)) AND (q.created_at > ? OR (q.created_at = ? AND q.id > ?))
@@ -555,7 +590,7 @@ class QuestionCRUD(BaseCRUD):
query += " AND q.status = ?" query += " AND q.status = ?"
params.append(status.value) 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]) params.extend([limit, offset])
async with conn.execute(query, params) as cursor: async with conn.execute(query, params) as cursor:
@@ -566,6 +601,46 @@ class QuestionCRUD(BaseCRUD):
"""Обновление вопроса""" """Обновление вопроса"""
logger.info(f"📝 Обновление вопроса {question.id} (статус: {question.status.value})") logger.info(f"📝 Обновление вопроса {question.id} (статус: {question.status.value})")
async with self.get_connection() as conn: async with self.get_connection() as conn:
# Если вопрос помечается как удаленный, нужно пересчитать номера
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(""" await conn.execute("""
UPDATE questions SET UPDATE questions SET
answer_text = ?, status = ?, answered_at = ?, is_read = ? answer_text = ?, status = ?, answered_at = ?, is_read = ?
@@ -575,18 +650,56 @@ class QuestionCRUD(BaseCRUD):
question.answered_at.isoformat() if question.answered_at else None, question.answered_at.isoformat() if question.answered_at else None,
question.is_read, question.id 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() await conn.commit()
logger.info(f"✅ Вопрос {question.id} обновлен") logger.info(f"✅ Вопрос {question.id} обновлен")
return question return question
async def delete(self, question_id: int) -> bool: async def delete(self, question_id: int) -> bool:
"""Удаление вопроса""" """Удаление вопроса с пересчетом user_question_number"""
async with self.get_connection() as conn: 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(""" cursor = await conn.execute("""
DELETE FROM questions WHERE id = ? DELETE FROM questions WHERE id = ?
""", (question_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() 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: async def get_unread_count(self, to_user_id: int) -> int:
"""Получение количества непрочитанных вопросов""" """Получение количества непрочитанных вопросов"""
@@ -641,7 +754,7 @@ class QuestionCRUD(BaseCRUD):
"""Преобразование строки БД в объект Question""" """Преобразование строки БД в объект Question"""
# Проверяем, что все необходимые поля присутствуют # Проверяем, что все необходимые поля присутствуют
if len(row) < 11: if len(row) < 11:
raise ValueError(f"Недостаточно данных в строке БД: {len(row)} колонок, ожидается 11") raise ValueError(f"Недостаточно данных в строке БД: {len(row)} колонок, ожидается минимум 11")
# Проверяем статус # Проверяем статус
status_value = row[10] status_value = row[10]
@@ -665,7 +778,8 @@ class QuestionCRUD(BaseCRUD):
created_at=self._parse_datetime(row[7]), created_at=self._parse_datetime(row[7]),
answered_at=self._parse_datetime(row[8]), answered_at=self._parse_datetime(row[8]),
is_read=bool(row[9]), is_read=bool(row[9]),
status=status status=status,
user_question_number=row[11] if len(row) > 11 else None
) )
return question return question

View File

@@ -31,6 +31,7 @@ CREATE TABLE questions (
answered_at DATETIME, answered_at DATETIME,
is_read BOOLEAN DEFAULT FALSE, is_read BOOLEAN DEFAULT FALSE,
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'answered', 'rejected', 'deleted')), 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, 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_status ON questions(status);
CREATE INDEX idx_questions_created_at ON questions(created_at); CREATE INDEX idx_questions_created_at ON questions(created_at);
CREATE INDEX idx_questions_is_read ON questions(is_read); 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_blocker_id ON user_blocks(blocker_id);
CREATE INDEX idx_user_blocks_blocked_id ON user_blocks(blocked_id); CREATE INDEX idx_user_blocks_blocked_id ON user_blocks(blocked_id);
@@ -106,3 +109,39 @@ FOR EACH ROW
BEGIN BEGIN
INSERT OR IGNORE INTO user_settings (user_id) VALUES (NEW.telegram_id); INSERT OR IGNORE INTO user_settings (user_id) VALUES (NEW.telegram_id);
END; 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;

387
dependencies.py Normal file
View File

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

7
handlers/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
"""
Обработчики для бота анонимных вопросов
"""
from . import start, questions, answers, admin, errors
__all__ = ['start', 'questions', 'answers', 'admin', 'errors']

660
handlers/admin.py Normal file
View File

@@ -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"🔍 <b>Управление суперпользователями</b>\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 = "👑 <b>Админ панель</b>\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(
"📢 <b>Рассылка</b>\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 = "⚙️ <b>Настройки бота</b>\n\n"
settings_text += f"🔧 <b>Текущие настройки:</b>\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"👑 <b>Администраторы:</b>\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(
"📢 <b>Рассылка</b>\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 = "👑 <b>Админ панель</b>\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,
"👥 <b>Управление суперпользователями</b>\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"👤 <b>Информация о пользователе</b>\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 += "❓ <b>Хотите снять права суперпользователя?</b>"
else:
user_text += "❓ <b>Хотите назначить суперпользователем?</b>"
# Показываем информацию и кнопки подтверждения
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"✅ <b>Права назначены!</b>\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"❌ <b>Права сняты!</b>\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 = "🚦 <b>Управление Rate Limiting</b>\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 = "📊 <b>Статистика Rate Limiting</b>\n\n"
stats_text += "🔢 <b>Общая статистика:</b>\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 += "🔍 <b>Детальная статистика:</b>\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 = "🚦 <b>Управление Rate Limiting</b>\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 = "🚦 <b>Управление Rate Limiting</b>\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)

478
handlers/answers.py Normal file
View File

@@ -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"✏️ <b>Ответ на вопрос #{question_id}</b>\n\n"
f"❓ <b>Вопрос:</b>\n{escape_html(question.message_text)}\n\n"
f"💬 <b>Введите ваш ответ:</b>",
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"✏️ <b>Редактирование ответа на вопрос #{question_id}</b>\n\n"
f"📝 <b>Вопрос:</b>\n{escape_html(question.message_text)}\n\n"
f"💬 <b>Текущий ответ:</b>\n{escape_html(question.answer_text)}\n\n"
f"✍️ <b>Введите новый ответ:</b>",
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(
"✅ <b>Ответ отправлен!</b>\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(
"✅ <b>Ответ обновлен!</b>\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"🗑️ <b>Вопрос #{question.user_question_number if question.user_question_number is not None else question_id} удален</b>\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"📋 <b>Ваши вопросы ({len(questions)}):</b>\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} <b>#{display_number}</b>\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 = "🏠 <b>Главное меню (Админ)</b>\n\nВыберите действие:"
else:
keyboard = get_main_keyboard_for_user(callback.from_user.id)
text = "🏠 <b>Главное меню</b>\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)

217
handlers/errors.py Normal file
View File

@@ -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"🐛 <b>Ошибка отладки:</b>\n\n"
error_text += f"<code>{type(error).__name__}: {str(error)}</code>"
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"🐛 <b>Ошибка отладки:</b>\n\n"
error_text += f"<code>{type(error).__name__}: {str(error)}</code>"
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"🚨 <b>Ошибка в боте</b>\n\n"
error_text += f"🐛 <b>Тип:</b> {type(error).__name__}\n"
error_text += f"📝 <b>Сообщение:</b> {str(error)}\n"
error_text += f"👤 <b>Пользователь:</b> {message.from_user.id}\n"
error_text += f"💬 <b>Чат:</b> {message.chat.id}\n"
error_text += f"📅 <b>Время:</b> {message.date.strftime('%d.%m.%Y %H:%M:%S')}\n\n"
if config.DEBUG:
error_text += f"🔍 <b>Трассировка:</b>\n<code>{traceback.format_exc()}</code>"
# Отправляем уведомление всем администраторам
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}")

1272
handlers/questions.py Normal file

File diff suppressed because it is too large Load Diff

328
handlers/start.py Normal file
View File

@@ -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"👋 <b>Добро пожаловать, {user.display_name}!</b>\n\n"
welcome_text += "🤖 Я бот для анонимных вопросов.\n\n"
welcome_text += "📝 <b>Как это работает:</b>\n"
welcome_text += "• Поделитесь своей ссылкой с друзьями\n"
welcome_text += "• Они смогут задать вам анонимные вопросы\n"
welcome_text += "• Вы получите уведомления и сможете ответить\n\n"
welcome_text += f"🔗 <b>Ваша персональная ссылка:</b>\n"
welcome_text += f"<code>{referral_link}</code>\n\n"
welcome_text += "💡 <b>Совет:</b> Скопируйте ссылку и поделитесь ею в социальных сетях!"
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 = "📖 <b>Справка по боту</b>\n\n"
help_text += "🤖 <b>Основные команды:</b>\n"
help_text += "/start - Запуск бота и получение персональной ссылки\n"
help_text += "/help - Показать эту справку\n"
help_text += "/stats - Показать статистику (только для админов)\n\n"
help_text += "📝 <b>Как задать анонимный вопрос:</b>\n"
help_text += "1. Перейдите по персональной ссылке пользователя\n"
help_text += "2. Отправьте ваш вопрос боту\n"
help_text += "3. Вопрос будет передан получателю анонимно\n\n"
help_text += "💬 <b>Как ответить на вопрос:</b>\n"
help_text += "1. Получите уведомление о новом вопросе\n"
help_text += "2. Нажмите кнопку 'Ответить'\n"
help_text += "3. Введите ваш ответ\n"
help_text += "4. Ответ будет отправлен анонимно\n\n"
help_text += "🔗 <b>Ваша персональная ссылка:</b>\n"
help_text += "Используйте кнопку 'Моя ссылка' для получения ссылки\n\n"
help_text += "❓ <b>Нужна помощь?</b>\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 = "🔗 <b>Ваша персональная ссылка:</b>\n\n"
link_text += f"<code>{referral_link}</code>\n\n"
link_text += "📝 <b>Как использовать:</b>\n"
link_text += "• Скопируйте ссылку\n"
link_text += "• Поделитесь ею в социальных сетях\n"
link_text += "• Друзья смогут задать вам анонимные вопросы\n\n"
link_text += "💡 <b>Совет:</b> Добавьте ссылку в описание профиля или поделитесь в 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,
"🏠 <b>Главное меню</b>\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,
"👑 <b>Админ панель</b>\n\nВыберите действие:",
admin_keyboard
)

17
keyboards/__init__.py Normal file
View File

@@ -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'
]

615
keyboards/inline.py Normal file
View File

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

118
keyboards/reply.py Normal file
View File

@@ -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="Отправьте текст или нажмите 'Отмена'"
)

174
loader.py Normal file
View File

@@ -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 = "🤖 <b>Бот запущен!</b>\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

11
main.py
View File

@@ -1,2 +1,9 @@
if __name__ == '__main__': """
print("Hello World") Точка входа для запуска бота анонимных вопросов
"""
import asyncio
from bot import main
if __name__ == "__main__":
asyncio.run(main())

8
middlewares/__init__.py Normal file
View File

@@ -0,0 +1,8 @@
"""
Middleware для бота
"""
from .rate_limit_middleware import RateLimitMiddleware
from .validation_middleware import ValidationMiddleware, ValidationError
__all__ = ['RateLimitMiddleware', 'ValidationMiddleware', 'ValidationError']

View File

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

View File

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

10
models/__init__.py Normal file
View File

@@ -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']

118
models/question.py Normal file
View File

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

92
models/user.py Normal file
View File

@@ -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('&', '&amp;')
.replace('<', '&lt;')
.replace('>', '&gt;')
.replace('"', '&quot;')
.replace("'", '&#x27;'))
@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()

18
models/user_block.py Normal file
View File

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

37
models/user_settings.py Normal file
View File

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

22
prometheus.yml Normal file
View File

@@ -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']

29
pytest.ini Normal file
View File

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

28
requirements.txt Normal file
View File

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

9
services/__init__.py Normal file
View File

@@ -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']

View File

@@ -0,0 +1,7 @@
"""
Модуль авторизации и разрешений
"""
from .auth_new import AuthService
__all__ = ['AuthService']

146
services/auth/auth_new.py Normal file
View File

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

View File

@@ -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']

View File

@@ -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"🔔 <b>{title}</b>\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

View File

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

View File

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

View File

@@ -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 []

View File

@@ -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'
]

View File

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

View File

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

View File

@@ -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="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
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()

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'
]

View File

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

View File

@@ -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 адаптирована на основе текущей производительности")

View File

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

402
services/utils.py Normal file
View File

@@ -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"👤 <b>{user.display_name}</b>\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"❓ <b>Вопрос #{display_number}</b>\n\n"
if question.is_anonymous:
info += "👤 <i>Анонимный вопрос</i>\n"
else:
info += f"👤 От: {question.from_user_id}\n"
info += f"📝 <b>Вопрос:</b>\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💬 <b>Ответ:</b>\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"📋 <b>Список вопросов ({len(questions)}):</b>\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 = "📊 <b>Статистика бота:</b>\n\n"
# Статистика пользователей
if 'total_users' in stats:
info += "👥 <b>Пользователи:</b>\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 += "❓ <b>Вопросы:</b>\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 += "📈 <b>Активность:</b>\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('&', '&amp;')
.replace('<', '&lt;')
.replace('>', '&gt;')
.replace('"', '&quot;')
.replace("'", '&#x27;'))
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"💬 <b>Получен ответ на ваш вопрос!</b>\n\n"
message_text += f"❓ <b>Ваш вопрос:</b>\n{question.message_text}\n\n"
message_text += f"✅ <b>Ответ:</b>\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"📅 <b>Дата ответа:</b> {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('&', '&amp;')
.replace('<', '&lt;')
.replace('>', '&gt;')
.replace('"', '&quot;')
.replace("'", '&#x27;'))
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)

View File

@@ -0,0 +1,6 @@
"""
Модуль валидации входных данных
"""
from .input_validator import InputValidator, ValidationResult
__all__ = ['InputValidator', 'ValidationResult']

View File

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

View File

@@ -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%
- **Документация**: Полная и актуальная

287
tests/README.md Normal file
View File

@@ -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
```

161
tests/SUMMARY.md Normal file
View File

@@ -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 полностью готова к использованию. Все необходимые файлы созданы, конфигурация настроена, документация написана. Теперь можно приступать к реализации реальных тестов, следуя плану и используя созданные примеры.
**Готово к разработке! 🚀**

3
tests/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""
Тесты для AnonBot
"""

View File

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

65
tests/conftest.py Normal file
View File

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

View File

@@ -0,0 +1,3 @@
"""
Интеграционные тесты для AnonBot
"""

View File

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

View File

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

8
tests/requirements.txt Normal file
View File

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

166
tests/run_tests.sh Executable file
View File

@@ -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 "✅ Тесты завершены!"

43
tests/test_config.env Normal file
View File

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

3
tests/unit/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""
Unit тесты для AnonBot
"""

View File

@@ -0,0 +1,3 @@
"""
Unit тесты для конфигурации
"""

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
"""
Unit тесты для базы данных
"""

View File

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

View File

@@ -0,0 +1,3 @@
"""
Unit тесты для обработчиков
"""

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
"""
Unit тесты для middleware
"""

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
"""
Unit тесты для моделей данных
"""

View File

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

View File

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

View File

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

View File

@@ -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 = "<script>alert('xss')</script>"
# Act
escaped = escape_html(text)
# Assert
assert escaped == "&lt;script&gt;alert(&#x27;xss&#x27;)&lt;/script&gt;"
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 &amp; &lt; &gt; &quot; &#x27;"
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 = "&lt;script&gt;"
# Act
escaped = escape_html(text)
# Assert
assert escaped == "&amp;lt;script&amp;gt;"

View File

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

View File

@@ -0,0 +1,3 @@
"""
Unit тесты для сервисов
"""

View File

@@ -0,0 +1,3 @@
"""
Unit тесты для сервисов авторизации
"""

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
"""
Unit тесты для бизнес-сервисов
"""

Some files were not shown because too many files have changed in this diff Show More