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

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