mirror of
https://github.com/FerraSoft/bottohelp.git
synced 2026-08-06 21:55:03 +00:00
Подготовка к релизу
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
Сервисный слой телеграм-бота.
|
||||
Содержит бизнес-логику и правила работы приложения.
|
||||
"""
|
||||
|
||||
from .user_service import UserService
|
||||
from .game_service import GameService
|
||||
from .moderation_service import ModerationService
|
||||
from .donation_service import DonationService
|
||||
from .notification_service import NotificationService
|
||||
from .trigger_service import TriggerService
|
||||
|
||||
__all__ = ['UserService', 'GameService', 'ModerationService', 'DonationService', 'NotificationService', 'TriggerService']
|
||||
@@ -0,0 +1,461 @@
|
||||
"""
|
||||
Сервис для обработки донатов через различные платежные системы.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Any, Tuple
|
||||
from datetime import datetime, timedelta
|
||||
from dataclasses import dataclass
|
||||
|
||||
from core.payment_models import (
|
||||
PaymentIntent, PaymentEvent, PaymentStatus, TransactionType,
|
||||
DonationResponse, PaymentValidationResult
|
||||
)
|
||||
from core.payment_exceptions import (
|
||||
PaymentError, PaymentProviderError, PaymentValidationError,
|
||||
PaymentSecurityError, PaymentDuplicateError, PaymentNotFoundError
|
||||
)
|
||||
from services.payment_provider import (
|
||||
PaymentProvider, StripePaymentProvider, YooKassaPaymentProvider, SBPaymentProvider
|
||||
)
|
||||
from database.payment_repository import PaymentRepository, TransactionRepository
|
||||
from services.user_service import UserService
|
||||
from services.notification_service import NotificationService
|
||||
|
||||
|
||||
@dataclass
|
||||
class PaymentConfig:
|
||||
"""Конфигурация платежного провайдера"""
|
||||
enabled: bool
|
||||
api_key: Optional[str] = None
|
||||
webhook_secret: Optional[str] = None
|
||||
shop_id: Optional[str] = None
|
||||
secret_key: Optional[str] = None
|
||||
bank_id: Optional[str] = None
|
||||
|
||||
|
||||
class DonationService:
|
||||
"""
|
||||
Основной сервис для обработки донатов.
|
||||
|
||||
Отвечает за:
|
||||
- Создание платежей через различные провайдеры
|
||||
- Валидацию платежных данных
|
||||
- Обработку webhook уведомлений
|
||||
- Управление статусами платежей
|
||||
- Интеграцию с пользователями и уведомлениями
|
||||
"""
|
||||
|
||||
def __init__(self, payment_repo: PaymentRepository,
|
||||
transaction_repo: TransactionRepository,
|
||||
user_service: UserService,
|
||||
notification_service: NotificationService,
|
||||
payment_configs: Dict[str, PaymentConfig]):
|
||||
"""
|
||||
Инициализация сервиса донатов.
|
||||
|
||||
Args:
|
||||
payment_repo: Репозиторий платежей
|
||||
transaction_repo: Репозиторий транзакций
|
||||
user_service: Сервис пользователей
|
||||
notification_service: Сервис уведомлений
|
||||
payment_configs: Конфигурации платежных провайдеров
|
||||
"""
|
||||
self.payment_repo = payment_repo
|
||||
self.transaction_repo = transaction_repo
|
||||
self.user_service = user_service
|
||||
self.notification_service = notification_service
|
||||
self.payment_configs = payment_configs
|
||||
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
# Инициализация провайдеров
|
||||
self.providers: Dict[str, PaymentProvider] = {}
|
||||
self._initialize_providers()
|
||||
|
||||
# Инициализация rate limiter для защиты от злоупотреблений
|
||||
from core.payment_security import PaymentRateLimiter
|
||||
self.rate_limiter = PaymentRateLimiter(
|
||||
max_payments_per_hour=10, # Максимум 10 платежей в час на пользователя
|
||||
max_amount_per_hour=50000.0 # Максимум 50000 руб в час
|
||||
)
|
||||
|
||||
# Настройки валидации
|
||||
self.min_amount = 10.0
|
||||
self.max_amount = 10000.0
|
||||
self.allowed_currencies = ['RUB', 'USD', 'EUR']
|
||||
|
||||
# Настройки безопасности
|
||||
self.duplicate_check_window_minutes = 30
|
||||
|
||||
def _initialize_providers(self):
|
||||
"""Инициализация платежных провайдеров"""
|
||||
# Stripe
|
||||
stripe_config = self.payment_configs.get('stripe')
|
||||
if stripe_config and stripe_config.enabled:
|
||||
self.providers['stripe'] = StripePaymentProvider(
|
||||
api_key=stripe_config.api_key,
|
||||
webhook_secret=stripe_config.webhook_secret
|
||||
)
|
||||
|
||||
# YooKassa
|
||||
yookassa_config = self.payment_configs.get('yookassa')
|
||||
if yookassa_config and yookassa_config.enabled:
|
||||
self.providers['yookassa'] = YooKassaPaymentProvider(
|
||||
shop_id=yookassa_config.shop_id,
|
||||
secret_key=yookassa_config.secret_key
|
||||
)
|
||||
|
||||
# СБП (заглушка)
|
||||
sbp_config = self.payment_configs.get('sbp')
|
||||
if sbp_config and sbp_config.enabled:
|
||||
self.providers['sbp'] = SBPaymentProvider(
|
||||
api_key=sbp_config.api_key,
|
||||
bank_id=sbp_config.bank_id,
|
||||
webhook_secret=sbp_config.webhook_secret
|
||||
)
|
||||
|
||||
async def create_donation(self, user_id: int, amount: float,
|
||||
provider_name: str = 'stripe',
|
||||
metadata: Optional[Dict[str, Any]] = None) -> DonationResponse:
|
||||
"""
|
||||
Создание доната.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
amount: Сумма доната
|
||||
provider_name: Название провайдера
|
||||
metadata: Дополнительные данные
|
||||
|
||||
Returns:
|
||||
DonationResponse: Ответ с URL оплаты и ID платежа
|
||||
|
||||
Raises:
|
||||
PaymentValidationError: Ошибка валидации
|
||||
PaymentProviderError: Ошибка провайдера
|
||||
"""
|
||||
try:
|
||||
self.logger.info(f"Creating donation: user_id={user_id}, amount={amount}, provider={provider_name}")
|
||||
|
||||
# Валидация входных данных
|
||||
validation_result = self._validate_donation_data(user_id, amount, provider_name)
|
||||
if not validation_result.is_valid:
|
||||
raise PaymentValidationError(f"Validation failed: {', '.join(validation_result.errors)}")
|
||||
|
||||
# Проверка rate limit
|
||||
rate_limit_result = self.rate_limiter.check_rate_limit(user_id, amount)
|
||||
if not rate_limit_result['allowed']:
|
||||
raise PaymentValidationError(
|
||||
f"Rate limit exceeded. Try again after {rate_limit_result['reset_time']}"
|
||||
)
|
||||
|
||||
# Проверка на дубликаты
|
||||
if self._check_duplicate_donation(user_id, amount):
|
||||
raise PaymentDuplicateError("Duplicate donation detected")
|
||||
|
||||
# Получение провайдера
|
||||
provider = self._get_provider(provider_name)
|
||||
|
||||
# Подготовка метаданных
|
||||
if metadata is None:
|
||||
metadata = {}
|
||||
metadata.update({
|
||||
'user_id': str(user_id),
|
||||
'created_at': datetime.now().isoformat(),
|
||||
'service': 'telegram_bot_donation'
|
||||
})
|
||||
|
||||
# Создание платежа через провайдера
|
||||
payment_intent = provider.create_payment(amount, 'RUB', user_id, metadata)
|
||||
|
||||
# Сохранение платежа в БД
|
||||
payment_record = self.payment_repo.create_payment({
|
||||
'user_id': user_id,
|
||||
'amount': amount,
|
||||
'currency': 'RUB',
|
||||
'provider': provider_name,
|
||||
'external_id': payment_intent.id,
|
||||
'status': 'pending',
|
||||
'metadata': metadata,
|
||||
'created_at': datetime.now(),
|
||||
'updated_at': datetime.now()
|
||||
})
|
||||
|
||||
self.logger.info(f"Donation created: payment_id={payment_record['id']}, external_id={payment_intent.id}")
|
||||
|
||||
# Создание транзакции
|
||||
self.transaction_repo.create_transaction({
|
||||
'payment_id': payment_record['id'],
|
||||
'type': 'payment',
|
||||
'amount': amount,
|
||||
'status': 'pending',
|
||||
'created_at': datetime.now()
|
||||
})
|
||||
|
||||
# Регистрация платежа в rate limiter для учета лимитов
|
||||
self.rate_limiter.record_payment(user_id, amount)
|
||||
|
||||
return DonationResponse(
|
||||
payment_url=payment_intent.url,
|
||||
payment_id=payment_record['id'],
|
||||
provider=provider_name,
|
||||
amount=amount,
|
||||
currency='RUB'
|
||||
)
|
||||
|
||||
except (PaymentValidationError, PaymentDuplicateError, PaymentProviderError):
|
||||
raise
|
||||
except Exception as e:
|
||||
self.logger.error(f"Unexpected error creating donation: {e}", exc_info=True)
|
||||
raise PaymentError(f"Failed to create donation: {e}")
|
||||
|
||||
async def process_payment_webhook(self, provider_name: str, webhook_data: Dict[str, Any],
|
||||
signature: str) -> bool:
|
||||
"""
|
||||
Обработка webhook уведомления от платежного провайдера.
|
||||
|
||||
Args:
|
||||
provider_name: Название провайдера
|
||||
webhook_data: Данные webhook
|
||||
signature: Подпись для валидации
|
||||
|
||||
Returns:
|
||||
bool: Успешно ли обработано
|
||||
|
||||
Raises:
|
||||
PaymentSecurityError: Ошибка безопасности
|
||||
PaymentProviderError: Ошибка провайдера
|
||||
"""
|
||||
try:
|
||||
self.logger.info(f"Processing webhook: provider={provider_name}")
|
||||
|
||||
# Получение провайдера
|
||||
provider = self._get_provider(provider_name)
|
||||
|
||||
# Валидация подписи
|
||||
if not provider.validate_webhook(webhook_data, signature):
|
||||
raise PaymentSecurityError("Invalid webhook signature")
|
||||
|
||||
# Обработка события платежа
|
||||
payment_event = provider.process_webhook(webhook_data)
|
||||
|
||||
# Обработка события
|
||||
await self._handle_payment_event(payment_event)
|
||||
|
||||
return True
|
||||
|
||||
except (PaymentSecurityError, PaymentProviderError):
|
||||
raise
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error processing webhook: {e}", exc_info=True)
|
||||
raise PaymentProviderError(f"Webhook processing failed: {e}")
|
||||
|
||||
async def _handle_payment_event(self, event: PaymentEvent):
|
||||
"""
|
||||
Обработка события платежа.
|
||||
|
||||
Args:
|
||||
event: Событие платежа
|
||||
"""
|
||||
try:
|
||||
self.logger.info(f"Handling payment event: {event.type} for payment {event.payment_id}")
|
||||
|
||||
# Определение типа события
|
||||
if event.type == 'payment.succeeded':
|
||||
await self._process_successful_payment(event)
|
||||
elif event.type == 'payment.failed':
|
||||
await self._process_failed_payment(event)
|
||||
elif event.type == 'payment.cancelled':
|
||||
await self._process_cancelled_payment(event)
|
||||
elif event.type == 'payment.pending':
|
||||
await self._process_pending_payment(event)
|
||||
else:
|
||||
self.logger.warning(f"Unknown payment event type: {event.type}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error handling payment event: {e}", exc_info=True)
|
||||
# Не прерываем обработку, логируем ошибку
|
||||
|
||||
async def _process_successful_payment(self, event: PaymentEvent):
|
||||
"""Обработка успешного платежа"""
|
||||
try:
|
||||
# Обновление статуса платежа
|
||||
success = self.payment_repo.update_payment_status_by_external_id(
|
||||
event.payment_id, 'succeeded',
|
||||
processed_at=datetime.now()
|
||||
)
|
||||
|
||||
if not success:
|
||||
self.logger.error(f"Failed to update payment status for {event.payment_id}")
|
||||
return
|
||||
|
||||
# Получение данных платежа
|
||||
payment = self.payment_repo.get_payment_by_external_id(event.payment_id)
|
||||
if not payment:
|
||||
self.logger.error(f"Payment not found: {event.payment_id}")
|
||||
return
|
||||
|
||||
# Обновление баланса пользователя
|
||||
await self._update_user_balance(payment['user_id'], event.amount)
|
||||
|
||||
# Обновление транзакции
|
||||
transactions = self.transaction_repo.get_transactions_by_payment(payment['id'])
|
||||
if transactions:
|
||||
self.transaction_repo.update_transaction_status(
|
||||
transactions[0]['id'], 'succeeded'
|
||||
)
|
||||
|
||||
# Уведомление пользователя
|
||||
if self.notification_service:
|
||||
await self.notification_service.notify_payment_success(
|
||||
payment['user_id'], event.amount
|
||||
)
|
||||
|
||||
self.logger.info(f"Successfully processed payment: {event.payment_id}, amount: {event.amount}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error processing successful payment: {e}", exc_info=True)
|
||||
|
||||
async def _process_failed_payment(self, event: PaymentEvent):
|
||||
"""Обработка неудачного платежа"""
|
||||
try:
|
||||
# Обновление статуса платежа
|
||||
self.payment_repo.update_payment_status_by_external_id(
|
||||
event.payment_id, 'failed',
|
||||
processed_at=datetime.now(),
|
||||
error_message=event.metadata.get('error', 'Payment failed')
|
||||
)
|
||||
|
||||
# Получение данных платежа
|
||||
payment = self.payment_repo.get_payment_by_external_id(event.payment_id)
|
||||
if payment and self.notification_service:
|
||||
await self.notification_service.notify_payment_failed(
|
||||
payment['user_id'],
|
||||
event.metadata.get('error', 'Unknown error')
|
||||
)
|
||||
|
||||
self.logger.info(f"Processed failed payment: {event.payment_id}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error processing failed payment: {e}", exc_info=True)
|
||||
|
||||
async def _process_cancelled_payment(self, event: PaymentEvent):
|
||||
"""Обработка отмененного платежа"""
|
||||
try:
|
||||
# Обновление статуса платежа
|
||||
self.payment_repo.update_payment_status_by_external_id(
|
||||
event.payment_id, 'cancelled',
|
||||
processed_at=datetime.now()
|
||||
)
|
||||
|
||||
self.logger.info(f"Processed cancelled payment: {event.payment_id}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error processing cancelled payment: {e}", exc_info=True)
|
||||
|
||||
async def _process_pending_payment(self, event: PaymentEvent):
|
||||
"""Обработка платежа в ожидании"""
|
||||
try:
|
||||
# Обновление статуса платежа
|
||||
self.payment_repo.update_payment_status_by_external_id(
|
||||
event.payment_id, 'processing'
|
||||
)
|
||||
|
||||
self.logger.info(f"Processed pending payment: {event.payment_id}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error processing pending payment: {e}", exc_info=True)
|
||||
|
||||
async def _update_user_balance(self, user_id: int, amount: float):
|
||||
"""Обновление баланса пользователя после успешного платежа"""
|
||||
try:
|
||||
# Добавление доната в систему пользователей
|
||||
await self.user_service.add_donation(user_id, amount)
|
||||
|
||||
self.logger.info(f"Updated user balance: user_id={user_id}, amount={amount}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error updating user balance: {e}", exc_info=True)
|
||||
|
||||
def _validate_donation_data(self, user_id: int, amount: float, provider_name: str) -> PaymentValidationResult:
|
||||
"""Валидация данных доната"""
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
# Проверка user_id
|
||||
if not isinstance(user_id, int) or user_id <= 0:
|
||||
errors.append("Invalid user ID")
|
||||
|
||||
# Проверка суммы
|
||||
if not isinstance(amount, (int, float)) or amount <= 0:
|
||||
errors.append("Amount must be positive number")
|
||||
elif amount < self.min_amount:
|
||||
errors.append(f"Minimum amount is {self.min_amount}")
|
||||
elif amount > self.max_amount:
|
||||
errors.append(f"Maximum amount is {self.max_amount}")
|
||||
|
||||
# Проверка провайдера
|
||||
if provider_name not in self.providers:
|
||||
errors.append(f"Unsupported provider: {provider_name}")
|
||||
elif not self.payment_configs.get(provider_name, PaymentConfig(False)).enabled:
|
||||
errors.append(f"Provider {provider_name} is disabled")
|
||||
|
||||
return PaymentValidationResult(
|
||||
is_valid=len(errors) == 0,
|
||||
errors=errors,
|
||||
warnings=warnings
|
||||
)
|
||||
|
||||
def _check_duplicate_donation(self, user_id: int, amount: float) -> bool:
|
||||
"""Проверка на дубликат доната"""
|
||||
return self.payment_repo.check_duplicate_payment(
|
||||
"", amount, user_id, self.duplicate_check_window_minutes
|
||||
)
|
||||
|
||||
def _get_provider(self, provider_name: str) -> PaymentProvider:
|
||||
"""Получение провайдера по имени"""
|
||||
if provider_name not in self.providers:
|
||||
raise PaymentProviderError(f"Provider {provider_name} not found")
|
||||
return self.providers[provider_name]
|
||||
|
||||
def get_payment_status(self, payment_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Получение статуса платежа"""
|
||||
return self.payment_repo.get_payment_by_id(payment_id)
|
||||
|
||||
def get_user_payments(self, user_id: int, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
"""Получение платежей пользователя"""
|
||||
return self.payment_repo.get_payments_by_user(user_id, limit)
|
||||
|
||||
def get_payment_statistics(self, date_from: Optional[datetime] = None,
|
||||
date_to: Optional[datetime] = None) -> Dict[str, Any]:
|
||||
"""Получение статистики платежей"""
|
||||
return self.payment_repo.get_payment_statistics(date_from, date_to)
|
||||
|
||||
def cancel_payment(self, payment_id: int, user_id: int) -> bool:
|
||||
"""Отмена платежа"""
|
||||
try:
|
||||
# Получение данных платежа
|
||||
payment = self.payment_repo.get_payment_by_id(payment_id)
|
||||
if not payment:
|
||||
raise PaymentNotFoundError(f"Payment {payment_id} not found")
|
||||
|
||||
if payment['user_id'] != user_id:
|
||||
raise PaymentSecurityError("Unauthorized payment cancellation")
|
||||
|
||||
if payment['status'] not in ['pending', 'processing']:
|
||||
return False # Нельзя отменить завершенный платеж
|
||||
|
||||
# Получение провайдера и отмена платежа
|
||||
provider = self._get_provider(payment['provider'])
|
||||
success = provider.cancel_payment(payment['external_id'])
|
||||
|
||||
if success:
|
||||
self.payment_repo.update_payment_status(payment_id, 'cancelled')
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error cancelling payment: {e}", exc_info=True)
|
||||
return False
|
||||
@@ -0,0 +1,874 @@
|
||||
"""
|
||||
Сервис управления играми.
|
||||
Отвечает за бизнес-логику игровых механик.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Tuple, Any
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from core.exceptions import ValidationError
|
||||
from utils.validators import InputValidator
|
||||
import random
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameSession:
|
||||
"""Игровая сессия"""
|
||||
game_id: str
|
||||
game_type: str
|
||||
player_id: int
|
||||
chat_id: int
|
||||
status: str = "active"
|
||||
created_at: datetime = None
|
||||
data: Dict[str, Any] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.created_at is None:
|
||||
self.created_at = datetime.now()
|
||||
if self.data is None:
|
||||
self.data = {}
|
||||
|
||||
|
||||
class GameService:
|
||||
"""
|
||||
Сервис для управления игровыми механиками.
|
||||
|
||||
Отвечает за:
|
||||
- Управление игровыми сессиями
|
||||
- Логику игр (камень-ножницы-бумага, викторина, etc.)
|
||||
- Расчет очков за игры
|
||||
- Статистику игровых достижений
|
||||
"""
|
||||
|
||||
def __init__(self, user_repository, score_repository):
|
||||
"""
|
||||
Инициализация сервиса.
|
||||
|
||||
Args:
|
||||
user_repository: Репозиторий пользователей
|
||||
score_repository: Репозиторий очков и рейтингов
|
||||
"""
|
||||
self.user_repo = user_repository
|
||||
self.score_repo = score_repository
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
# Настройки игр
|
||||
self.game_settings = {
|
||||
'rock_paper_scissors': {
|
||||
'points_win': 5,
|
||||
'points_draw': 1
|
||||
},
|
||||
'tic_tac_toe': {
|
||||
'points_win': 15,
|
||||
'points_draw': 3
|
||||
},
|
||||
'quiz': {
|
||||
'points_correct': 5,
|
||||
'max_questions': 10
|
||||
},
|
||||
'battleship': {
|
||||
'points_win': 20,
|
||||
'board_size': 5,
|
||||
'ships': [3, 2, 1] # размеры кораблей
|
||||
},
|
||||
'game_2048': {
|
||||
'points_win': 20,
|
||||
'board_size': 4,
|
||||
'win_value': 2048
|
||||
},
|
||||
'tetris': {
|
||||
'points_per_line': 10,
|
||||
'board_width': 10,
|
||||
'board_height': 20
|
||||
},
|
||||
'snake': {
|
||||
'points_per_food': 10,
|
||||
'board_size': 10,
|
||||
'initial_length': 3
|
||||
}
|
||||
}
|
||||
|
||||
# Вопросы для викторины
|
||||
self.quiz_questions = [
|
||||
{
|
||||
'question': 'Столица Франции?',
|
||||
'answers': ['Париж', 'Лондон', 'Берлин', 'Мадрид'],
|
||||
'correct': 0
|
||||
},
|
||||
{
|
||||
'question': 'Сколько континентов на Земле?',
|
||||
'answers': ['5', '6', '7', '8'],
|
||||
'correct': 2
|
||||
},
|
||||
{
|
||||
'question': 'Какой газ составляет большую часть атмосферы?',
|
||||
'answers': ['Кислород', 'Азот', 'Углекислый газ', 'Водород'],
|
||||
'correct': 1
|
||||
},
|
||||
{
|
||||
'question': 'В каком году был запущен первый искусственный спутник?',
|
||||
'answers': ['1955', '1957', '1959', '1961'],
|
||||
'correct': 1
|
||||
}
|
||||
]
|
||||
|
||||
# Активные игровые сессии
|
||||
self.active_sessions = {}
|
||||
|
||||
def create_game_session(self, game_type: str, player_id: int, chat_id: int) -> GameSession:
|
||||
"""
|
||||
Создание новой игровой сессии.
|
||||
|
||||
Args:
|
||||
game_type: Тип игры
|
||||
player_id: ID игрока
|
||||
chat_id: ID чата
|
||||
|
||||
Returns:
|
||||
Игровая сессия
|
||||
"""
|
||||
if game_type not in self.game_settings:
|
||||
raise ValidationError(f"Неизвестный тип игры: {game_type}")
|
||||
|
||||
import random
|
||||
game_id = f"{game_type[:3]}_{random.randint(100000, 999999)}"
|
||||
|
||||
# Проверяем, что game_id не пустой
|
||||
if not game_id:
|
||||
raise ValidationError(f"Не удалось сгенерировать game_id для игры {game_type}")
|
||||
|
||||
self.logger.info(f"Создан game_id: {game_id} для игры {game_type}")
|
||||
|
||||
session = GameSession(
|
||||
game_id=game_id,
|
||||
game_type=game_type,
|
||||
player_id=player_id,
|
||||
chat_id=chat_id,
|
||||
status="active"
|
||||
)
|
||||
|
||||
# Инициализируем данные игры в зависимости от типа
|
||||
if game_type == 'rock_paper_scissors':
|
||||
session.data = {
|
||||
'player_choice': None,
|
||||
'bot_choice': None,
|
||||
'result': None
|
||||
}
|
||||
elif game_type == 'tic_tac_toe':
|
||||
session.data = {
|
||||
'board': [' '] * 9,
|
||||
'current_player': 'X',
|
||||
'moves': 0,
|
||||
'winner': None
|
||||
}
|
||||
elif game_type == 'quiz':
|
||||
session.data = {
|
||||
'current_question': 0,
|
||||
'score': 0,
|
||||
'questions': random.sample(self.quiz_questions, 3)
|
||||
}
|
||||
elif game_type == 'battleship':
|
||||
session.data = {
|
||||
'player_board': [['~'] * 5 for _ in range(5)],
|
||||
'bot_board': [['~'] * 5 for _ in range(5)],
|
||||
'bot_ships': self._place_battleship_ships(),
|
||||
'player_shots': 0,
|
||||
'player_hits': 0,
|
||||
'game_status': 'active'
|
||||
}
|
||||
elif game_type == 'game_2048':
|
||||
session.data = {
|
||||
'board': [[0] * 4 for _ in range(4)],
|
||||
'score': 0,
|
||||
'moves': 0,
|
||||
'game_status': 'active'
|
||||
}
|
||||
# Добавляем две начальные плитки
|
||||
self._add_random_2048_tile(session.data['board'])
|
||||
self._add_random_2048_tile(session.data['board'])
|
||||
elif game_type == 'tetris':
|
||||
session.data = {
|
||||
'board': [[0] * 10 for _ in range(20)],
|
||||
'current_piece': self._get_random_tetris_piece(),
|
||||
'score': 0,
|
||||
'lines_cleared': 0,
|
||||
'level': 1,
|
||||
'game_status': 'active'
|
||||
}
|
||||
elif game_type == 'snake':
|
||||
session.data = {
|
||||
'board': [[0] * 10 for _ in range(10)],
|
||||
'snake': [(5, 5)],
|
||||
'food': None,
|
||||
'direction': 'right',
|
||||
'score': 0,
|
||||
'game_status': 'active'
|
||||
}
|
||||
# Размещаем первую еду
|
||||
session.data['food'] = self._place_snake_food(session.data['board'], session.data['snake'])
|
||||
|
||||
self.active_sessions[game_id] = session
|
||||
self.logger.info(f"Сессия сохранена в active_sessions: {repr(game_id)}, активные сессии: {list(self.active_sessions.keys())}")
|
||||
return session
|
||||
|
||||
def get_game_session(self, game_id: str) -> Optional[GameSession]:
|
||||
"""Получение игровой сессии по ID"""
|
||||
self.logger.info(f"Поиск сессии для game_id: {repr(game_id)}, активные сессии: {list(self.active_sessions.keys())}")
|
||||
session = self.active_sessions.get(game_id)
|
||||
if not session:
|
||||
# self.logger.error(f"Сессия не найдена для game_id: {repr(game_id)}, активные сессии: {list(self.active_sessions.keys())}")
|
||||
return None
|
||||
if session.status != "active":
|
||||
self.logger.warning(f"Сессия {repr(game_id)} не активна (status: {session.status})")
|
||||
return None
|
||||
return session
|
||||
|
||||
def end_game_session(self, game_id: str) -> bool:
|
||||
"""Завершение игровой сессии"""
|
||||
self.logger.info(f"Завершение сессии для game_id: {repr(game_id)}, активные сессии: {list(self.active_sessions.keys())}")
|
||||
if game_id in self.active_sessions:
|
||||
self.active_sessions[game_id].status = "completed"
|
||||
del self.active_sessions[game_id]
|
||||
self.logger.info(f"Сессия {repr(game_id)} завершена и удалена")
|
||||
return True
|
||||
self.logger.warning(f"Сессия {repr(game_id)} не найдена для завершения")
|
||||
return False
|
||||
|
||||
def play_rock_paper_scissors(self, game_id: str, player_choice: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Игра в камень-ножницы-бумага.
|
||||
|
||||
Args:
|
||||
game_id: ID игровой сессии
|
||||
player_choice: Выбор игрока (rock, paper, scissors, или на русском: камень, ножницы, бумага)
|
||||
|
||||
Returns:
|
||||
Результат игры
|
||||
"""
|
||||
session = self.get_game_session(game_id)
|
||||
if not session or session.game_type != 'rock_paper_scissors':
|
||||
raise ValidationError("Игра не найдена или неверный тип игры")
|
||||
|
||||
# Mapping для поддержки русского ввода
|
||||
choice_mapping = {
|
||||
'камень': 'rock',
|
||||
'ножницы': 'scissors',
|
||||
'бумага': 'paper',
|
||||
'rock': 'rock',
|
||||
'paper': 'paper',
|
||||
'scissors': 'scissors'
|
||||
}
|
||||
|
||||
normalized_choice = choice_mapping.get(player_choice.lower(), player_choice)
|
||||
valid_choices = ['rock', 'paper', 'scissors']
|
||||
if normalized_choice not in valid_choices:
|
||||
raise ValidationError(f"Неверный выбор: {player_choice}. Допустимые: камень, ножницы, бумага или rock, paper, scissors")
|
||||
|
||||
# Определяем выбор бота
|
||||
bot_choice = random.choice(valid_choices)
|
||||
|
||||
# Определяем результат
|
||||
if normalized_choice == bot_choice:
|
||||
result = 'draw'
|
||||
points = self.game_settings['rock_paper_scissors']['points_draw']
|
||||
elif (normalized_choice == 'rock' and bot_choice == 'scissors') or \
|
||||
(normalized_choice == 'paper' and bot_choice == 'rock') or \
|
||||
(normalized_choice == 'scissors' and bot_choice == 'paper'):
|
||||
result = 'win'
|
||||
points = self.game_settings['rock_paper_scissors']['points_win']
|
||||
else:
|
||||
result = 'lose'
|
||||
points = 0
|
||||
|
||||
# Обновляем данные сессии
|
||||
session.data.update({
|
||||
'player_choice': normalized_choice,
|
||||
'bot_choice': bot_choice,
|
||||
'result': result
|
||||
})
|
||||
|
||||
# Начисляем очки игроку
|
||||
if points > 0:
|
||||
# Здесь будет вызов score_repository для начисления очков
|
||||
pass
|
||||
|
||||
return {
|
||||
'result': result,
|
||||
'player_choice': normalized_choice,
|
||||
'bot_choice': bot_choice,
|
||||
'points': points
|
||||
}
|
||||
|
||||
def make_tic_tac_toe_move(self, game_id: str, position: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Ход в крестики-нолики.
|
||||
|
||||
Args:
|
||||
game_id: ID игровой сессии
|
||||
position: Позиция хода (0-8)
|
||||
|
||||
Returns:
|
||||
Результат хода
|
||||
"""
|
||||
session = self.get_game_session(game_id)
|
||||
if not session or session.game_type != 'tic_tac_toe':
|
||||
raise ValidationError("Игра не найдена или неверный тип игры")
|
||||
|
||||
if not (0 <= position <= 8):
|
||||
raise ValidationError("Неверная позиция хода")
|
||||
|
||||
board = session.data['board']
|
||||
if board[position] != ' ':
|
||||
# Клетка уже занята, возвращаем текущее состояние
|
||||
return {
|
||||
'status': 'already_occupied',
|
||||
'board': board,
|
||||
'points': 0
|
||||
}
|
||||
|
||||
# Ход игрока
|
||||
board[position] = 'X'
|
||||
session.data['moves'] += 1
|
||||
session.data['board'] = board
|
||||
|
||||
# Проверяем победу игрока
|
||||
winner = self._check_tic_tac_toe_winner(board)
|
||||
if winner:
|
||||
session.data['winner'] = 'player'
|
||||
points = self.game_settings['tic_tac_toe']['points_win']
|
||||
return {
|
||||
'status': 'player_win',
|
||||
'board': board,
|
||||
'winner': 'player',
|
||||
'points': points
|
||||
}
|
||||
|
||||
# Проверяем ничью
|
||||
if session.data['moves'] >= 9:
|
||||
session.data['winner'] = 'draw'
|
||||
points = self.game_settings['tic_tac_toe']['points_draw']
|
||||
return {
|
||||
'status': 'draw',
|
||||
'board': board,
|
||||
'winner': 'draw',
|
||||
'points': points
|
||||
}
|
||||
|
||||
# Ход бота (простая логика - случайный ход)
|
||||
available_moves = [i for i, cell in enumerate(board) if cell == ' ']
|
||||
if available_moves:
|
||||
bot_position = random.choice(available_moves)
|
||||
board[bot_position] = 'O'
|
||||
session.data['moves'] += 1
|
||||
session.data['board'] = board
|
||||
|
||||
# Проверяем победу бота
|
||||
winner = self._check_tic_tac_toe_winner(board)
|
||||
if winner:
|
||||
session.data['winner'] = 'bot'
|
||||
return {
|
||||
'status': 'bot_win',
|
||||
'board': board,
|
||||
'winner': 'bot',
|
||||
'points': 0
|
||||
}
|
||||
|
||||
return {
|
||||
'status': 'continue',
|
||||
'board': board,
|
||||
'points': 0
|
||||
}
|
||||
|
||||
def answer_quiz_question(self, game_id: str, answer_index: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Ответ на вопрос викторины.
|
||||
|
||||
Args:
|
||||
game_id: ID игровой сессии
|
||||
answer_index: Индекс ответа
|
||||
|
||||
Returns:
|
||||
Результат ответа
|
||||
"""
|
||||
session = self.get_game_session(game_id)
|
||||
if not session or session.game_type != 'quiz':
|
||||
raise ValidationError("Игра не найдена или неверный тип игры")
|
||||
|
||||
current_q = session.data['current_question']
|
||||
questions = session.data['questions']
|
||||
|
||||
if current_q >= len(questions):
|
||||
raise ValidationError("Все вопросы отвечены")
|
||||
|
||||
question = questions[current_q]
|
||||
if not (0 <= answer_index < len(question['answers'])):
|
||||
raise ValidationError("Неверный индекс ответа")
|
||||
|
||||
# Проверяем ответ
|
||||
is_correct = (answer_index == question['correct'])
|
||||
|
||||
if is_correct:
|
||||
session.data['score'] += self.game_settings['quiz']['points_correct']
|
||||
|
||||
# Переходим к следующему вопросу
|
||||
session.data['current_question'] += 1
|
||||
current_q += 1
|
||||
|
||||
# Проверяем завершение игры
|
||||
if current_q >= len(questions):
|
||||
final_score = session.data['score']
|
||||
return {
|
||||
'status': 'game_over',
|
||||
'correct': is_correct,
|
||||
'final_score': final_score,
|
||||
'points': final_score
|
||||
}
|
||||
|
||||
return {
|
||||
'status': 'continue',
|
||||
'correct': is_correct,
|
||||
'current_score': session.data['score']
|
||||
}
|
||||
|
||||
def _check_tic_tac_toe_winner(self, board: List[str]) -> Optional[str]:
|
||||
"""Проверка победителя в крестики-нолики"""
|
||||
# Проверяем строки
|
||||
for i in range(0, 9, 3):
|
||||
if board[i] == board[i+1] == board[i+2] != ' ':
|
||||
return board[i]
|
||||
|
||||
# Проверяем столбцы
|
||||
for i in range(3):
|
||||
if board[i] == board[i+3] == board[i+6] != ' ':
|
||||
return board[i]
|
||||
|
||||
# Проверяем диагонали
|
||||
if board[0] == board[4] == board[8] != ' ':
|
||||
return board[0]
|
||||
if board[2] == board[4] == board[6] != ' ':
|
||||
return board[2]
|
||||
|
||||
return None
|
||||
|
||||
def get_game_statistics(self, user_id: int) -> Dict[str, Any]:
|
||||
"""Получение игровой статистики пользователя"""
|
||||
# Здесь будет получение статистики из базы данных
|
||||
return {
|
||||
'games_played': 0,
|
||||
'games_won': 0,
|
||||
'games_lost': 0,
|
||||
'total_points': 0,
|
||||
'favorite_game': None
|
||||
}
|
||||
|
||||
# ===== МОРСКОЙ БОЙ =====
|
||||
|
||||
def make_battleship_shot(self, game_id: str, row: int, col: int) -> Dict[str, Any]:
|
||||
"""Ход в морском бое"""
|
||||
session = self.get_game_session(game_id)
|
||||
if not session or session.game_type != 'battleship':
|
||||
raise ValidationError("Игра не найдена или неверный тип игры")
|
||||
|
||||
data = session.data
|
||||
bot_board = data['bot_board']
|
||||
bot_ships = data['bot_ships']
|
||||
|
||||
if not (0 <= row < 5 and 0 <= col < 5):
|
||||
raise ValidationError("Неверные координаты")
|
||||
|
||||
if bot_board[row][col] != '~':
|
||||
# Уже стреляли, возвращаем текущее состояние
|
||||
return {
|
||||
'status': 'already_shot',
|
||||
'board': bot_board,
|
||||
'hit': False,
|
||||
'shots': data['player_shots'],
|
||||
'points': 0
|
||||
}
|
||||
|
||||
# Определяем попадание
|
||||
hit = False
|
||||
for ship in bot_ships:
|
||||
if (row, col) in ship:
|
||||
hit = True
|
||||
bot_board[row][col] = '💥'
|
||||
data['player_hits'] += 1
|
||||
break
|
||||
|
||||
if not hit:
|
||||
bot_board[row][col] = '💧'
|
||||
|
||||
data['player_shots'] += 1
|
||||
session.data = data
|
||||
|
||||
# Проверяем победу
|
||||
total_ship_cells = sum(len(ship) for ship in bot_ships)
|
||||
if data['player_hits'] >= total_ship_cells:
|
||||
data['game_status'] = 'won'
|
||||
return {
|
||||
'status': 'win',
|
||||
'board': bot_board,
|
||||
'hit': hit,
|
||||
'shots': data['player_shots'],
|
||||
'points': self.game_settings['battleship']['points_win']
|
||||
}
|
||||
|
||||
return {
|
||||
'status': 'continue',
|
||||
'board': bot_board,
|
||||
'hit': hit,
|
||||
'shots': data['player_shots'],
|
||||
'points': 0
|
||||
}
|
||||
|
||||
def _place_battleship_ships(self) -> List[List[Tuple[int, int]]]:
|
||||
"""Размещение кораблей бота"""
|
||||
ships = []
|
||||
ship_sizes = self.game_settings['battleship']['ships'].copy()
|
||||
|
||||
for size in ship_sizes:
|
||||
placed = False
|
||||
attempts = 0
|
||||
while not placed and attempts < 100:
|
||||
direction = random.randint(0, 1) # 0 - горизонтально, 1 - вертикально
|
||||
if direction == 0:
|
||||
row = random.randint(0, 4)
|
||||
col = random.randint(0, 4 - size)
|
||||
ship_coords = [(row, col + i) for i in range(size)]
|
||||
else:
|
||||
row = random.randint(0, 4 - size)
|
||||
col = random.randint(0, 4)
|
||||
ship_coords = [(row + i, col) for i in range(size)]
|
||||
|
||||
# Проверяем пересечения
|
||||
if not any(coord in [c for ship in ships for c in ship] for coord in ship_coords):
|
||||
ships.append(ship_coords)
|
||||
placed = True
|
||||
attempts += 1
|
||||
|
||||
return ships
|
||||
|
||||
# ===== ИГРА 2048 =====
|
||||
|
||||
def make_2048_move(self, game_id: str, direction: str) -> Dict[str, Any]:
|
||||
"""Ход в игре 2048"""
|
||||
session = self.get_game_session(game_id)
|
||||
if not session or session.game_type != 'game_2048':
|
||||
raise ValidationError("Игра не найдена или неверный тип игры")
|
||||
|
||||
data = session.data
|
||||
board = data['board']
|
||||
|
||||
valid_directions = ['left', 'right', 'up', 'down']
|
||||
if direction not in valid_directions:
|
||||
raise ValidationError(f"Неверное направление: {direction}")
|
||||
|
||||
# Сохраняем доску для сравнения
|
||||
old_board = [row[:] for row in board]
|
||||
|
||||
# Делаем ход
|
||||
moved, score_gained = self._move_2048_board(board, direction)
|
||||
data['score'] += score_gained
|
||||
data['moves'] += 1
|
||||
|
||||
if moved:
|
||||
self._add_random_2048_tile(board)
|
||||
|
||||
# Проверяем победу
|
||||
if self._check_2048_win(board):
|
||||
data['game_status'] = 'won'
|
||||
return {
|
||||
'status': 'win',
|
||||
'board': board,
|
||||
'score': data['score'],
|
||||
'moved': moved,
|
||||
'points': self.game_settings['game_2048']['points_win']
|
||||
}
|
||||
|
||||
# Проверяем поражение
|
||||
if not self._can_move_2048(board):
|
||||
data['game_status'] = 'lost'
|
||||
return {
|
||||
'status': 'lose',
|
||||
'board': board,
|
||||
'score': data['score'],
|
||||
'moved': moved,
|
||||
'points': 0
|
||||
}
|
||||
|
||||
return {
|
||||
'status': 'continue',
|
||||
'board': board,
|
||||
'score': data['score'],
|
||||
'moved': moved,
|
||||
'moves': data['moves'],
|
||||
'points': 0
|
||||
}
|
||||
|
||||
def _move_2048_board(self, board: List[List[int]], direction: str) -> Tuple[bool, int]:
|
||||
"""Движение доски 2048"""
|
||||
def move_row_left(row):
|
||||
new_row = [num for num in row if num != 0]
|
||||
score = 0
|
||||
for i in range(len(new_row) - 1):
|
||||
if new_row[i] == new_row[i + 1]:
|
||||
new_row[i] *= 2
|
||||
score += new_row[i]
|
||||
new_row[i + 1] = 0
|
||||
new_row = [num for num in new_row if num != 0]
|
||||
return new_row + [0] * (4 - len(new_row)), score
|
||||
|
||||
moved = False
|
||||
total_score = 0
|
||||
|
||||
if direction == 'left':
|
||||
for i in range(4):
|
||||
old_row = board[i][:]
|
||||
board[i], score = move_row_left(board[i])
|
||||
if board[i] != old_row:
|
||||
moved = True
|
||||
total_score += score
|
||||
elif direction == 'right':
|
||||
for i in range(4):
|
||||
old_row = board[i][::-1]
|
||||
new_row, score = move_row_left(old_row)
|
||||
board[i] = new_row[::-1]
|
||||
if board[i] != old_row[::-1]:
|
||||
moved = True
|
||||
total_score += score
|
||||
elif direction == 'up':
|
||||
for j in range(4):
|
||||
col = [board[i][j] for i in range(4)]
|
||||
old_col = col[:]
|
||||
new_col, score = move_row_left(col)
|
||||
for i in range(4):
|
||||
board[i][j] = new_col[i]
|
||||
if new_col != old_col:
|
||||
moved = True
|
||||
total_score += score
|
||||
elif direction == 'down':
|
||||
for j in range(4):
|
||||
col = [board[i][j] for i in range(3, -1, -1)]
|
||||
old_col = col[:]
|
||||
new_col, score = move_row_left(col)
|
||||
for i in range(3, -1, -1):
|
||||
board[3 - i][j] = new_col[i]
|
||||
if new_col != old_col:
|
||||
moved = True
|
||||
total_score += score
|
||||
|
||||
return moved, total_score
|
||||
|
||||
def _add_random_2048_tile(self, board: List[List[int]]):
|
||||
"""Добавление случайной плитки"""
|
||||
empty_cells = [(i, j) for i in range(4) for j in range(4) if board[i][j] == 0]
|
||||
if empty_cells:
|
||||
i, j = random.choice(empty_cells)
|
||||
board[i][j] = 2 if random.random() < 0.9 else 4
|
||||
|
||||
def _check_2048_win(self, board: List[List[int]]) -> bool:
|
||||
"""Проверка победы в 2048"""
|
||||
return any(2048 in row for row in board)
|
||||
|
||||
def _can_move_2048(self, board: List[List[int]]) -> bool:
|
||||
"""Проверка возможности хода"""
|
||||
for i in range(4):
|
||||
for j in range(4):
|
||||
if board[i][j] == 0:
|
||||
return True
|
||||
if i > 0 and board[i][j] == board[i-1][j]:
|
||||
return True
|
||||
if j > 0 and board[i][j] == board[i][j-1]:
|
||||
return True
|
||||
return False
|
||||
|
||||
# ===== ТЕТРИС =====
|
||||
|
||||
def make_tetris_move(self, game_id: str, action: str) -> Dict[str, Any]:
|
||||
"""Ход в тетрисе"""
|
||||
session = self.get_game_session(game_id)
|
||||
if not session or session.game_type != 'tetris':
|
||||
raise ValidationError("Игра не найдена или неверный тип игры")
|
||||
|
||||
data = session.data
|
||||
board = data['board']
|
||||
piece = data['current_piece']
|
||||
|
||||
if action == 'left' and piece['x'] > 0:
|
||||
piece['x'] -= 1
|
||||
if not self._is_valid_tetris_position(board, piece):
|
||||
piece['x'] += 1
|
||||
elif action == 'right' and piece['x'] < 10 - len(piece['shape'][0]):
|
||||
piece['x'] += 1
|
||||
if not self._is_valid_tetris_position(board, piece):
|
||||
piece['x'] -= 1
|
||||
elif action == 'down':
|
||||
piece['y'] += 1
|
||||
if not self._is_valid_tetris_position(board, piece):
|
||||
piece['y'] -= 1
|
||||
# Фиксируем фигуру
|
||||
self._place_tetris_piece(board, piece)
|
||||
lines_cleared = self._clear_tetris_lines(board)
|
||||
data['score'] += lines_cleared * self.game_settings['tetris']['points_per_line']
|
||||
data['lines_cleared'] += lines_cleared
|
||||
|
||||
# Создаем новую фигуру
|
||||
piece = self._get_random_tetris_piece()
|
||||
if not self._is_valid_tetris_position(board, piece):
|
||||
data['game_status'] = 'game_over'
|
||||
return {
|
||||
'status': 'game_over',
|
||||
'board': board,
|
||||
'score': data['score'],
|
||||
'lines_cleared': data['lines_cleared'],
|
||||
'points': 0
|
||||
}
|
||||
data['current_piece'] = piece
|
||||
elif action == 'rotate':
|
||||
old_shape = piece['shape'][:]
|
||||
piece['shape'] = list(zip(*piece['shape'][::-1]))
|
||||
if not self._is_valid_tetris_position(board, piece):
|
||||
piece['shape'] = old_shape
|
||||
|
||||
return {
|
||||
'status': 'continue',
|
||||
'board': board,
|
||||
'piece': piece,
|
||||
'score': data['score'],
|
||||
'lines_cleared': data['lines_cleared'],
|
||||
'level': data['level'],
|
||||
'points': 0
|
||||
}
|
||||
|
||||
def _is_valid_tetris_position(self, board: List[List[int]], piece: Dict) -> bool:
|
||||
"""Проверка валидности позиции тетрис-фигуры"""
|
||||
for i, row in enumerate(piece['shape']):
|
||||
for j, cell in enumerate(row):
|
||||
if cell:
|
||||
x, y = piece['x'] + j, piece['y'] + i
|
||||
if x < 0 or x >= 10 or y >= 20 or (y >= 0 and board[y][x] != 0):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _place_tetris_piece(self, board: List[List[int]], piece: Dict):
|
||||
"""Размещение тетрис-фигуры на доске"""
|
||||
for i, row in enumerate(piece['shape']):
|
||||
for j, cell in enumerate(row):
|
||||
if cell:
|
||||
x, y = piece['x'] + j, piece['y'] + i
|
||||
if 0 <= y < 20 and 0 <= x < 10:
|
||||
board[y][x] = 1
|
||||
|
||||
def _clear_tetris_lines(self, board: List[List[int]]) -> int:
|
||||
"""Очистка заполненных линий"""
|
||||
lines_cleared = 0
|
||||
y = 19
|
||||
while y >= 0:
|
||||
if all(board[y]):
|
||||
del board[y]
|
||||
board.insert(0, [0] * 10)
|
||||
lines_cleared += 1
|
||||
else:
|
||||
y -= 1
|
||||
return lines_cleared
|
||||
|
||||
def _get_random_tetris_piece(self) -> Dict:
|
||||
"""Получение случайной тетрис-фигуры"""
|
||||
pieces = [
|
||||
{'shape': [[1, 1, 1, 1]], 'x': 3, 'y': 0}, # I
|
||||
{'shape': [[1, 1], [1, 1]], 'x': 4, 'y': 0}, # O
|
||||
{'shape': [[1, 0, 0], [1, 1, 1]], 'x': 3, 'y': 0}, # J
|
||||
{'shape': [[0, 0, 1], [1, 1, 1]], 'x': 3, 'y': 0}, # L
|
||||
{'shape': [[0, 1, 1], [1, 1, 0]], 'x': 3, 'y': 0}, # S
|
||||
{'shape': [[1, 1, 0], [0, 1, 1]], 'x': 3, 'y': 0}, # Z
|
||||
{'shape': [[0, 1, 0], [1, 1, 1]], 'x': 3, 'y': 0}, # T
|
||||
]
|
||||
return random.choice(pieces)
|
||||
|
||||
# ===== ЗМЕЙКА =====
|
||||
|
||||
def make_snake_move(self, game_id: str, direction: str) -> Dict[str, Any]:
|
||||
"""Ход в змейке"""
|
||||
session = self.get_game_session(game_id)
|
||||
if not session or session.game_type != 'snake':
|
||||
raise ValidationError("Игра не найдена или неверный тип игры")
|
||||
|
||||
data = session.data
|
||||
snake = data['snake']
|
||||
food = data['food']
|
||||
current_direction = data['direction']
|
||||
|
||||
# Проверяем допустимость направления
|
||||
opposites = {'up': 'down', 'down': 'up', 'left': 'right', 'right': 'left'}
|
||||
if direction == opposites.get(current_direction):
|
||||
raise ValidationError("Нельзя двигаться в противоположном направлении")
|
||||
|
||||
# Вычисляем новую голову
|
||||
head = snake[0]
|
||||
if direction == 'up':
|
||||
new_head = (head[0] - 1, head[1])
|
||||
elif direction == 'down':
|
||||
new_head = (head[0] + 1, head[1])
|
||||
elif direction == 'left':
|
||||
new_head = (head[0], head[1] - 1)
|
||||
elif direction == 'right':
|
||||
new_head = (head[0], head[1] + 1)
|
||||
else:
|
||||
raise ValidationError(f"Неверное направление: {direction}")
|
||||
|
||||
# Проверяем столкновения
|
||||
if (new_head[0] < 0 or new_head[0] >= 10 or
|
||||
new_head[1] < 0 or new_head[1] >= 10 or
|
||||
new_head in snake):
|
||||
data['game_status'] = 'game_over'
|
||||
return {
|
||||
'status': 'game_over',
|
||||
'snake': snake,
|
||||
'food': food,
|
||||
'score': data['score'],
|
||||
'length': len(snake),
|
||||
'points': 0
|
||||
}
|
||||
|
||||
# Добавляем новую голову
|
||||
snake.insert(0, new_head)
|
||||
data['direction'] = direction
|
||||
|
||||
# Проверяем еду
|
||||
if new_head == food:
|
||||
data['score'] += self.game_settings['snake']['points_per_food']
|
||||
food = self._place_snake_food(data['board'], snake)
|
||||
data['food'] = food
|
||||
else:
|
||||
# Удаляем хвост если не съели еду
|
||||
snake.pop()
|
||||
|
||||
return {
|
||||
'status': 'continue',
|
||||
'snake': snake,
|
||||
'food': food,
|
||||
'score': data['score'],
|
||||
'length': len(snake),
|
||||
'points': 0
|
||||
}
|
||||
|
||||
def _place_snake_food(self, board: List[List[int]], snake: List[Tuple[int, int]]) -> Tuple[int, int]:
|
||||
"""Размещение еды для змейки"""
|
||||
empty_cells = [(i, j) for i in range(10) for j in range(10) if (i, j) not in snake]
|
||||
if empty_cells:
|
||||
return random.choice(empty_cells)
|
||||
return None
|
||||
|
||||
def cleanup_old_sessions(self, max_age_minutes: int = 30):
|
||||
"""Очистка старых игровых сессий"""
|
||||
current_time = datetime.now()
|
||||
cutoff_time = current_time - timedelta(minutes=max_age_minutes)
|
||||
|
||||
to_remove = []
|
||||
for game_id, session in self.active_sessions.items():
|
||||
if session.created_at < cutoff_time:
|
||||
to_remove.append(game_id)
|
||||
|
||||
for game_id in to_remove:
|
||||
del self.active_sessions[game_id]
|
||||
@@ -0,0 +1,434 @@
|
||||
"""
|
||||
Сервис модерации пользователей.
|
||||
Отвечает за бизнес-логику модерации и администрирования.
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional, Tuple, Any
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from core.exceptions import ValidationError, PermissionError
|
||||
from utils.validators import InputValidator
|
||||
import re
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModerationAction:
|
||||
"""Действие модерации"""
|
||||
action_id: str
|
||||
user_id: int
|
||||
admin_id: int
|
||||
action_type: str
|
||||
reason: str
|
||||
duration: Optional[int] = None # в секундах для временных наказаний
|
||||
created_at: datetime = None
|
||||
expires_at: Optional[datetime] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.created_at is None:
|
||||
self.created_at = datetime.now()
|
||||
if self.duration and self.expires_at is None:
|
||||
self.expires_at = self.created_at + timedelta(seconds=self.duration)
|
||||
|
||||
|
||||
class ModerationService:
|
||||
"""
|
||||
Сервис для модерации пользователей и администрирования.
|
||||
|
||||
Отвечает за:
|
||||
- Управление предупреждениями
|
||||
- Блокировку и разблокировку пользователей
|
||||
- Модерацию контента
|
||||
- Проверку прав администраторов
|
||||
- Логирование действий модерации
|
||||
"""
|
||||
|
||||
def __init__(self, user_repository, score_repository):
|
||||
"""
|
||||
Инициализация сервиса.
|
||||
|
||||
Args:
|
||||
user_repository: Репозиторий пользователей
|
||||
score_repository: Репозиторий очков и рейтингов
|
||||
"""
|
||||
self.user_repo = user_repository
|
||||
self.score_repo = score_repository
|
||||
|
||||
# Настройки модерации
|
||||
self.moderation_settings = {
|
||||
'max_warnings': 3,
|
||||
'ban_threshold': 5,
|
||||
'mute_duration': 300, # 5 минут по умолчанию
|
||||
'profanity_filter_enabled': True,
|
||||
'auto_moderate_media': False
|
||||
}
|
||||
|
||||
# Список запрещенных слов
|
||||
self.profanity_words = [
|
||||
'блядь', 'бля', 'блять', 'хуй', 'хуйня', 'пизда', 'пиздец',
|
||||
'ебать', 'ебаный', 'сука', 'сукин', 'мудак', 'говно',
|
||||
'fuck', 'shit', 'damn', 'asshole', 'bastard', 'cunt', 'dick', 'bitch'
|
||||
]
|
||||
|
||||
# Активные модерационные действия
|
||||
self.active_actions = {}
|
||||
|
||||
async def warn_user(self, user_id: int, reason: str, admin_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Выдача предупреждения пользователю.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
reason: Причина предупреждения
|
||||
admin_id: ID администратора
|
||||
|
||||
Returns:
|
||||
Результат действия
|
||||
"""
|
||||
if not InputValidator.validate_text_content(reason, max_length=500):
|
||||
raise ValidationError("Неверная причина предупреждения")
|
||||
|
||||
# Добавляем предупреждение в базу данных
|
||||
print(f"[DEBUG] ModerationService.warn_user: calling user_repo.add_warning({user_id}, '{reason}', {admin_id})")
|
||||
success = self.user_repo.add_warning(user_id, reason, admin_id)
|
||||
|
||||
if not success:
|
||||
raise ValidationError("Не удалось выдать предупреждение")
|
||||
|
||||
# Получаем обновленное количество предупреждений
|
||||
print(f"[DEBUG] ModerationService.warn_user: calling user_repo.get_user_warnings({user_id})")
|
||||
current_warnings = self.user_repo.get_user_warnings(user_id)
|
||||
|
||||
# Проверяем необходимость автоматических действий
|
||||
action_taken = None
|
||||
if current_warnings >= self.moderation_settings['ban_threshold']:
|
||||
action_taken = 'auto_ban'
|
||||
await self.ban_user(user_id, "Автоматический бан за превышение лимита предупреждений", admin_id, permanent=True)
|
||||
elif current_warnings >= self.moderation_settings['max_warnings']:
|
||||
action_taken = 'auto_mute'
|
||||
await self.mute_user(user_id, "Автоматическая заглушка за превышение лимита предупреждений", admin_id)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'warnings_count': current_warnings,
|
||||
'action_taken': action_taken,
|
||||
'reason': reason
|
||||
}
|
||||
|
||||
async def mute_user(self, user_id: int, reason: str, admin_id: int, duration: int = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Заглушка пользователя.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
reason: Причина заглушки
|
||||
admin_id: ID администратора
|
||||
duration: Длительность в секундах
|
||||
|
||||
Returns:
|
||||
Результат действия
|
||||
"""
|
||||
if duration is None:
|
||||
duration = self.moderation_settings['mute_duration']
|
||||
|
||||
if not InputValidator.validate_number(duration, min_value=1, max_value=86400): # макс 24 часа
|
||||
raise ValidationError("Неверная длительность заглушки")
|
||||
|
||||
# Создаем действие модерации
|
||||
action_id = f"mute_{user_id}_{admin_id}_{datetime.now().strftime('%H%M%S')}"
|
||||
action = ModerationAction(
|
||||
action_id=action_id,
|
||||
user_id=user_id,
|
||||
admin_id=admin_id,
|
||||
action_type='mute',
|
||||
reason=reason,
|
||||
duration=duration
|
||||
)
|
||||
|
||||
self.active_actions[action_id] = action
|
||||
|
||||
# Логируем действие в базу данных
|
||||
await self._log_moderation_action(user_id, admin_id, 'mute', reason, duration)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'action_id': action_id,
|
||||
'duration': duration,
|
||||
'expires_at': action.expires_at
|
||||
}
|
||||
|
||||
async def ban_user(self, user_id: int, reason: str, admin_id: int, permanent: bool = False) -> Dict[str, Any]:
|
||||
"""
|
||||
Блокировка пользователя.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
reason: Причина блокировки
|
||||
admin_id: ID администратора
|
||||
permanent: Постоянная блокировка
|
||||
|
||||
Returns:
|
||||
Результат действия
|
||||
"""
|
||||
duration = None if permanent else (24 * 3600) # 24 часа по умолчанию
|
||||
|
||||
# Создаем действие модерации
|
||||
action_id = f"ban_{user_id}_{admin_id}_{datetime.now().strftime('%H%M%S')}"
|
||||
action = ModerationAction(
|
||||
action_id=action_id,
|
||||
user_id=user_id,
|
||||
admin_id=admin_id,
|
||||
action_type='ban',
|
||||
reason=reason,
|
||||
duration=duration
|
||||
)
|
||||
|
||||
self.active_actions[action_id] = action
|
||||
|
||||
# Логируем действие в базу данных
|
||||
await self._log_moderation_action(user_id, admin_id, 'ban', reason, duration)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'action_id': action_id,
|
||||
'permanent': permanent,
|
||||
'duration': duration,
|
||||
'expires_at': action.expires_at if not permanent else None
|
||||
}
|
||||
|
||||
async def unban_user(self, user_id: int, admin_id: int) -> bool:
|
||||
"""
|
||||
Разблокировка пользователя.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
admin_id: ID администратора
|
||||
|
||||
Returns:
|
||||
True если разблокировка успешна
|
||||
"""
|
||||
# Удаляем все активные действия бана для пользователя
|
||||
to_remove = []
|
||||
for action_id, action in self.active_actions.items():
|
||||
if action.user_id == user_id and action.action_type == 'ban':
|
||||
to_remove.append(action_id)
|
||||
|
||||
for action_id in to_remove:
|
||||
del self.active_actions[action_id]
|
||||
|
||||
# Логируем действие в базу данных
|
||||
await self._log_moderation_action(user_id, admin_id, 'unban', "Разблокировка пользователя")
|
||||
|
||||
return len(to_remove) > 0
|
||||
|
||||
async def unmute_user(self, user_id: int, admin_id: int) -> bool:
|
||||
"""
|
||||
Снятие заглушки с пользователя.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
admin_id: ID администратора
|
||||
|
||||
Returns:
|
||||
True если снятие заглушки успешно
|
||||
"""
|
||||
# Удаляем все активные действия mute для пользователя
|
||||
to_remove = []
|
||||
for action_id, action in self.active_actions.items():
|
||||
if action.user_id == user_id and action.action_type == 'mute':
|
||||
to_remove.append(action_id)
|
||||
|
||||
for action_id in to_remove:
|
||||
del self.active_actions[action_id]
|
||||
|
||||
# Логируем действие в базу данных
|
||||
await self._log_moderation_action(user_id, admin_id, 'unmute', "Снятие заглушки")
|
||||
|
||||
return len(to_remove) > 0
|
||||
|
||||
def check_profanity(self, text: str) -> List[str]:
|
||||
"""
|
||||
Проверка текста на нецензурную лексику.
|
||||
|
||||
Args:
|
||||
text: Проверяемый текст
|
||||
|
||||
Returns:
|
||||
Список найденных запрещенных слов
|
||||
"""
|
||||
if not self.moderation_settings['profanity_filter_enabled']:
|
||||
return []
|
||||
|
||||
found_words = []
|
||||
text_lower = text.lower()
|
||||
|
||||
for word in self.profanity_words:
|
||||
if word in text_lower:
|
||||
found_words.append(word)
|
||||
|
||||
return found_words
|
||||
|
||||
async def moderate_message(self, user_id: int, text: str, chat_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Модерация сообщения пользователя.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
text: Текст сообщения
|
||||
chat_id: ID чата
|
||||
|
||||
Returns:
|
||||
Результат модерации
|
||||
"""
|
||||
# Проверяем на нецензурную лексику
|
||||
profanity_words = self.check_profanity(text)
|
||||
|
||||
if profanity_words:
|
||||
# Автоматически выдаем предупреждение за нецензурную лексику
|
||||
admin_id = 0 # Системный администратор для авто-модерации
|
||||
await self.warn_user(user_id, f"Нецензурная лексика: {', '.join(profanity_words)}", admin_id)
|
||||
|
||||
return {
|
||||
'action': 'warning',
|
||||
'reason': 'Нецензурная лексика',
|
||||
'profanity_words': profanity_words
|
||||
}
|
||||
|
||||
return {
|
||||
'action': 'none',
|
||||
'clean': True
|
||||
}
|
||||
|
||||
def is_user_muted(self, user_id: int) -> bool:
|
||||
"""
|
||||
Проверка, заглушен ли пользователь.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
|
||||
Returns:
|
||||
True если пользователь заглушен
|
||||
"""
|
||||
current_time = datetime.now()
|
||||
|
||||
for action in self.active_actions.values():
|
||||
if (action.user_id == user_id and
|
||||
action.action_type == 'mute' and
|
||||
action.expires_at and
|
||||
current_time < action.expires_at):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def is_user_banned(self, user_id: int) -> bool:
|
||||
"""
|
||||
Проверка, забанен ли пользователь.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
|
||||
Returns:
|
||||
True если пользователь забанен
|
||||
"""
|
||||
current_time = datetime.now()
|
||||
|
||||
for action in self.active_actions.values():
|
||||
if (action.user_id == user_id and
|
||||
action.action_type == 'ban' and
|
||||
(action.expires_at is None or current_time < action.expires_at)):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def get_moderation_stats(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Получение статистики модерации.
|
||||
|
||||
Returns:
|
||||
Статистика модерации
|
||||
"""
|
||||
# Подсчитываем активные действия
|
||||
mutes = sum(1 for action in self.active_actions.values() if action.action_type == 'mute')
|
||||
bans = sum(1 for action in self.active_actions.values() if action.action_type == 'ban')
|
||||
|
||||
return {
|
||||
'active_mutes': mutes,
|
||||
'active_bans': bans,
|
||||
'total_actions': len(self.active_actions),
|
||||
'profanity_filter_enabled': self.moderation_settings['profanity_filter_enabled']
|
||||
}
|
||||
|
||||
async def get_user_moderation_history(self, user_id: int, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Получение истории модерации пользователя.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
limit: Максимальное количество записей
|
||||
|
||||
Returns:
|
||||
История модерации
|
||||
"""
|
||||
# Здесь будет получение истории из базы данных
|
||||
return []
|
||||
|
||||
async def _log_moderation_action(self, user_id: int, admin_id: int, action_type: str,
|
||||
reason: str, duration: int = None):
|
||||
"""
|
||||
Логирование действия модерации в базу данных.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
admin_id: ID администратора
|
||||
action_type: Тип действия
|
||||
reason: Причина
|
||||
duration: Длительность (для временных действий)
|
||||
"""
|
||||
# Здесь будет сохранение в базу данных через репозиторий
|
||||
pass
|
||||
|
||||
def cleanup_expired_actions(self):
|
||||
"""Очистка истекших модерационных действий"""
|
||||
current_time = datetime.now()
|
||||
|
||||
to_remove = []
|
||||
for action_id, action in self.active_actions.items():
|
||||
if (action.expires_at and current_time > action.expires_at):
|
||||
to_remove.append(action_id)
|
||||
|
||||
for action_id in to_remove:
|
||||
del self.active_actions[action_id]
|
||||
|
||||
def add_profanity_word(self, word: str) -> bool:
|
||||
"""
|
||||
Добавление слова в фильтр нецензурной лексики.
|
||||
|
||||
Args:
|
||||
word: Слово для добавления
|
||||
|
||||
Returns:
|
||||
True если слово добавлено
|
||||
"""
|
||||
if not InputValidator.validate_text_content(word, max_length=50):
|
||||
return False
|
||||
|
||||
if word.lower() not in self.profanity_words:
|
||||
self.profanity_words.append(word.lower())
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def remove_profanity_word(self, word: str) -> bool:
|
||||
"""
|
||||
Удаление слова из фильтра нецензурной лексики.
|
||||
|
||||
Args:
|
||||
word: Слово для удаления
|
||||
|
||||
Returns:
|
||||
True если слово удалено
|
||||
"""
|
||||
if word.lower() in self.profanity_words:
|
||||
self.profanity_words.remove(word.lower())
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -0,0 +1,402 @@
|
||||
"""
|
||||
Сервис для отправки уведомлений пользователям о платежах.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Optional, Dict, Any
|
||||
from telegram import Bot
|
||||
from telegram.error import TelegramError, NetworkError, TimedOut, RetryAfter
|
||||
|
||||
|
||||
class NotificationService:
|
||||
"""
|
||||
Сервис для отправки уведомлений пользователям.
|
||||
|
||||
Отвечает за:
|
||||
- Уведомления об успешных платежах
|
||||
- Уведомления о неудачных платежах
|
||||
- Уведомления администраторам о проблемах
|
||||
"""
|
||||
|
||||
def __init__(self, bot_token: str, admin_chat_ids: Optional[list] = None):
|
||||
"""
|
||||
Инициализация сервиса уведомлений.
|
||||
|
||||
Args:
|
||||
bot_token: Токен Telegram бота
|
||||
admin_chat_ids: Список ID чатов администраторов
|
||||
"""
|
||||
self.bot = Bot(token=bot_token)
|
||||
self.admin_chat_ids = admin_chat_ids or []
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
# Статистика отправки уведомлений
|
||||
self.stats = {
|
||||
'successful_sends': 0,
|
||||
'failed_sends': 0,
|
||||
'network_errors': 0,
|
||||
'timeout_errors': 0,
|
||||
'rate_limit_errors': 0,
|
||||
'other_errors': 0
|
||||
}
|
||||
|
||||
# Настройки повторных попыток
|
||||
self.max_retries = 3
|
||||
self.base_retry_delay = 1.0 # секунды
|
||||
self.max_retry_delay = 30.0 # секунды
|
||||
|
||||
# Rate limiting
|
||||
self.last_request_time = 0
|
||||
self.min_request_interval = 0.1 # минимум 100ms между запросами
|
||||
|
||||
async def notify_payment_success(self, user_id: int, amount: float, currency: str = "RUB"):
|
||||
"""
|
||||
Уведомление об успешном платеже.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
amount: Сумма платежа
|
||||
currency: Валюта
|
||||
"""
|
||||
try:
|
||||
message = (
|
||||
f"✅ <b>Спасибо за донат!</b>\n\n"
|
||||
f"💰 Сумма: <code>{amount:.2f} {currency}</code>\n"
|
||||
f"🎯 Средства успешно зачислены на ваш счет!\n\n"
|
||||
f"Вы помогаете развитию проекта и получаете бонусные очки за поддержку. "
|
||||
f"Посмотреть свою статистику можно командой /rank"
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user_id,
|
||||
text=message,
|
||||
parse_mode='HTML'
|
||||
)
|
||||
|
||||
self.logger.info(f"Sent success notification to user {user_id} for amount {amount}")
|
||||
|
||||
except TelegramError as e:
|
||||
self.logger.error(f"Failed to send success notification to user {user_id}: {e}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Unexpected error sending success notification: {e}")
|
||||
|
||||
async def notify_payment_failed(self, user_id: int, reason: str = "Неизвестная ошибка"):
|
||||
"""
|
||||
Уведомление о неудачном платеже.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
reason: Причина неудачи
|
||||
"""
|
||||
try:
|
||||
message = (
|
||||
f"❌ <b>Платеж не прошел</b>\n\n"
|
||||
f"💡 Причина: <code>{reason}</code>\n\n"
|
||||
f"Попробуйте еще раз или обратитесь в поддержку, "
|
||||
f"если проблема сохраняется."
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user_id,
|
||||
text=message,
|
||||
parse_mode='HTML'
|
||||
)
|
||||
|
||||
self.logger.info(f"Sent failure notification to user {user_id}: {reason}")
|
||||
|
||||
except TelegramError as e:
|
||||
self.logger.error(f"Failed to send failure notification to user {user_id}: {e}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Unexpected error sending failure notification: {e}")
|
||||
|
||||
async def notify_payment_cancelled(self, user_id: int):
|
||||
"""
|
||||
Уведомление об отмене платежа.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
"""
|
||||
try:
|
||||
message = (
|
||||
f"🚫 <b>Платеж отменен</b>\n\n"
|
||||
f"Если это было сделано по ошибке, попробуйте создать новый платеж."
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user_id,
|
||||
text=message,
|
||||
parse_mode='HTML'
|
||||
)
|
||||
|
||||
self.logger.info(f"Sent cancellation notification to user {user_id}")
|
||||
|
||||
except TelegramError as e:
|
||||
self.logger.error(f"Failed to send cancellation notification to user {user_id}: {e}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Unexpected error sending cancellation notification: {e}")
|
||||
|
||||
async def notify_payment_pending(self, user_id: int, payment_url: str, amount: float):
|
||||
"""
|
||||
Уведомление о том, что платеж находится в обработке.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
payment_url: URL для оплаты
|
||||
amount: Сумма платежа
|
||||
"""
|
||||
try:
|
||||
message = (
|
||||
f"⏳ <b>Платеж в обработке</b>\n\n"
|
||||
f"💰 Сумма: <code>{amount:.2f} RUB</code>\n"
|
||||
f"🔗 <a href=\"{payment_url}\">Завершить оплату</a>\n\n"
|
||||
f"Пожалуйста, завершите оплату по ссылке выше."
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user_id,
|
||||
text=message,
|
||||
parse_mode='HTML',
|
||||
disable_web_page_preview=True
|
||||
)
|
||||
|
||||
self.logger.info(f"Sent pending notification to user {user_id}")
|
||||
|
||||
except TelegramError as e:
|
||||
self.logger.error(f"Failed to send pending notification to user {user_id}: {e}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Unexpected error sending pending notification: {e}")
|
||||
|
||||
async def notify_admin_payment_issue(self, payment_id: str, error: str, user_id: Optional[int] = None):
|
||||
"""
|
||||
Уведомление администраторов о проблеме с платежом.
|
||||
|
||||
Args:
|
||||
payment_id: ID платежа
|
||||
error: Описание проблемы
|
||||
user_id: ID пользователя (опционально)
|
||||
"""
|
||||
try:
|
||||
user_info = f" (Пользователь: {user_id})" if user_id else ""
|
||||
message = (
|
||||
f"🚨 <b>Проблема с платежом</b>\n\n"
|
||||
f"🆔 ID платежа: <code>{payment_id}</code>{user_info}\n"
|
||||
f"❌ Ошибка: <code>{error}</code>\n\n"
|
||||
f"Требуется ручная проверка."
|
||||
)
|
||||
|
||||
for admin_chat_id in self.admin_chat_ids:
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
chat_id=admin_chat_id,
|
||||
text=message,
|
||||
parse_mode='HTML'
|
||||
)
|
||||
except TelegramError as e:
|
||||
self.logger.error(f"Failed to send admin notification to {admin_chat_id}: {e}")
|
||||
|
||||
self.logger.warning(f"Sent admin notification for payment {payment_id}: {error}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Unexpected error sending admin notification: {e}")
|
||||
|
||||
async def notify_admin_system_error(self, error_type: str, error_message: str, details: Optional[str] = None):
|
||||
"""
|
||||
Уведомление администраторов о системной ошибке.
|
||||
|
||||
Args:
|
||||
error_type: Тип ошибки
|
||||
error_message: Сообщение об ошибке
|
||||
details: Дополнительные детали
|
||||
"""
|
||||
try:
|
||||
message = (
|
||||
f"🔥 <b>Системная ошибка платежей</b>\n\n"
|
||||
f"⚠️ Тип: <code>{error_type}</code>\n"
|
||||
f"📝 Ошибка: <code>{error_message}</code>\n"
|
||||
)
|
||||
|
||||
if details:
|
||||
message += f"📋 Детали: <code>{details[:500]}</code>\n\n"
|
||||
|
||||
message += "Требуется немедленное вмешательство!"
|
||||
|
||||
for admin_chat_id in self.admin_chat_ids:
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
chat_id=admin_chat_id,
|
||||
text=message,
|
||||
parse_mode='HTML'
|
||||
)
|
||||
except TelegramError as e:
|
||||
self.logger.error(f"Failed to send system error notification to {admin_chat_id}: {e}")
|
||||
|
||||
self.logger.critical(f"Sent system error notification: {error_type} - {error_message}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Unexpected error sending system error notification: {e}")
|
||||
|
||||
async def notify_admin_payment_stats(self, stats: dict):
|
||||
"""
|
||||
Уведомление администраторов о статистике платежей.
|
||||
|
||||
Args:
|
||||
stats: Статистика платежей
|
||||
"""
|
||||
try:
|
||||
message = (
|
||||
f"📊 <b>Статистика платежей</b>\n\n"
|
||||
f"💰 Всего платежей: <code>{stats.get('total_payments', 0)}</code>\n"
|
||||
f"✅ Успешных: <code>{stats.get('successful_payments', 0)}</code>\n"
|
||||
f"❌ Неудачных: <code>{stats.get('failed_payments', 0)}</code>\n"
|
||||
f"⏳ Ожидающих: <code>{stats.get('pending_payments', 0)}</code>\n"
|
||||
f"📈 Общая сумма: <code>{stats.get('total_amount', 0):.2f} RUB</code>\n"
|
||||
)
|
||||
|
||||
for admin_chat_id in self.admin_chat_ids:
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
chat_id=admin_chat_id,
|
||||
text=message,
|
||||
parse_mode='HTML'
|
||||
)
|
||||
except TelegramError as e:
|
||||
self.logger.error(f"Failed to send stats notification to {admin_chat_id}: {e}")
|
||||
|
||||
self.logger.info("Sent payment statistics notification to admins")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Unexpected error sending stats notification: {e}")
|
||||
|
||||
async def send_custom_notification(self, user_id: int, message: str, parse_mode: str = 'HTML'):
|
||||
"""
|
||||
Отправка произвольного уведомления пользователю.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
message: Текст сообщения
|
||||
parse_mode: Режим парсинга ('HTML', 'Markdown', None)
|
||||
"""
|
||||
await self._send_with_retry(user_id, message, parse_mode)
|
||||
|
||||
async def _send_with_retry(self, user_id: int, message: str, parse_mode: str = 'HTML', attempt: int = 1):
|
||||
"""
|
||||
Отправка сообщения с механизмом повторных попыток и rate limiting.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
message: Текст сообщения
|
||||
parse_mode: Режим парсинга
|
||||
attempt: Номер текущей попытки
|
||||
"""
|
||||
# Rate limiting
|
||||
await self._apply_rate_limit()
|
||||
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
chat_id=user_id,
|
||||
text=message,
|
||||
parse_mode=parse_mode if parse_mode != 'HTML' else None,
|
||||
disable_web_page_preview=True
|
||||
)
|
||||
|
||||
self.stats['successful_sends'] += 1
|
||||
self.logger.info(f"Sent notification to user {user_id} (attempt {attempt})")
|
||||
|
||||
except RetryAfter as e:
|
||||
# Rate limit от Telegram API
|
||||
self.stats['rate_limit_errors'] += 1
|
||||
self.logger.warning(f"Rate limit hit for user {user_id}, retry after {e.retry_after}s")
|
||||
await asyncio.sleep(e.retry_after)
|
||||
await self._send_with_retry(user_id, message, parse_mode, attempt)
|
||||
|
||||
except TimedOut as e:
|
||||
# Таймаут сети
|
||||
self.stats['timeout_errors'] += 1
|
||||
self.logger.warning(f"Timeout sending notification to user {user_id} (attempt {attempt})")
|
||||
await self._handle_retry(user_id, message, parse_mode, attempt, "timeout")
|
||||
|
||||
except NetworkError as e:
|
||||
# Сетевая ошибка
|
||||
self.stats['network_errors'] += 1
|
||||
self.logger.warning(f"Network error sending notification to user {user_id} (attempt {attempt}): {e}")
|
||||
await self._handle_retry(user_id, message, parse_mode, attempt, "network")
|
||||
|
||||
except TelegramError as e:
|
||||
# Другие ошибки Telegram
|
||||
self.stats['other_errors'] += 1
|
||||
self.logger.error(f"Telegram error sending notification to user {user_id} (attempt {attempt}): {e}")
|
||||
self.stats['failed_sends'] += 1
|
||||
|
||||
except Exception as e:
|
||||
# Неожиданные ошибки
|
||||
self.stats['other_errors'] += 1
|
||||
self.logger.error(f"Unexpected error sending notification to user {user_id} (attempt {attempt}): {e}")
|
||||
self.stats['failed_sends'] += 1
|
||||
|
||||
async def _apply_rate_limit(self):
|
||||
"""Применение rate limiting между запросами."""
|
||||
current_time = time.time()
|
||||
time_since_last_request = current_time - self.last_request_time
|
||||
|
||||
if time_since_last_request < self.min_request_interval:
|
||||
await asyncio.sleep(self.min_request_interval - time_since_last_request)
|
||||
|
||||
self.last_request_time = time.time()
|
||||
|
||||
async def _handle_retry(self, user_id: int, message: str, parse_mode: str, attempt: int, error_type: str):
|
||||
"""
|
||||
Обработка повторных попыток отправки.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
message: Текст сообщения
|
||||
parse_mode: Режим парсинга
|
||||
attempt: Текущий номер попытки
|
||||
error_type: Тип ошибки
|
||||
"""
|
||||
if attempt >= self.max_retries:
|
||||
self.stats['failed_sends'] += 1
|
||||
self.logger.error(f"Max retries ({self.max_retries}) exceeded for user {user_id}, giving up")
|
||||
return
|
||||
|
||||
# Экспоненциальная задержка
|
||||
delay = min(self.base_retry_delay * (2 ** (attempt - 1)), self.max_retry_delay)
|
||||
self.logger.info(f"Retrying notification to user {user_id} in {delay}s (attempt {attempt + 1}/{self.max_retries})")
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
await self._send_with_retry(user_id, message, parse_mode, attempt + 1)
|
||||
|
||||
def get_notification_stats(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Получение статистики отправки уведомлений.
|
||||
|
||||
Returns:
|
||||
Dict с статистикой отправки
|
||||
"""
|
||||
total_attempts = (
|
||||
self.stats['successful_sends'] +
|
||||
self.stats['failed_sends'] +
|
||||
self.stats['network_errors'] +
|
||||
self.stats['timeout_errors'] +
|
||||
self.stats['rate_limit_errors'] +
|
||||
self.stats['other_errors']
|
||||
)
|
||||
|
||||
return {
|
||||
**self.stats,
|
||||
'total_attempts': total_attempts,
|
||||
'success_rate': (self.stats['successful_sends'] / total_attempts * 100) if total_attempts > 0 else 0.0
|
||||
}
|
||||
|
||||
def reset_stats(self):
|
||||
"""Сброс статистики отправки уведомлений."""
|
||||
self.stats = {
|
||||
'successful_sends': 0,
|
||||
'failed_sends': 0,
|
||||
'network_errors': 0,
|
||||
'timeout_errors': 0,
|
||||
'rate_limit_errors': 0,
|
||||
'other_errors': 0
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
"""
|
||||
Интерфейсы и реализации платежных провайдеров.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from core.payment_models import PaymentIntent, PaymentEvent, PaymentProvider as ProviderEnum
|
||||
from core.payment_exceptions import PaymentProviderError, PaymentSecurityError
|
||||
|
||||
|
||||
class PaymentProvider(ABC):
|
||||
"""Абстрактный интерфейс для платежных провайдеров"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
|
||||
|
||||
@abstractmethod
|
||||
def create_payment(self, amount: float, currency: str, user_id: int, metadata: Dict[str, Any]) -> PaymentIntent:
|
||||
"""
|
||||
Создание платежа
|
||||
|
||||
Args:
|
||||
amount: Сумма платежа
|
||||
currency: Валюта
|
||||
user_id: ID пользователя
|
||||
metadata: Дополнительные данные
|
||||
|
||||
Returns:
|
||||
PaymentIntent: Интент платежа
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def confirm_payment(self, payment_id: str) -> bool:
|
||||
"""
|
||||
Подтверждение платежа
|
||||
|
||||
Args:
|
||||
payment_id: ID платежа у провайдера
|
||||
|
||||
Returns:
|
||||
bool: Успешно ли подтвержден
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def cancel_payment(self, payment_id: str) -> bool:
|
||||
"""
|
||||
Отмена платежа
|
||||
|
||||
Args:
|
||||
payment_id: ID платежа у провайдера
|
||||
|
||||
Returns:
|
||||
bool: Успешно ли отменен
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_payment_status(self, payment_id: str) -> str:
|
||||
"""
|
||||
Получение статуса платежа
|
||||
|
||||
Args:
|
||||
payment_id: ID платежа у провайдера
|
||||
|
||||
Returns:
|
||||
str: Статус платежа
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def validate_webhook(self, request_data: Dict, signature: str) -> bool:
|
||||
"""
|
||||
Валидация webhook подписи
|
||||
|
||||
Args:
|
||||
request_data: Данные запроса
|
||||
signature: Подпись
|
||||
|
||||
Returns:
|
||||
bool: Валидна ли подпись
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def process_webhook(self, webhook_data: Dict) -> PaymentEvent:
|
||||
"""
|
||||
Обработка webhook события
|
||||
|
||||
Args:
|
||||
webhook_data: Данные webhook
|
||||
|
||||
Returns:
|
||||
PaymentEvent: Событие платежа
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class StripePaymentProvider(PaymentProvider):
|
||||
"""Интеграция с Stripe"""
|
||||
|
||||
def __init__(self, api_key: str, webhook_secret: str):
|
||||
super().__init__("stripe")
|
||||
try:
|
||||
import stripe
|
||||
stripe.api_key = api_key
|
||||
self.stripe = stripe
|
||||
except ImportError:
|
||||
raise PaymentProviderError("Stripe library not installed")
|
||||
self.webhook_secret = webhook_secret
|
||||
|
||||
def create_payment(self, amount: float, currency: str, user_id: int, metadata: Dict[str, Any]) -> PaymentIntent:
|
||||
"""Создание платежа в Stripe"""
|
||||
try:
|
||||
self.logger.info(f"Creating Stripe payment: amount={amount}, currency={currency}, user_id={user_id}")
|
||||
|
||||
# Создаем PaymentIntent
|
||||
intent = self.stripe.PaymentIntent.create(
|
||||
amount=int(amount * 100), # Stripe работает с копейками
|
||||
currency=currency.lower(),
|
||||
metadata={
|
||||
'user_id': str(user_id),
|
||||
'created_at': datetime.now().isoformat(),
|
||||
**metadata
|
||||
},
|
||||
payment_method_types=['card'],
|
||||
confirmation_method='automatic',
|
||||
capture_method='automatic'
|
||||
)
|
||||
|
||||
self.logger.info(f"Stripe payment created: {intent.id}")
|
||||
return PaymentIntent(
|
||||
id=intent.id,
|
||||
url=f"https://checkout.stripe.com/pay/{intent.client_secret}",
|
||||
client_secret=intent.client_secret,
|
||||
status=intent.status
|
||||
)
|
||||
|
||||
except self.stripe.error.StripeError as e:
|
||||
self.logger.error(f"Stripe API error: {e}")
|
||||
raise PaymentProviderError(f"Stripe error: {e}")
|
||||
|
||||
def confirm_payment(self, payment_id: str) -> bool:
|
||||
"""Подтверждение платежа в Stripe"""
|
||||
try:
|
||||
intent = self.stripe.PaymentIntent.retrieve(payment_id)
|
||||
if intent.status == 'requires_confirmation':
|
||||
intent.confirm()
|
||||
return intent.status in ['succeeded', 'processing']
|
||||
except self.stripe.error.StripeError as e:
|
||||
raise PaymentProviderError(f"Stripe confirm error: {e}")
|
||||
|
||||
def cancel_payment(self, payment_id: str) -> bool:
|
||||
"""Отмена платежа в Stripe"""
|
||||
try:
|
||||
intent = self.stripe.PaymentIntent.retrieve(payment_id)
|
||||
intent.cancel()
|
||||
return True
|
||||
except self.stripe.error.StripeError as e:
|
||||
raise PaymentProviderError(f"Stripe cancel error: {e}")
|
||||
|
||||
def get_payment_status(self, payment_id: str) -> str:
|
||||
"""Получение статуса платежа из Stripe"""
|
||||
try:
|
||||
intent = self.stripe.PaymentIntent.retrieve(payment_id)
|
||||
return intent.status
|
||||
except self.stripe.error.StripeError as e:
|
||||
raise PaymentProviderError(f"Stripe status error: {e}")
|
||||
|
||||
def validate_webhook(self, request_data: Dict, signature: str) -> bool:
|
||||
"""Валидация Stripe webhook подписи"""
|
||||
try:
|
||||
self.stripe.Webhook.construct_event(
|
||||
request_data,
|
||||
signature,
|
||||
self.webhook_secret
|
||||
)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def process_webhook(self, webhook_data: Dict) -> PaymentEvent:
|
||||
"""Обработка Stripe webhook"""
|
||||
event = webhook_data
|
||||
event_type = event.get('type', '')
|
||||
data = event.get('data', {}).get('object', {})
|
||||
|
||||
# Извлекаем payment_intent
|
||||
payment_intent = data
|
||||
if 'payment_intent' in data:
|
||||
payment_intent = data['payment_intent']
|
||||
|
||||
amount = payment_intent.get('amount', 0) / 100 # Конвертируем из копеек
|
||||
metadata = payment_intent.get('metadata', {})
|
||||
|
||||
return PaymentEvent(
|
||||
type=f"payment.{event_type.split('.')[1]}",
|
||||
payment_id=payment_intent.get('id'),
|
||||
amount=amount,
|
||||
currency=payment_intent.get('currency', 'usd').upper(),
|
||||
user_id=int(metadata.get('user_id')) if metadata.get('user_id') else None,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
|
||||
class YooKassaPaymentProvider(PaymentProvider):
|
||||
"""Интеграция с YooKassa"""
|
||||
|
||||
def __init__(self, shop_id: str, secret_key: str):
|
||||
super().__init__("yookassa")
|
||||
try:
|
||||
from yookassa import Client, Payment
|
||||
self.client = Client(shop_id=shop_id, secret_key=secret_key)
|
||||
self.Payment = Payment
|
||||
except ImportError:
|
||||
raise PaymentProviderError("YooKassa library not installed")
|
||||
self.secret_key = secret_key
|
||||
|
||||
def create_payment(self, amount: float, currency: str, user_id: int, metadata: Dict[str, Any]) -> PaymentIntent:
|
||||
"""Создание платежа в YooKassa"""
|
||||
try:
|
||||
self.logger.info(f"Creating YooKassa payment: amount={amount}, currency={currency}, user_id={user_id}")
|
||||
|
||||
payment = self.Payment.create({
|
||||
"amount": {
|
||||
"value": f"{amount:.2f}",
|
||||
"currency": currency.upper()
|
||||
},
|
||||
"metadata": {
|
||||
'user_id': str(user_id),
|
||||
'created_at': datetime.now().isoformat(),
|
||||
**metadata
|
||||
},
|
||||
"confirmation": {
|
||||
"type": "redirect",
|
||||
"return_url": "https://t.me/bot" # URL возврата в бот
|
||||
},
|
||||
"capture": True,
|
||||
"description": f"Донат пользователю {user_id}"
|
||||
}, self.client.id)
|
||||
|
||||
self.logger.info(f"YooKassa payment created: {payment.id}")
|
||||
return PaymentIntent(
|
||||
id=payment.id,
|
||||
url=payment.confirmation.confirmation_url,
|
||||
status=payment.status
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"YooKassa API error: {e}")
|
||||
raise PaymentProviderError(f"YooKassa error: {e}")
|
||||
|
||||
def confirm_payment(self, payment_id: str) -> bool:
|
||||
"""Подтверждение платежа в YooKassa"""
|
||||
try:
|
||||
payment = self.Payment.find_one(payment_id, self.client.id)
|
||||
return payment.status == 'succeeded'
|
||||
except Exception as e:
|
||||
raise PaymentProviderError(f"YooKassa confirm error: {e}")
|
||||
|
||||
def cancel_payment(self, payment_id: str) -> bool:
|
||||
"""Отмена платежа в YooKassa"""
|
||||
try:
|
||||
payment = self.Payment.find_one(payment_id, self.client.id)
|
||||
if payment.status == 'pending':
|
||||
payment.cancel()
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
raise PaymentProviderError(f"YooKassa cancel error: {e}")
|
||||
|
||||
def get_payment_status(self, payment_id: str) -> str:
|
||||
"""Получение статуса платежа из YooKassa"""
|
||||
try:
|
||||
payment = self.Payment.find_one(payment_id, self.client.id)
|
||||
return payment.status
|
||||
except Exception as e:
|
||||
raise PaymentProviderError(f"YooKassa status error: {e}")
|
||||
|
||||
def validate_webhook(self, request_data: Dict, signature: str) -> bool:
|
||||
"""Валидация YooKassa webhook подписи"""
|
||||
try:
|
||||
# Реализация проверки подписи YooKassa
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
body = request_data
|
||||
if isinstance(request_data, dict):
|
||||
import json
|
||||
body = json.dumps(request_data, separators=(',', ':'))
|
||||
|
||||
expected_signature = hmac.new(
|
||||
self.secret_key.encode('utf-8'),
|
||||
body.encode('utf-8'),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
return hmac.compare_digest(signature, expected_signature)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"YooKassa webhook validation error: {e}")
|
||||
return False
|
||||
|
||||
def process_webhook(self, webhook_data: Dict) -> PaymentEvent:
|
||||
"""Обработка YooKassa webhook"""
|
||||
event = webhook_data
|
||||
payment = event.get('object', {})
|
||||
|
||||
amount_value = float(payment.get('amount', {}).get('value', 0))
|
||||
currency = payment.get('amount', {}).get('currency', 'RUB')
|
||||
metadata = payment.get('metadata', {})
|
||||
|
||||
return PaymentEvent(
|
||||
type=f"payment.{payment.get('status', 'unknown')}",
|
||||
payment_id=payment.get('id'),
|
||||
amount=amount_value,
|
||||
currency=currency,
|
||||
user_id=int(metadata.get('user_id')) if metadata.get('user_id') else None,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
|
||||
class SBPaymentProvider(PaymentProvider):
|
||||
"""Интеграция с Системой Быстрых Платежей (СБП)"""
|
||||
|
||||
def __init__(self, api_key: str, bank_id: str, webhook_secret: str, base_url: str = "https://api.sbp.ru"):
|
||||
super().__init__("sbp")
|
||||
self.api_key = api_key
|
||||
self.bank_id = bank_id
|
||||
self.webhook_secret = webhook_secret
|
||||
self.base_url = base_url
|
||||
self.session = None
|
||||
|
||||
# Импорт aiohttp для асинхронных HTTP запросов
|
||||
try:
|
||||
import aiohttp
|
||||
self.aiohttp = aiohttp
|
||||
except ImportError:
|
||||
raise PaymentProviderError("aiohttp library not installed (required for SBP)")
|
||||
|
||||
async def _ensure_session(self):
|
||||
"""Инициализация HTTP сессии"""
|
||||
if self.session is None:
|
||||
self.session = self.aiohttp.ClientSession()
|
||||
|
||||
async def close(self):
|
||||
"""Закрытие HTTP сессии"""
|
||||
if self.session:
|
||||
await self.session.close()
|
||||
self.session = None
|
||||
|
||||
def create_payment(self, amount: float, currency: str, user_id: int, metadata: Dict[str, Any]) -> PaymentIntent:
|
||||
"""Создание платежа через СБП"""
|
||||
# Для СБП создание платежа обычно происходит асинхронно
|
||||
# Возвращаем intent с URL для перенаправления на платежную форму
|
||||
try:
|
||||
payment_id = f"sbp_{user_id}_{int(datetime.now().timestamp())}"
|
||||
|
||||
# СБП обычно использует QR-код или перенаправление на банковское приложение
|
||||
payment_url = f"{self.base_url}/pay/{payment_id}"
|
||||
|
||||
return PaymentIntent(
|
||||
id=payment_id,
|
||||
url=payment_url,
|
||||
status="pending"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"SBP payment creation error: {e}")
|
||||
raise PaymentProviderError(f"SBP payment creation failed: {e}")
|
||||
|
||||
async def create_payment_async(self, amount: float, currency: str, user_id: int, metadata: Dict[str, Any]) -> PaymentIntent:
|
||||
"""Асинхронное создание платежа через СБП"""
|
||||
await self._ensure_session()
|
||||
|
||||
try:
|
||||
payment_data = {
|
||||
"amount": amount,
|
||||
"currency": currency,
|
||||
"user_id": user_id,
|
||||
"bank_id": self.bank_id,
|
||||
"metadata": metadata
|
||||
}
|
||||
|
||||
async with self.session.post(
|
||||
f"{self.base_url}/payments",
|
||||
json=payment_data,
|
||||
headers={"Authorization": f"Bearer {self.api_key}"}
|
||||
) as response:
|
||||
if response.status == 201:
|
||||
data = await response.json()
|
||||
return PaymentIntent(
|
||||
id=data["payment_id"],
|
||||
url=data["payment_url"],
|
||||
status="pending"
|
||||
)
|
||||
else:
|
||||
error_data = await response.text()
|
||||
raise PaymentProviderError(f"SBP API error: {response.status} - {error_data}")
|
||||
|
||||
except self.aiohttp.ClientError as e:
|
||||
raise PaymentProviderError(f"SBP network error: {e}")
|
||||
|
||||
def confirm_payment(self, payment_id: str) -> bool:
|
||||
"""Подтверждение платежа через СБП"""
|
||||
# Для СБП подтверждение обычно происходит через webhook
|
||||
# Этот метод можно использовать для ручного подтверждения
|
||||
self.logger.warning(f"SBP manual confirmation not supported for {payment_id}")
|
||||
return False
|
||||
|
||||
async def confirm_payment_async(self, payment_id: str) -> bool:
|
||||
"""Асинхронное подтверждение платежа через СБП"""
|
||||
await self._ensure_session()
|
||||
|
||||
try:
|
||||
async with self.session.post(
|
||||
f"{self.base_url}/payments/{payment_id}/confirm",
|
||||
headers={"Authorization": f"Bearer {self.api_key}"}
|
||||
) as response:
|
||||
return response.status == 200
|
||||
|
||||
except self.aiohttp.ClientError as e:
|
||||
self.logger.error(f"SBP confirm error: {e}")
|
||||
return False
|
||||
|
||||
def cancel_payment(self, payment_id: str) -> bool:
|
||||
"""Отмена платежа через СБП"""
|
||||
self.logger.warning(f"SBP cancel not supported for {payment_id}")
|
||||
return False
|
||||
|
||||
async def cancel_payment_async(self, payment_id: str) -> bool:
|
||||
"""Асинхронная отмена платежа через СБП"""
|
||||
await self._ensure_session()
|
||||
|
||||
try:
|
||||
async with self.session.post(
|
||||
f"{self.base_url}/payments/{payment_id}/cancel",
|
||||
headers={"Authorization": f"Bearer {self.api_key}"}
|
||||
) as response:
|
||||
return response.status == 200
|
||||
|
||||
except self.aiohttp.ClientError as e:
|
||||
self.logger.error(f"SBP cancel error: {e}")
|
||||
return False
|
||||
|
||||
def get_payment_status(self, payment_id: str) -> str:
|
||||
"""Получение статуса платежа через СБП"""
|
||||
# Синхронная версия для совместимости
|
||||
import asyncio
|
||||
try:
|
||||
return asyncio.run(self.get_payment_status_async(payment_id))
|
||||
except:
|
||||
return "unknown"
|
||||
|
||||
async def get_payment_status_async(self, payment_id: str) -> str:
|
||||
"""Асинхронное получение статуса платежа через СБП"""
|
||||
await self._ensure_session()
|
||||
|
||||
try:
|
||||
async with self.session.get(
|
||||
f"{self.base_url}/payments/{payment_id}/status",
|
||||
headers={"Authorization": f"Bearer {self.api_key}"}
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
return data.get("status", "unknown")
|
||||
else:
|
||||
return "unknown"
|
||||
|
||||
except self.aiohttp.ClientError as e:
|
||||
self.logger.error(f"SBP status error: {e}")
|
||||
return "unknown"
|
||||
|
||||
def validate_webhook(self, request_data: Dict, signature: str) -> bool:
|
||||
"""Валидация webhook подписи для СБП"""
|
||||
try:
|
||||
# СБП webhook валидация основана на HMAC-SHA256
|
||||
import base64
|
||||
import json
|
||||
|
||||
# Сериализуем данные в JSON
|
||||
body = json.dumps(request_data, separators=(',', ':'))
|
||||
|
||||
# Вычисляем ожидаемую подпись
|
||||
expected_hmac = hmac.new(
|
||||
self.webhook_secret.encode('utf-8'),
|
||||
body.encode('utf-8'),
|
||||
hashlib.sha256
|
||||
)
|
||||
expected_signature = base64.b64encode(expected_hmac.digest()).decode('utf-8')
|
||||
|
||||
return hmac.compare_digest(signature, expected_signature)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"SBP webhook validation error: {e}")
|
||||
return False
|
||||
|
||||
def process_webhook(self, webhook_data: Dict) -> PaymentEvent:
|
||||
"""Обработка webhook события от СБП"""
|
||||
try:
|
||||
event_type = webhook_data.get("event_type", "payment.unknown")
|
||||
payment_data = webhook_data.get("payment", {})
|
||||
|
||||
amount = float(payment_data.get("amount", 0))
|
||||
metadata = payment_data.get("metadata", {})
|
||||
|
||||
return PaymentEvent(
|
||||
type=f"payment.{event_type.split('.')[-1]}",
|
||||
payment_id=payment_data.get("id"),
|
||||
amount=amount,
|
||||
currency=payment_data.get("currency", "RUB"),
|
||||
user_id=int(metadata.get("user_id")) if metadata.get("user_id") else None,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"SBP webhook processing error: {e}")
|
||||
raise PaymentProviderError(f"SBP webhook processing failed: {e}")
|
||||
@@ -0,0 +1,346 @@
|
||||
"""
|
||||
Сервис управления ролями пользователей.
|
||||
Отвечает за назначение, проверку и получение ролей пользователей.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Dict, Optional, List
|
||||
from database.models import Role
|
||||
from database.repository import BaseRepository
|
||||
|
||||
|
||||
class RoleService:
|
||||
"""
|
||||
Сервис для управления ролями пользователей.
|
||||
|
||||
Предоставляет методы для:
|
||||
- Получения ролей пользователей
|
||||
- Назначения ролей
|
||||
- Проверки прав доступа
|
||||
- Валидации ролей
|
||||
"""
|
||||
|
||||
# Стандартные роли с их ID
|
||||
DEFAULT_ROLES = {
|
||||
'user': 1,
|
||||
'moderator': 2,
|
||||
'admin': 3
|
||||
}
|
||||
|
||||
def __init__(self, db_path: str):
|
||||
"""
|
||||
Инициализация сервиса ролей.
|
||||
|
||||
Args:
|
||||
db_path: Путь к файлу базы данных
|
||||
"""
|
||||
self.db_repo = BaseRepository(db_path)
|
||||
|
||||
def get_user_role_name(self, user_id: int) -> str:
|
||||
"""
|
||||
Получить имя роли пользователя.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя в Telegram
|
||||
|
||||
Returns:
|
||||
str: Имя роли ('user', 'moderator', 'admin')
|
||||
"""
|
||||
try:
|
||||
# Получаем role_id пользователя
|
||||
query = """
|
||||
SELECT r.name
|
||||
FROM users u
|
||||
JOIN roles r ON u.role_id = r.id
|
||||
WHERE u.telegram_id = ?
|
||||
"""
|
||||
result = self.db_repo._fetch_one(query, (user_id,))
|
||||
|
||||
if result:
|
||||
return result[0]
|
||||
|
||||
# Если пользователь не найден или роль не указана, возвращаем 'user'
|
||||
return 'user'
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting user role: {e}")
|
||||
return 'user' # fallback
|
||||
|
||||
def get_user_role_id(self, user_id: int) -> int:
|
||||
"""
|
||||
Получить ID роли пользователя.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя в Telegram
|
||||
|
||||
Returns:
|
||||
int: ID роли
|
||||
"""
|
||||
try:
|
||||
query = "SELECT role_id FROM users WHERE telegram_id = ?"
|
||||
result = self.db_repo._fetch_one(query, (user_id,))
|
||||
|
||||
return result[0] if result else 1 # default user role
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting user role ID: {e}")
|
||||
return 1
|
||||
|
||||
def assign_role(self, user_id: int, role_name: str) -> bool:
|
||||
"""
|
||||
Назначить роль пользователю.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя в Telegram
|
||||
role_name: Имя роли ('user', 'moderator', 'admin')
|
||||
|
||||
Returns:
|
||||
bool: True если роль назначена успешно
|
||||
"""
|
||||
try:
|
||||
# Проверяем валидность роли
|
||||
if not self._validate_role_name(role_name):
|
||||
return False
|
||||
|
||||
# Получаем ID роли
|
||||
role_id = self._get_role_id_by_name(role_name)
|
||||
if not role_id:
|
||||
return False
|
||||
|
||||
# Обновляем роль пользователя
|
||||
query = "UPDATE users SET role_id = ? WHERE telegram_id = ?"
|
||||
self.db_repo._execute_query(query, (role_id, user_id))
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error assigning role: {e}")
|
||||
return False
|
||||
|
||||
def is_admin(self, user_id: int) -> bool:
|
||||
"""
|
||||
Проверить, является ли пользователь администратором.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя в Telegram
|
||||
|
||||
Returns:
|
||||
bool: True если пользователь администратор
|
||||
"""
|
||||
role_name = self.get_user_role_name(user_id)
|
||||
return role_name == 'admin'
|
||||
|
||||
def is_moderator(self, user_id: int) -> bool:
|
||||
"""
|
||||
Проверить, является ли пользователь модератором или администратором.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя в Telegram
|
||||
|
||||
Returns:
|
||||
bool: True если пользователь модератор или администратор
|
||||
"""
|
||||
role_name = self.get_user_role_name(user_id)
|
||||
return role_name in ['moderator', 'admin']
|
||||
|
||||
def get_role_permissions(self, role_name: str) -> Dict:
|
||||
"""
|
||||
Получить разрешения для роли.
|
||||
|
||||
Args:
|
||||
role_name: Имя роли
|
||||
|
||||
Returns:
|
||||
Dict: Словарь с разрешениями
|
||||
"""
|
||||
try:
|
||||
query = "SELECT permissions FROM roles WHERE name = ?"
|
||||
result = self.db_repo._fetch_one(query, (role_name,))
|
||||
|
||||
if result and result[0]:
|
||||
return json.loads(result[0])
|
||||
|
||||
# Возвращаем разрешения по умолчанию
|
||||
return self._get_default_permissions(role_name)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting role permissions: {e}")
|
||||
return {}
|
||||
|
||||
def get_all_roles(self) -> List[Role]:
|
||||
"""
|
||||
Получить все активные роли.
|
||||
|
||||
Returns:
|
||||
List[Role]: Список ролей
|
||||
"""
|
||||
try:
|
||||
query = "SELECT id, name, display_name, description, permissions, is_active FROM roles WHERE is_active = 1"
|
||||
rows = self.db_repo._fetch_all(query)
|
||||
|
||||
roles = []
|
||||
for row in rows:
|
||||
role = Role(
|
||||
id=row[0],
|
||||
name=row[1],
|
||||
display_name=row[2],
|
||||
description=row[3],
|
||||
permissions=row[4],
|
||||
is_active=bool(row[5])
|
||||
)
|
||||
roles.append(role)
|
||||
|
||||
return roles
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting all roles: {e}")
|
||||
return []
|
||||
|
||||
def _validate_role_name(self, role_name: str) -> bool:
|
||||
"""
|
||||
Проверить валидность имени роли.
|
||||
|
||||
Args:
|
||||
role_name: Имя роли
|
||||
|
||||
Returns:
|
||||
bool: True если роль валидна
|
||||
"""
|
||||
return role_name in self.DEFAULT_ROLES.keys()
|
||||
|
||||
def _get_role_id_by_name(self, role_name: str) -> Optional[int]:
|
||||
"""
|
||||
Получить ID роли по имени.
|
||||
|
||||
Args:
|
||||
role_name: Имя роли
|
||||
|
||||
Returns:
|
||||
Optional[int]: ID роли или None
|
||||
"""
|
||||
try:
|
||||
query = "SELECT id FROM roles WHERE name = ? AND is_active = 1"
|
||||
result = self.db_repo._fetch_one(query, (role_name,))
|
||||
|
||||
return result[0] if result else None
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting role ID by name: {e}")
|
||||
return None
|
||||
|
||||
def _get_default_permissions(self, role_name: str) -> Dict:
|
||||
"""
|
||||
Получить разрешения по умолчанию для роли.
|
||||
|
||||
Args:
|
||||
role_name: Имя роли
|
||||
|
||||
Returns:
|
||||
Dict: Словарь с разрешениями
|
||||
"""
|
||||
defaults = {
|
||||
'user': {
|
||||
"can_use_basic_commands": True,
|
||||
"can_play_games": True
|
||||
},
|
||||
'moderator': {
|
||||
"can_use_basic_commands": True,
|
||||
"can_play_games": True,
|
||||
"can_moderate": True,
|
||||
"can_warn_users": True
|
||||
},
|
||||
'admin': {
|
||||
"can_use_basic_commands": True,
|
||||
"can_play_games": True,
|
||||
"can_moderate": True,
|
||||
"can_warn_users": True,
|
||||
"can_manage_users": True,
|
||||
"can_schedule_posts": True,
|
||||
"can_export_data": True
|
||||
}
|
||||
}
|
||||
|
||||
return defaults.get(role_name, {})
|
||||
|
||||
def initialize_roles(self) -> bool:
|
||||
"""
|
||||
Инициализировать стандартные роли в базе данных.
|
||||
|
||||
Returns:
|
||||
bool: True если инициализация успешна
|
||||
"""
|
||||
try:
|
||||
roles_data = [
|
||||
(1, 'user', 'Пользователь', 'Обычный пользователь чата', json.dumps({
|
||||
"can_use_basic_commands": True,
|
||||
"can_play_games": True
|
||||
})),
|
||||
(2, 'moderator', 'Модератор', 'Модератор чата с правами управления', json.dumps({
|
||||
"can_use_basic_commands": True,
|
||||
"can_play_games": True,
|
||||
"can_moderate": True,
|
||||
"can_warn_users": True
|
||||
})),
|
||||
(3, 'admin', 'Администратор', 'Администратор бота с полными правами', json.dumps({
|
||||
"can_use_basic_commands": True,
|
||||
"can_play_games": True,
|
||||
"can_moderate": True,
|
||||
"can_warn_users": True,
|
||||
"can_manage_users": True,
|
||||
"can_schedule_posts": True,
|
||||
"can_export_data": True
|
||||
}))
|
||||
]
|
||||
|
||||
for role_data in roles_data:
|
||||
query = """
|
||||
INSERT OR IGNORE INTO roles (id, name, display_name, description, permissions)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
"""
|
||||
self.db_repo._execute_query(query, role_data)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error initializing roles: {e}")
|
||||
return False
|
||||
|
||||
def initialize_admin_users(self, admin_ids: List[int]) -> bool:
|
||||
"""
|
||||
Инициализировать пользователей-администраторов из конфигурации.
|
||||
|
||||
Args:
|
||||
admin_ids: Список ID администраторов из конфигурации
|
||||
|
||||
Returns:
|
||||
bool: True если инициализация успешна
|
||||
"""
|
||||
try:
|
||||
for admin_id in admin_ids:
|
||||
try:
|
||||
# Проверяем, существует ли пользователь
|
||||
existing_user = self.db_repo._fetch_one(
|
||||
"SELECT id FROM users WHERE telegram_id = ?", (admin_id,)
|
||||
)
|
||||
|
||||
if existing_user:
|
||||
# Обновляем роль существующего пользователя на admin
|
||||
query = "UPDATE users SET role_id = ? WHERE telegram_id = ?"
|
||||
self.db_repo._execute_query(query, (3, admin_id)) # 3 = admin role
|
||||
print(f"Updated existing user {admin_id} to admin role")
|
||||
else:
|
||||
# Создаем нового пользователя с ролью admin
|
||||
# Для этого нам нужны дополнительные данные, которые мы не имеем
|
||||
# Поэтому просто пропускаем - пользователь будет создан при первом взаимодействии
|
||||
print(f"Admin user {admin_id} not found in database, will be created on first interaction")
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing admin user {admin_id}: {e}")
|
||||
continue
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error initializing admin users: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,437 @@
|
||||
"""
|
||||
Сервис управления триггерами сообщений.
|
||||
Отвечает за проверку триггеров, их активацию и обновление статистики.
|
||||
"""
|
||||
|
||||
import re
|
||||
import logging
|
||||
from typing import List, Dict, Optional, Tuple, Any
|
||||
from datetime import datetime
|
||||
from database.repository import TriggerRepository
|
||||
from core.exceptions import ValidationError, DatabaseError
|
||||
|
||||
|
||||
class TriggerService:
|
||||
"""
|
||||
Сервис для управления триггерами сообщений.
|
||||
|
||||
Отвечает за:
|
||||
- Проверку текста сообщений на совпадение с триггерами
|
||||
- Выполнение действий при срабатывании триггеров
|
||||
- Обновление статистики использования триггеров
|
||||
- Управление активными триггерами
|
||||
"""
|
||||
|
||||
def __init__(self, database_url: str):
|
||||
"""
|
||||
Инициализация сервиса триггеров.
|
||||
|
||||
Args:
|
||||
database_url: URL базы данных
|
||||
"""
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.repository = TriggerRepository(database_url)
|
||||
self._triggers_cache: Optional[List[Dict]] = None
|
||||
self._last_cache_update: Optional[datetime] = None
|
||||
self._cache_ttl_seconds = 300 # 5 минут
|
||||
|
||||
async def check_triggers(self, message_text: str, chat_type: str = "group") -> List[Dict]:
|
||||
"""
|
||||
Проверка текста сообщения на совпадение с активными триггерами.
|
||||
|
||||
Args:
|
||||
message_text: Текст сообщения для проверки
|
||||
chat_type: Тип чата ("group" или "private")
|
||||
|
||||
Returns:
|
||||
Список сработавших триггеров с их данными
|
||||
"""
|
||||
if not message_text or not isinstance(message_text, str):
|
||||
return []
|
||||
|
||||
try:
|
||||
# Получаем активные триггеры
|
||||
active_triggers = await self.get_active_triggers(chat_type)
|
||||
|
||||
if not active_triggers:
|
||||
return []
|
||||
|
||||
matched_triggers = []
|
||||
|
||||
for trigger in active_triggers:
|
||||
try:
|
||||
# Компилируем регулярное выражение
|
||||
pattern = re.compile(trigger['pattern'], re.IGNORECASE | re.MULTILINE)
|
||||
|
||||
# Проверяем совпадение
|
||||
if pattern.search(message_text.strip()):
|
||||
matched_triggers.append(trigger)
|
||||
|
||||
# Обновляем статистику асинхронно (не ждем завершения)
|
||||
self._update_trigger_stats_async(trigger['id'])
|
||||
|
||||
except re.error as e:
|
||||
self.logger.error(f"Invalid regex pattern in trigger {trigger['id']}: {e}")
|
||||
continue
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error checking trigger {trigger['id']}: {e}")
|
||||
continue
|
||||
|
||||
return matched_triggers
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in check_triggers: {e}")
|
||||
return []
|
||||
|
||||
async def get_active_triggers(self, chat_type: str = "group") -> List[Dict]:
|
||||
"""
|
||||
Получение активных триггеров для указанного типа чата.
|
||||
|
||||
Args:
|
||||
chat_type: Тип чата ("group" или "private")
|
||||
|
||||
Returns:
|
||||
Список активных триггеров
|
||||
"""
|
||||
try:
|
||||
# Проверяем кеш
|
||||
if self._should_use_cache():
|
||||
return self._triggers_cache or []
|
||||
|
||||
# Получаем из базы данных
|
||||
triggers = await self.repository.get_active_triggers_async(chat_type)
|
||||
|
||||
# Обновляем кеш
|
||||
self._triggers_cache = triggers
|
||||
self._last_cache_update = datetime.now()
|
||||
|
||||
return triggers
|
||||
|
||||
except DatabaseError as e:
|
||||
self.logger.error(f"Database error getting active triggers: {e}")
|
||||
return self._triggers_cache or []
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting active triggers: {e}")
|
||||
return []
|
||||
|
||||
async def execute_trigger_actions(self, matched_triggers: List[Dict],
|
||||
chat_id: int, message_text: str, message_id: int = None) -> List[Dict]:
|
||||
"""
|
||||
Выполнение действий для сработавших триггеров.
|
||||
|
||||
Args:
|
||||
matched_triggers: Список сработавших триггеров
|
||||
chat_id: ID чата
|
||||
message_text: Оригинальный текст сообщения
|
||||
message_id: ID сообщения (для реакций)
|
||||
|
||||
Returns:
|
||||
Список действий для выполнения
|
||||
"""
|
||||
actions = []
|
||||
|
||||
for trigger in matched_triggers:
|
||||
try:
|
||||
trigger_actions = self._build_trigger_actions(trigger, chat_id, message_text, message_id)
|
||||
actions.extend(trigger_actions)
|
||||
|
||||
# Обновляем статистику
|
||||
await self.update_trigger_stats(trigger['id'])
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error executing actions for trigger {trigger['id']}: {e}")
|
||||
continue
|
||||
|
||||
return actions
|
||||
|
||||
def _build_trigger_actions(self, trigger: Dict, chat_id: int, message_text: str, message_id: int = None) -> List[Dict]:
|
||||
"""
|
||||
Построение списка действий для триггера.
|
||||
|
||||
Args:
|
||||
trigger: Данные триггера
|
||||
chat_id: ID чата
|
||||
message_text: Текст сообщения
|
||||
message_id: ID сообщения (для реакций)
|
||||
|
||||
Returns:
|
||||
Список действий
|
||||
"""
|
||||
actions = []
|
||||
|
||||
# Текстовый ответ
|
||||
if trigger.get('response_text'):
|
||||
actions.append({
|
||||
'type': 'text',
|
||||
'content': trigger['response_text'],
|
||||
'trigger_id': trigger['id'],
|
||||
'trigger_name': trigger['name']
|
||||
})
|
||||
|
||||
# Стикер
|
||||
if trigger.get('response_sticker'):
|
||||
actions.append({
|
||||
'type': 'sticker',
|
||||
'content': trigger['response_sticker'],
|
||||
'trigger_id': trigger['id'],
|
||||
'trigger_name': trigger['name']
|
||||
})
|
||||
|
||||
# GIF
|
||||
if trigger.get('response_gif'):
|
||||
actions.append({
|
||||
'type': 'gif',
|
||||
'content': trigger['response_gif'],
|
||||
'trigger_id': trigger['id'],
|
||||
'trigger_name': trigger['name']
|
||||
})
|
||||
|
||||
# Реакция
|
||||
if trigger.get('reaction_type') and trigger.get('action_data') and message_id is not None:
|
||||
actions.append({
|
||||
'type': 'reaction',
|
||||
'reaction_type': trigger['reaction_type'],
|
||||
'content': trigger['action_data'], # emoji
|
||||
'message_id': message_id,
|
||||
'trigger_id': trigger['id'],
|
||||
'trigger_name': trigger['name']
|
||||
})
|
||||
|
||||
return actions
|
||||
|
||||
async def update_trigger_stats(self, trigger_id: int) -> bool:
|
||||
"""
|
||||
Обновление статистики срабатывания триггера.
|
||||
|
||||
Args:
|
||||
trigger_id: ID триггера
|
||||
|
||||
Returns:
|
||||
True если обновление успешно
|
||||
"""
|
||||
try:
|
||||
success = self.repository.update_trigger_stats(trigger_id)
|
||||
|
||||
# Инвалидируем кеш
|
||||
self._invalidate_cache()
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error updating trigger stats for {trigger_id}: {e}")
|
||||
return False
|
||||
|
||||
def _update_trigger_stats_async(self, trigger_id: int):
|
||||
"""Асинхронное обновление статистики триггера (не ждет завершения)"""
|
||||
import asyncio
|
||||
asyncio.create_task(self.update_trigger_stats(trigger_id))
|
||||
|
||||
async def add_trigger(self, trigger_data: Dict) -> Optional[int]:
|
||||
"""
|
||||
Добавление нового триггера.
|
||||
|
||||
Args:
|
||||
trigger_data: Данные триггера
|
||||
|
||||
Returns:
|
||||
ID созданного триггера или None при ошибке
|
||||
"""
|
||||
try:
|
||||
# Валидация данных
|
||||
self._validate_trigger_data(trigger_data)
|
||||
|
||||
# Проверяем регулярное выражение
|
||||
self._validate_regex_pattern(trigger_data['pattern'])
|
||||
|
||||
trigger_id = self.repository.add_trigger(trigger_data)
|
||||
|
||||
# Инвалидируем кеш
|
||||
self._invalidate_cache()
|
||||
|
||||
self.logger.info(f"Created new trigger: {trigger_data['name']} (ID: {trigger_id})")
|
||||
return trigger_id
|
||||
|
||||
except ValidationError as e:
|
||||
self.logger.warning(f"Validation error creating trigger: {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error creating trigger: {e}")
|
||||
return None
|
||||
|
||||
async def update_trigger(self, trigger_id: int, update_data: Dict) -> bool:
|
||||
"""
|
||||
Обновление триггера.
|
||||
|
||||
Args:
|
||||
trigger_id: ID триггера
|
||||
update_data: Данные для обновления
|
||||
|
||||
Returns:
|
||||
True если обновление успешно
|
||||
"""
|
||||
try:
|
||||
# Валидация данных
|
||||
if 'pattern' in update_data:
|
||||
self._validate_regex_pattern(update_data['pattern'])
|
||||
|
||||
success = self.repository.update_trigger(trigger_id, update_data)
|
||||
|
||||
# Инвалидируем кеш
|
||||
self._invalidate_cache()
|
||||
|
||||
if success:
|
||||
self.logger.info(f"Updated trigger {trigger_id}")
|
||||
else:
|
||||
self.logger.warning(f"Trigger {trigger_id} not found for update")
|
||||
|
||||
return success
|
||||
|
||||
except ValidationError as e:
|
||||
self.logger.warning(f"Validation error updating trigger {trigger_id}: {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error updating trigger {trigger_id}: {e}")
|
||||
return False
|
||||
|
||||
async def delete_trigger(self, trigger_id: int) -> bool:
|
||||
"""
|
||||
Удаление триггера.
|
||||
|
||||
Args:
|
||||
trigger_id: ID триггера
|
||||
|
||||
Returns:
|
||||
True если удаление успешно
|
||||
"""
|
||||
try:
|
||||
success = self.repository.delete_trigger(trigger_id)
|
||||
|
||||
# Инвалидируем кеш
|
||||
self._invalidate_cache()
|
||||
|
||||
if success:
|
||||
self.logger.info(f"Deleted trigger {trigger_id}")
|
||||
else:
|
||||
self.logger.warning(f"Trigger {trigger_id} not found for deletion")
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error deleting trigger {trigger_id}: {e}")
|
||||
return False
|
||||
|
||||
async def toggle_trigger(self, trigger_id: int, is_active: bool) -> bool:
|
||||
"""
|
||||
Включение/выключение триггера.
|
||||
|
||||
Args:
|
||||
trigger_id: ID триггера
|
||||
is_active: Статус активности
|
||||
|
||||
Returns:
|
||||
True если обновление успешно
|
||||
"""
|
||||
try:
|
||||
success = self.repository.toggle_trigger(trigger_id, is_active)
|
||||
|
||||
# Инвалидируем кеш
|
||||
self._invalidate_cache()
|
||||
|
||||
if success:
|
||||
status = "activated" if is_active else "deactivated"
|
||||
self.logger.info(f"Trigger {trigger_id} {status}")
|
||||
else:
|
||||
self.logger.warning(f"Trigger {trigger_id} not found for toggle")
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error toggling trigger {trigger_id}: {e}")
|
||||
return False
|
||||
|
||||
async def get_all_triggers(self) -> List[Dict]:
|
||||
"""
|
||||
Получение всех триггеров (для администрирования).
|
||||
|
||||
Returns:
|
||||
Список всех триггеров
|
||||
"""
|
||||
try:
|
||||
return self.repository.get_all_triggers()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting all triggers: {e}")
|
||||
return []
|
||||
|
||||
async def get_trigger_by_id(self, trigger_id: int) -> Optional[Dict]:
|
||||
"""
|
||||
Получение триггера по ID.
|
||||
|
||||
Args:
|
||||
trigger_id: ID триггера
|
||||
|
||||
Returns:
|
||||
Данные триггера или None
|
||||
"""
|
||||
try:
|
||||
return self.repository.get_trigger_by_id(trigger_id)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting trigger {trigger_id}: {e}")
|
||||
return None
|
||||
|
||||
def _validate_trigger_data(self, data: Dict):
|
||||
"""Валидация данных триггера"""
|
||||
if not data.get('name') or not isinstance(data['name'], str):
|
||||
raise ValidationError("Trigger name is required and must be a string")
|
||||
|
||||
if not data.get('pattern') or not isinstance(data['pattern'], str):
|
||||
raise ValidationError("Trigger pattern is required and must be a string")
|
||||
|
||||
if len(data['name'].strip()) < 1 or len(data['name']) > 100:
|
||||
raise ValidationError("Trigger name must be between 1 and 100 characters")
|
||||
|
||||
if len(data['pattern'].strip()) < 1 or len(data['pattern']) > 500:
|
||||
raise ValidationError("Trigger pattern must be between 1 and 500 characters")
|
||||
|
||||
chat_type = data.get('chat_type', 'group')
|
||||
if chat_type not in ['group', 'private']:
|
||||
raise ValidationError("Chat type must be 'group' or 'private'")
|
||||
|
||||
# Валидация полей реакции
|
||||
reaction_type = data.get('reaction_type')
|
||||
if reaction_type is not None:
|
||||
if not isinstance(reaction_type, str):
|
||||
raise ValidationError("Reaction type must be a string")
|
||||
if reaction_type not in ['emoji']:
|
||||
raise ValidationError("Reaction type must be 'emoji'")
|
||||
|
||||
# action_data обязательно при наличии reaction_type
|
||||
if not data.get('action_data') or not isinstance(data['action_data'], str):
|
||||
raise ValidationError("Action data is required when reaction type is specified")
|
||||
|
||||
if len(data['action_data'].strip()) < 1 or len(data['action_data']) > 100:
|
||||
raise ValidationError("Action data must be between 1 and 100 characters")
|
||||
|
||||
def _validate_regex_pattern(self, pattern: str):
|
||||
"""Валидация регулярного выражения"""
|
||||
try:
|
||||
re.compile(pattern, re.IGNORECASE | re.MULTILINE)
|
||||
except re.error as e:
|
||||
raise ValidationError(f"Invalid regex pattern: {e}")
|
||||
|
||||
def _should_use_cache(self) -> bool:
|
||||
"""Проверка необходимости использования кеша"""
|
||||
if self._triggers_cache is None or self._last_cache_update is None:
|
||||
return False
|
||||
|
||||
time_diff = (datetime.now() - self._last_cache_update).total_seconds()
|
||||
return time_diff < self._cache_ttl_seconds
|
||||
|
||||
def _invalidate_cache(self):
|
||||
"""Инвалидация кеша"""
|
||||
self._triggers_cache = None
|
||||
self._last_cache_update = None
|
||||
|
||||
async def clear_cache(self):
|
||||
"""Очистка кеша"""
|
||||
self._invalidate_cache()
|
||||
self.logger.debug("Trigger cache cleared")
|
||||
@@ -0,0 +1,805 @@
|
||||
"""
|
||||
Сервис управления пользователями.
|
||||
Отвечает за бизнес-логику работы с пользователями.
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional, Tuple, Any
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from core.exceptions import ValidationError
|
||||
from core.permissions import UserRole, permission_manager
|
||||
from utils.validators import InputValidator, Validator
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserProfile:
|
||||
"""Профиль пользователя"""
|
||||
user_id: int
|
||||
username: Optional[str]
|
||||
first_name: str
|
||||
last_name: Optional[str]
|
||||
reputation: int = 0
|
||||
rank: str = "Новичок"
|
||||
message_count: int = 0
|
||||
joined_date: Optional[datetime] = None
|
||||
last_activity: Optional[datetime] = None
|
||||
warnings: int = 0
|
||||
achievements: Optional[List[str]] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.achievements is None:
|
||||
self.achievements = []
|
||||
if self.joined_date is None:
|
||||
self.joined_date = datetime.now()
|
||||
if self.last_activity is None:
|
||||
self.last_activity = datetime.now()
|
||||
|
||||
|
||||
class UserService:
|
||||
"""
|
||||
Сервис для управления пользователями и их данными.
|
||||
|
||||
Отвечает за:
|
||||
- Создание и обновление профилей пользователей
|
||||
- Расчет рейтингов и рангов
|
||||
- Управление достижениями
|
||||
- Статистику активности
|
||||
"""
|
||||
|
||||
def __init__(self, user_repository, score_repository, role_service=None):
|
||||
"""
|
||||
Инициализация сервиса.
|
||||
|
||||
Args:
|
||||
user_repository: Репозиторий пользователей
|
||||
score_repository: Репозиторий очков и рейтингов
|
||||
role_service: Сервис управления ролями
|
||||
"""
|
||||
self.user_repo = user_repository
|
||||
self.score_repo = score_repository
|
||||
self.role_service = role_service
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
# Настройки рангов и очков
|
||||
self.rank_thresholds = {
|
||||
"Новичок": 0,
|
||||
"Ученик": 100,
|
||||
"Активист": 500,
|
||||
"Знаток": 1000,
|
||||
"Эксперт": 2500,
|
||||
"Мастер": 5000,
|
||||
"Гуру": 10000,
|
||||
"Легенда": 25000,
|
||||
"Капитан": 50000,
|
||||
"Генерал": 100000,
|
||||
"Император": 250000
|
||||
}
|
||||
|
||||
self.rank_names = list(self.rank_thresholds.keys())
|
||||
|
||||
async def get_or_create_user(self, user_id: int, username: str = None,
|
||||
first_name: str = None, last_name: str = None) -> UserProfile:
|
||||
"""
|
||||
Получение пользователя или создание нового профиля.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
username: Имя пользователя
|
||||
first_name: Имя
|
||||
last_name: Фамилия
|
||||
|
||||
Returns:
|
||||
Профиль пользователя
|
||||
"""
|
||||
try:
|
||||
# Валидация входных данных
|
||||
if not isinstance(user_id, int) or user_id <= 0 or user_id > 2147483647:
|
||||
raise ValidationError("Неверный ID пользователя")
|
||||
|
||||
# Получаем данные пользователя из репозитория
|
||||
user_data = await self.user_repo.get_by_id_async(user_id)
|
||||
if user_data and isinstance(user_data, dict):
|
||||
user_data = user_data
|
||||
else:
|
||||
user_data = None
|
||||
self.logger.debug(f"Результат get_by_id для {user_id}: {user_data is not None}")
|
||||
|
||||
if user_data:
|
||||
self.logger.debug(f"Найден существующий пользователь {user_id}: id={user_data.get('id')}, telegram_id={user_data.get('telegram_id')}, first_name={user_data.get('first_name')}")
|
||||
|
||||
# Проверяем, что все необходимые поля присутствуют и не None
|
||||
if not user_data.get('id'):
|
||||
self.logger.error(f"Пользователь {user_id} найден, но поле id отсутствует или None")
|
||||
return None
|
||||
|
||||
# Пользователь существует, обновляем данные
|
||||
self.logger.debug(f"Обновление данных существующего пользователя {user_id}")
|
||||
updated_data = await self._update_user_data(user_data, username, first_name, last_name)
|
||||
self.logger.debug(f"Результат обновления данных для {user_id}: {updated_data is not None}")
|
||||
if updated_data:
|
||||
profile = self._map_to_profile(updated_data)
|
||||
self.logger.debug(f"Создан профиль из обновленных данных для {user_id}: {profile is not None}")
|
||||
return profile
|
||||
else:
|
||||
self.logger.error(f"Ошибка обновления данных пользователя {user_id}")
|
||||
return None
|
||||
else:
|
||||
# Создаем нового пользователя
|
||||
self.logger.debug(f"Создание нового пользователя {user_id}")
|
||||
new_user_data = await self._create_new_user(user_id, username, first_name, last_name)
|
||||
self.logger.debug(f"Результат создания нового пользователя {user_id}: {new_user_data is not None}")
|
||||
if new_user_data:
|
||||
profile = self._map_to_profile(new_user_data)
|
||||
self.logger.debug(f"Создан профиль из новых данных для {user_id}: {profile is not None}")
|
||||
return profile
|
||||
else:
|
||||
self.logger.error(f"Ошибка создания нового пользователя {user_id}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
# Handle potential sqlite3.IntegrityError or other database errors
|
||||
if "FOREIGN KEY constraint failed" in str(e) or "IntegrityError" in str(type(e).__name__):
|
||||
self.logger.warning(f"Нарушение внешнего ключа для пользователя {user_id}, пропускаем создание ролей")
|
||||
# Попытка создания без ролей или с ролью по умолчанию
|
||||
try:
|
||||
# Используем роль 1 (user) по умолчанию
|
||||
user_data['role_id'] = 1
|
||||
new_user_data = await self._create_new_user(user_id, username, first_name, last_name)
|
||||
if new_user_data:
|
||||
profile = self._map_to_profile(new_user_data)
|
||||
return profile
|
||||
except Exception as retry_error:
|
||||
self.logger.error(f"Повторная попытка создания пользователя {user_id} не удалась: {retry_error}")
|
||||
return None
|
||||
else:
|
||||
self.logger.error(f"Ошибка базы данных при работе с пользователем {user_id}: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
if "FOREIGN KEY constraint failed" in str(e):
|
||||
self.logger.warning(f"Нарушение внешнего ключа для пользователя {user_id}, пропускаем создание ролей")
|
||||
# Попытка создания без ролей или с ролью по умолчанию
|
||||
try:
|
||||
# Используем роль 1 (user) по умолчанию
|
||||
user_data['role_id'] = 1
|
||||
new_user_data = await self._create_new_user(user_id, username, first_name, last_name)
|
||||
if new_user_data:
|
||||
profile = self._map_to_profile(new_user_data)
|
||||
return profile
|
||||
except Exception as retry_error:
|
||||
self.logger.error(f"Повторная попытка создания пользователя {user_id} не удалась: {retry_error}")
|
||||
return None
|
||||
else:
|
||||
self.logger.error(f"Ошибка базы данных при работе с пользователем {user_id}: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
self.logger.error(f"Ошибка в get_or_create_user для пользователя {user_id}: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
async def update_user_activity(self, user_id: int, chat_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Обновление активности пользователя.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
chat_id: ID чата
|
||||
|
||||
Returns:
|
||||
Результат обновления (включая повышение ранга)
|
||||
"""
|
||||
try:
|
||||
# Обновляем статистику активности
|
||||
await self.user_repo.update_activity(user_id, chat_id)
|
||||
|
||||
# Обновляем очки
|
||||
print(f"[DEBUG] UserService.update_user_activity: calling score_repo.update_score({user_id}, 1)")
|
||||
await self.score_repo.update_score(user_id, 1)
|
||||
|
||||
# Проверяем повышение ранга
|
||||
rank_update = await self._check_rank_promotion(user_id)
|
||||
|
||||
return {
|
||||
'activity_updated': True,
|
||||
'rank_promoted': rank_update is not None,
|
||||
'rank_update': rank_update
|
||||
}
|
||||
except Exception as e:
|
||||
self.logger.error(f"Ошибка в update_user_activity для пользователя {user_id}: {e}", exc_info=True)
|
||||
# Возвращаем результат с ошибкой, но не прерываем выполнение
|
||||
return {
|
||||
'activity_updated': False,
|
||||
'rank_promoted': False,
|
||||
'rank_update': None,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
async def add_warning(self, user_id: int, reason: str, admin_id: int) -> bool:
|
||||
"""
|
||||
Добавление предупреждения пользователю.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
reason: Причина предупреждения
|
||||
admin_id: ID администратора
|
||||
|
||||
Returns:
|
||||
True если предупреждение добавлено
|
||||
"""
|
||||
if not InputValidator.validate_text_content(reason, max_length=500):
|
||||
raise ValidationError("Неверная причина предупреждения")
|
||||
|
||||
print(f"[DEBUG] UserService.add_warning called: user_id={user_id}, reason='{reason}', admin_id={admin_id}")
|
||||
result = await self.user_repo.add_warning(user_id, reason, admin_id)
|
||||
print(f"[DEBUG] UserService.add_warning result: {result}")
|
||||
|
||||
# Проверяем обновление счетчика предупреждений
|
||||
if result:
|
||||
warnings_count = await self.user_repo.get_warnings_count(user_id)
|
||||
print(f"[DEBUG] Warnings count after adding warning: {warnings_count}")
|
||||
|
||||
return result
|
||||
|
||||
async def get_user_achievements(self, user_id: int) -> List[Tuple[str, datetime]]:
|
||||
"""
|
||||
Получение достижений пользователя.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
|
||||
Returns:
|
||||
Список достижений с датами разблокировки
|
||||
"""
|
||||
return await self.user_repo.get_user_achievements(user_id)
|
||||
|
||||
async def check_and_unlock_achievements(self, user_id: int) -> List[str]:
|
||||
"""
|
||||
Проверка и разблокировка новых достижений.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
|
||||
Returns:
|
||||
Список новых достижений
|
||||
"""
|
||||
self.logger.debug(f"Starting achievement check for user {user_id}")
|
||||
user_profile = await self.get_or_create_user(user_id)
|
||||
user_stats = await self._get_user_statistics(user_id)
|
||||
self.logger.debug(f"User stats for {user_id}: {user_stats}")
|
||||
|
||||
new_achievements = []
|
||||
all_achievements = await self.user_repo.get_all_achievements()
|
||||
self.logger.debug(f"Total achievements to check: {len(all_achievements)}")
|
||||
|
||||
for achievement in all_achievements:
|
||||
achievement_id, name, description, condition_type, condition_value, badge = achievement
|
||||
self.logger.debug(f"Checking achievement: {name}, type: {condition_type}, value: {condition_value}")
|
||||
|
||||
if await self._check_achievement_condition(user_profile, user_stats, condition_type, condition_value):
|
||||
self.logger.debug(f"Achievement condition met for: {name}")
|
||||
if not await self.user_repo.has_achievement(user_id, achievement_id):
|
||||
self.logger.debug(f"Unlocking achievement: {name}")
|
||||
await self.user_repo.unlock_achievement(user_id, achievement_id)
|
||||
new_achievements.append(name)
|
||||
else:
|
||||
self.logger.debug(f"Achievement already unlocked: {name}")
|
||||
else:
|
||||
self.logger.debug(f"Achievement condition NOT met for: {name}")
|
||||
|
||||
self.logger.debug(f"New achievements unlocked: {new_achievements}")
|
||||
return new_achievements
|
||||
|
||||
async def get_top_users(self, limit: int = 10) -> List[Tuple[int, str, str, int]]:
|
||||
"""
|
||||
Получение топ пользователей по очкам.
|
||||
|
||||
Args:
|
||||
limit: Количество пользователей
|
||||
|
||||
Returns:
|
||||
Список пользователей (id, username, first_name, score)
|
||||
"""
|
||||
return await self.user_repo.get_top_users_async(limit)
|
||||
|
||||
async def search_users(self, query: str, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Поиск пользователей по имени или username.
|
||||
|
||||
Args:
|
||||
query: Поисковый запрос
|
||||
limit: Максимальное количество результатов
|
||||
|
||||
Returns:
|
||||
Список найденных пользователей
|
||||
"""
|
||||
if not InputValidator.validate_text_content(query, max_length=50):
|
||||
raise ValidationError("Неверный поисковый запрос")
|
||||
|
||||
return await self.user_repo.search_users(query, limit)
|
||||
|
||||
def calculate_rank(self, score: int) -> str:
|
||||
"""
|
||||
Расчет ранга пользователя по количеству очков.
|
||||
|
||||
Args:
|
||||
score: Количество очков
|
||||
|
||||
Returns:
|
||||
Название ранга
|
||||
"""
|
||||
for rank, threshold in reversed(self.rank_thresholds.items()):
|
||||
if score >= threshold:
|
||||
return rank
|
||||
|
||||
return "Новичок"
|
||||
|
||||
def get_user_role(self, user_id: int) -> str:
|
||||
"""
|
||||
Получение роли пользователя.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
|
||||
Returns:
|
||||
Название роли
|
||||
"""
|
||||
if self.role_service:
|
||||
return self.role_service.get_user_role_name(user_id)
|
||||
return UserRole.USER.value # fallback
|
||||
|
||||
def is_admin(self, user_id: int) -> bool:
|
||||
"""
|
||||
Проверка, является ли пользователь администратором.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
|
||||
Returns:
|
||||
True если администратор
|
||||
"""
|
||||
if self.role_service:
|
||||
return self.role_service.is_admin(user_id)
|
||||
# Используем permission_manager как fallback
|
||||
user_role = self.get_user_role(user_id)
|
||||
try:
|
||||
role_enum = UserRole(user_role)
|
||||
return role_enum in [UserRole.ADMIN, UserRole.SUPER_ADMIN]
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def is_moderator(self, user_id: int) -> bool:
|
||||
"""
|
||||
Проверка, является ли пользователь модератором или администратором.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
|
||||
Returns:
|
||||
True если модератор или администратор
|
||||
"""
|
||||
if self.role_service:
|
||||
return self.role_service.is_moderator(user_id)
|
||||
# Используем permission_manager как fallback
|
||||
user_role = self.get_user_role(user_id)
|
||||
try:
|
||||
role_enum = UserRole(user_role)
|
||||
return role_enum in [UserRole.MODERATOR, UserRole.ADMIN, UserRole.SUPER_ADMIN]
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
async def get_user_role_enum_async(self, user_id: int) -> UserRole:
|
||||
"""
|
||||
Получение роли пользователя как enum UserRole (асинхронная версия).
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
|
||||
Returns:
|
||||
Роль пользователя как enum
|
||||
"""
|
||||
try:
|
||||
# Используем permission_manager для получения роли
|
||||
from core.permissions import permission_manager
|
||||
# Получаем config из Application если возможно, иначе создаем минимальный
|
||||
try:
|
||||
from core.application import app_instance
|
||||
if app_instance and hasattr(app_instance, 'config'):
|
||||
config = app_instance.config
|
||||
else:
|
||||
config = self._get_minimal_config_for_permissions()
|
||||
except ImportError:
|
||||
config = self._get_minimal_config_for_permissions()
|
||||
|
||||
role = await permission_manager.get_effective_role(None, user_id, config)
|
||||
print(f"[DEBUG] get_user_role_enum_async: permission_manager returned role: {role.value if hasattr(role, 'value') else role}") # ОТЛАДКА
|
||||
return role
|
||||
except Exception as e:
|
||||
# Fallback к старому методу
|
||||
print(f"[DEBUG] get_user_role_enum_async: permission_manager failed ({e}), using fallback") # ОТЛАДКА
|
||||
role_str = self.get_user_role(user_id)
|
||||
try:
|
||||
role_enum = UserRole(role_str)
|
||||
print(f"[DEBUG] get_user_role_enum_async: fallback returned role: {role_enum.value}") # ОТЛАДКА
|
||||
return role_enum
|
||||
except ValueError:
|
||||
print(f"[DEBUG] get_user_role_enum_async: fallback failed, returning USER") # ОТЛАДКА
|
||||
return UserRole.USER # fallback
|
||||
|
||||
def get_user_role_enum(self, user_id: int) -> UserRole:
|
||||
"""
|
||||
Получение роли пользователя как enum UserRole (синхронная версия).
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
|
||||
Returns:
|
||||
Роль пользователя как enum
|
||||
"""
|
||||
# Создаем event loop для вызова асинхронного метода
|
||||
import asyncio
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
# Если loop уже запущен, создаем новую задачу
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(asyncio.run, self.get_user_role_enum_async(user_id))
|
||||
return future.result()
|
||||
else:
|
||||
return loop.run_until_complete(self.get_user_role_enum_async(user_id))
|
||||
except Exception as e:
|
||||
print(f"[ERROR] get_user_role_enum: failed to get async role ({e}), using fallback") # ОТЛАДКА
|
||||
role_str = self.get_user_role(user_id)
|
||||
try:
|
||||
return UserRole(role_str)
|
||||
except ValueError:
|
||||
return UserRole.USER # fallback
|
||||
|
||||
def _get_minimal_config_for_permissions(self):
|
||||
"""Создание минимального конфига для permission_manager"""
|
||||
class MinimalConfig:
|
||||
def __init__(self):
|
||||
self.bot_config = MinimalBotConfig()
|
||||
|
||||
class MinimalBotConfig:
|
||||
def __init__(self):
|
||||
self.super_admin_ids = []
|
||||
self.admin_ids = []
|
||||
self.moderator_ids = []
|
||||
|
||||
config = MinimalConfig()
|
||||
return config
|
||||
|
||||
def get_rank_progress(self, current_score: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Получение прогресса до следующего ранга.
|
||||
|
||||
Args:
|
||||
current_score: Текущие очки пользователя
|
||||
|
||||
Returns:
|
||||
Информация о прогрессе
|
||||
"""
|
||||
current_rank = self.calculate_rank(current_score)
|
||||
|
||||
# Находим следующий ранг
|
||||
current_index = self.rank_names.index(current_rank)
|
||||
next_index = min(current_index + 1, len(self.rank_names) - 1)
|
||||
|
||||
current_threshold = self.rank_thresholds[current_rank]
|
||||
next_threshold = self.rank_thresholds[self.rank_names[next_index]]
|
||||
|
||||
progress = current_score - current_threshold
|
||||
needed = next_threshold - current_threshold
|
||||
|
||||
return {
|
||||
'current_rank': current_rank,
|
||||
'next_rank': self.rank_names[next_index] if current_index < len(self.rank_names) - 1 else current_rank,
|
||||
'current_score': current_score,
|
||||
'progress': progress,
|
||||
'needed': needed,
|
||||
'percentage': (progress / needed * 100) if needed > 0 else 100
|
||||
}
|
||||
|
||||
async def _update_user_data(self, user_data: Dict, username: str, first_name: str, last_name: str) -> Dict:
|
||||
"""Обновление данных существующего пользователя"""
|
||||
try:
|
||||
update_data = {}
|
||||
|
||||
if username and user_data.get('username') != username:
|
||||
update_data['username'] = username
|
||||
|
||||
if first_name and user_data.get('first_name') != first_name:
|
||||
update_data['first_name'] = first_name
|
||||
|
||||
if last_name and user_data.get('last_name') != last_name:
|
||||
update_data['last_name'] = last_name
|
||||
|
||||
if update_data:
|
||||
self.logger.debug(f"Обновление данных пользователя {user_data['id']}: {update_data}")
|
||||
await self.user_repo.update_user(user_data['id'], update_data)
|
||||
self.logger.debug(f"Данные пользователя {user_data['id']} успешно обновлены")
|
||||
else:
|
||||
self.logger.debug(f"Для пользователя {user_data['id']} нет данных для обновления")
|
||||
|
||||
# Возвращаем актуальные данные пользователя - используем telegram_id вместо id
|
||||
telegram_id = user_data.get('telegram_id')
|
||||
if telegram_id:
|
||||
current_data = await self.user_repo.get_by_id_async(telegram_id)
|
||||
if current_data:
|
||||
return current_data
|
||||
else:
|
||||
self.logger.error(f"Не удалось получить обновленные данные пользователя telegram_id={telegram_id}")
|
||||
return None
|
||||
else:
|
||||
self.logger.error(f"telegram_id отсутствует в данных пользователя: {user_data}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Ошибка при обновлении пользователя {user_data.get('id', 'unknown')}: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
async def _create_new_user(self, user_id: int, username: str, first_name: str, last_name: str) -> Dict:
|
||||
"""Создание нового пользователя"""
|
||||
try:
|
||||
user_data = {
|
||||
'telegram_id': user_id,
|
||||
'username': username,
|
||||
'first_name': first_name or '',
|
||||
'last_name': last_name,
|
||||
'joined_date': datetime.now(),
|
||||
'last_activity': datetime.now()
|
||||
}
|
||||
|
||||
result = await self.user_repo._execute_query_async(
|
||||
"INSERT INTO users (telegram_id, username, first_name, last_name, joined_date, last_activity) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(user_data['telegram_id'], user_data.get('username'), user_data.get('first_name'), user_data.get('last_name'), user_data.get('joined_date'), user_data.get('last_activity'))
|
||||
)
|
||||
result = True
|
||||
|
||||
# Проверяем результат создания пользователя
|
||||
if result:
|
||||
# Получаем созданного пользователя для возврата полных данных
|
||||
return await self.user_repo.get_by_id(user_id)
|
||||
else:
|
||||
print(f"Ошибка: репозиторий вернул None при создании пользователя {user_id}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"Ошибка при создании пользователя {user_id}: {e}")
|
||||
return None
|
||||
|
||||
async def _check_rank_promotion(self, user_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Проверка повышения ранга пользователя"""
|
||||
user_data = await self.user_repo.get_by_id_async(user_id)
|
||||
if not user_data:
|
||||
return None
|
||||
|
||||
current_rank = user_data.get('rank', 'Новичок')
|
||||
new_rank = self.calculate_rank(user_data.get('reputation', 0))
|
||||
|
||||
if new_rank != current_rank:
|
||||
# Обновляем ранг в базе данных
|
||||
await self.user_repo.update_rank(user_id, new_rank)
|
||||
|
||||
return {
|
||||
'old_rank': current_rank,
|
||||
'new_rank': new_rank,
|
||||
'user_name': user_data.get('first_name', 'Пользователь'),
|
||||
'promoted': True
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
async def _get_user_statistics(self, user_id: int) -> Dict[str, Any]:
|
||||
"""Получение статистики пользователя"""
|
||||
message_count = await self.score_repo.get_message_count(user_id)
|
||||
total_score = await self.score_repo.get_total_score(user_id)
|
||||
days_active = await self.user_repo.get_days_active(user_id)
|
||||
warnings_count = await self.user_repo.get_warnings_count(user_id)
|
||||
|
||||
return {
|
||||
'message_count': message_count,
|
||||
'total_score': total_score,
|
||||
'days_active': days_active,
|
||||
'warnings_count': warnings_count
|
||||
}
|
||||
|
||||
async def _check_achievement_condition(self, profile: UserProfile, stats: Dict[str, Any],
|
||||
condition_type: str, condition_value: Any) -> bool:
|
||||
"""Проверка условия достижения"""
|
||||
if condition_type == 'score' and stats.get('total_score', 0) >= condition_value:
|
||||
return True
|
||||
elif condition_type == 'messages' and stats.get('message_count', 0) >= condition_value:
|
||||
return True
|
||||
elif condition_type == 'days_active' and stats.get('days_active', 0) >= condition_value:
|
||||
return True
|
||||
elif condition_type == 'warnings' and stats.get('warnings_count', 0) <= condition_value:
|
||||
return True
|
||||
elif condition_type == 'donations':
|
||||
# Для достижений за донаты проверяем общую сумму донатов
|
||||
total_donations = await self.get_total_donations(profile.user_id)
|
||||
self.logger.debug(f"Checking donation achievement: total_donations={total_donations}, condition_value={condition_value}")
|
||||
result = total_donations >= float(condition_value)
|
||||
self.logger.debug(f"Donation achievement condition result: {result}")
|
||||
return result
|
||||
|
||||
return False
|
||||
|
||||
def _map_to_profile(self, user_data: Dict) -> UserProfile:
|
||||
"""Преобразование данных из БД в профиль пользователя"""
|
||||
if not user_data:
|
||||
raise ValueError("Данные пользователя не могут быть None")
|
||||
|
||||
# Дополнительная проверка на наличие обязательных полей
|
||||
if 'id' not in user_data or user_data['id'] is None:
|
||||
raise ValueError(f"Обязательное поле 'id' отсутствует в данных пользователя: {user_data}")
|
||||
|
||||
# Преобразование строковых дат в datetime объекты
|
||||
joined_date = user_data.get('joined_date')
|
||||
last_activity = user_data.get('last_activity')
|
||||
|
||||
if isinstance(joined_date, str):
|
||||
try:
|
||||
joined_date = datetime.fromisoformat(joined_date.replace('Z', '+00:00'))
|
||||
except (ValueError, AttributeError):
|
||||
joined_date = None
|
||||
|
||||
if isinstance(last_activity, str):
|
||||
try:
|
||||
last_activity = datetime.fromisoformat(last_activity.replace('Z', '+00:00'))
|
||||
except (ValueError, AttributeError):
|
||||
last_activity = None
|
||||
|
||||
return UserProfile(
|
||||
user_id=user_data['id'],
|
||||
username=user_data.get('username'),
|
||||
first_name=user_data.get('first_name', ''),
|
||||
last_name=user_data.get('last_name'),
|
||||
reputation=user_data.get('reputation', 0),
|
||||
rank=user_data.get('rank', 'Новичок'),
|
||||
message_count=user_data.get('message_count', 0),
|
||||
joined_date=joined_date,
|
||||
last_activity=last_activity,
|
||||
warnings=user_data.get('warnings', 0)
|
||||
)
|
||||
|
||||
async def add_donation(self, user_id: int, amount: float) -> bool:
|
||||
"""
|
||||
Добавление доната пользователя.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
amount: Сумма доната
|
||||
|
||||
Returns:
|
||||
True если донат добавлен успешно
|
||||
"""
|
||||
self.logger.info(f"Начало добавления доната: user_id={user_id}, amount={amount}")
|
||||
|
||||
try:
|
||||
# Валидация данных
|
||||
self.logger.debug(f"Валидация входных данных для пользователя {user_id}")
|
||||
if not Validator.validate_user_id(user_id):
|
||||
self.logger.error(f"Неверный ID пользователя: {user_id}")
|
||||
raise ValidationError("Неверный ID пользователя")
|
||||
|
||||
if amount <= 0:
|
||||
self.logger.error(f"Неверная сумма доната: {amount} (должна быть положительной)")
|
||||
raise ValidationError("Сумма доната должна быть положительной")
|
||||
|
||||
self.logger.debug(f"Валидация прошла успешно для пользователя {user_id}")
|
||||
|
||||
# Создаем пользователя, если не существует
|
||||
self.logger.debug(f"Получение или создание профиля пользователя {user_id}")
|
||||
try:
|
||||
user_profile = await self.get_or_create_user(user_id)
|
||||
self.logger.debug(f"Результат get_or_create_user для {user_id}: {user_profile is not None}")
|
||||
if user_profile:
|
||||
self.logger.debug(f"Профиль пользователя {user_id}: user_id={user_profile.user_id}, first_name={user_profile.first_name}")
|
||||
else:
|
||||
self.logger.error(f"get_or_create_user вернул None для пользователя {user_id}")
|
||||
except Exception as profile_error:
|
||||
self.logger.error(f"Исключение в get_or_create_user для пользователя {user_id}: {profile_error}", exc_info=True)
|
||||
return False
|
||||
|
||||
if not user_profile:
|
||||
self.logger.error(f"Не удалось создать или получить профиль пользователя {user_id}")
|
||||
return False
|
||||
|
||||
self.logger.debug(f"Профиль пользователя {user_id} получен: {user_profile.user_id if user_profile else None}")
|
||||
|
||||
# Получаем текущий год для статистики
|
||||
current_year = datetime.now().year
|
||||
self.logger.debug(f"Текущий год для статистики: {current_year}")
|
||||
|
||||
# Начинаем транзакцию
|
||||
self.logger.debug("Начало транзакции для добавления доната")
|
||||
self.logger.debug("Проверка подключения к репозиториям перед транзакцией")
|
||||
if self.user_repo is None:
|
||||
self.logger.error("user_repo is None - невозможно начать транзакцию")
|
||||
return False
|
||||
if self.score_repo is None:
|
||||
self.logger.error("score_repo is None - невозможно начать транзакцию")
|
||||
return False
|
||||
|
||||
try:
|
||||
await self.user_repo.begin_transaction()
|
||||
await self.score_repo.begin_transaction()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Не удалось начать транзакцию: {e}")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Добавляем донат в базу данных
|
||||
self.logger.debug(f"Добавление доната в базу данных: user_id={user_id}, amount={amount}, year={current_year}")
|
||||
self.logger.debug(f"Вызов user_repo.add_donation с параметрами: user_id={user_id}, amount={amount}, year={current_year}")
|
||||
success = await self.user_repo.add_donation(user_id, amount, current_year)
|
||||
self.logger.debug(f"Результат user_repo.add_donation: {success}")
|
||||
|
||||
if success:
|
||||
self.logger.info(f"Донат успешно добавлен в базу данных для пользователя {user_id}")
|
||||
|
||||
# Начисляем очки за донат (1 очко за каждые 100 рублей)
|
||||
points = int(amount // 100)
|
||||
self.logger.debug(f"Расчет очков за донат: amount={amount}, points={points}")
|
||||
if points > 0:
|
||||
self.logger.debug(f"Начисление {points} очков пользователю {user_id}")
|
||||
print(f"[DEBUG] UserService.add_donation: calling score_repo.update_score({user_id}, {points})")
|
||||
score_success = await self.score_repo.update_score(user_id, points)
|
||||
self.logger.debug(f"Результат update_score: {score_success}")
|
||||
if score_success:
|
||||
self.logger.info(f"Начислено {points} очков пользователю {user_id}")
|
||||
else:
|
||||
self.logger.warning(f"Не удалось начислить очки пользователю {user_id}")
|
||||
|
||||
# Проверяем достижения
|
||||
self.logger.debug(f"Проверка достижений для пользователя {user_id}")
|
||||
await self.check_and_unlock_achievements(user_id)
|
||||
self.logger.debug(f"Проверка достижений завершена для пользователя {user_id}")
|
||||
|
||||
# Подтверждаем транзакцию
|
||||
await self.user_repo.commit_transaction()
|
||||
await self.score_repo.commit_transaction()
|
||||
self.logger.info(f"Донат успешно обработан для пользователя {user_id}")
|
||||
else:
|
||||
self.logger.error(f"Не удалось добавить донат в базу данных для пользователя {user_id}")
|
||||
# Откатываем транзакцию
|
||||
await self.user_repo.rollback_transaction()
|
||||
await self.score_repo.rollback_transaction()
|
||||
return False
|
||||
|
||||
return success
|
||||
|
||||
except Exception as inner_e:
|
||||
# Откатываем транзакцию при ошибке
|
||||
self.logger.error(f"Ошибка во время транзакции, выполняем откат: {inner_e}")
|
||||
try:
|
||||
await self.user_repo.rollback_transaction()
|
||||
await self.score_repo.rollback_transaction()
|
||||
except Exception as rollback_e:
|
||||
self.logger.error(f"Ошибка при откате транзакции: {rollback_e}")
|
||||
raise inner_e
|
||||
|
||||
except ValidationError as e:
|
||||
self.logger.warning(f"Ошибка валидации при добавлении доната для пользователя {user_id}: {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
self.logger.error(f"Неожиданная ошибка при добавлении доната для пользователя {user_id}: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
async def get_total_donations(self, user_id: int, year: int = None) -> float:
|
||||
"""
|
||||
Получение общей суммы донатов пользователя.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
year: Год для фильтрации (если None, то все года)
|
||||
|
||||
Returns:
|
||||
Общая сумма донатов
|
||||
"""
|
||||
result = await self.user_repo._fetch_one_async(
|
||||
"SELECT COALESCE(SUM(amount), 0) as total FROM donations WHERE user_id = (SELECT id FROM users WHERE telegram_id = ?)" + (" AND year = ?" if year else ""),
|
||||
(user_id, year) if year else (user_id,)
|
||||
)
|
||||
return result['total'] if result else 0.0
|
||||
@@ -0,0 +1,200 @@
|
||||
"""
|
||||
Сервис управления приветственными сообщениями.
|
||||
Отвечает за генерацию приветствий в зависимости от роли пользователя.
|
||||
"""
|
||||
|
||||
from typing import Dict, Optional
|
||||
from core.permissions import UserRole
|
||||
from core.exceptions import ValidationError
|
||||
|
||||
|
||||
class WelcomeService:
|
||||
"""
|
||||
Сервис для генерации приветственных сообщений на основе роли пользователя.
|
||||
|
||||
Отвечает за:
|
||||
- Генерацию приветствий для разных ролей
|
||||
- Форматирование сообщений с персональными данными
|
||||
- Валидацию входных данных
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Инициализация сервиса приветствий"""
|
||||
self.welcome_messages = self._initialize_welcome_messages()
|
||||
|
||||
def _initialize_welcome_messages(self) -> Dict[str, str]:
|
||||
"""
|
||||
Инициализация словаря приветственных сообщений для разных ролей.
|
||||
На основе требований из welcome_messages_proposals.md
|
||||
"""
|
||||
return {
|
||||
UserRole.USER.value: """👋 Добро пожаловать в наш бот!
|
||||
|
||||
Здесь вы можете:
|
||||
• 📊 Проверить свой ранг: /rank
|
||||
• 🏆 Посмотреть таблицу лидеров: /leaderboard
|
||||
• 🎯 Играть в игры: /play_game
|
||||
• 🌦️ Узнать погоду: /weather
|
||||
• 📰 Прочитать новости: /news
|
||||
• 💸 Поддержать проект: /donate
|
||||
|
||||
Для справки используйте /help""",
|
||||
|
||||
UserRole.MODERATOR.value: """🛡️ Добро пожаловать, модератор!
|
||||
|
||||
Ваши права:
|
||||
• ⚠️ Предупреждения: /warn
|
||||
• 🔇 Заглушка: /mute /unmute
|
||||
• 📊 Статистика модерации
|
||||
|
||||
Все базовые команды также доступны: /rank, /play_game, /weather и др.""",
|
||||
|
||||
UserRole.ADMIN.value: """👑 Добро пожаловать, администратор!
|
||||
|
||||
У вас полный доступ к управлению ботом:
|
||||
• 👥 Модерация: /warn, /mute, /ban, /kick
|
||||
• 📊 Статистика: /admin_stats, /export_stats
|
||||
• 📝 Планирование: /schedule_post, /list_posts
|
||||
• 🛠️ Система: /admin_errors, /report_error
|
||||
|
||||
Все пользовательские команды также доступны.
|
||||
Используйте /help для полного списка.""",
|
||||
|
||||
UserRole.SUPER_ADMIN.value: """🔧 Супер-администратор активирован!
|
||||
|
||||
Ваши возможности:
|
||||
• ⚠️ Модерация пользователей: /warn /mute /ban
|
||||
• 📈 Просмотр статистики: /admin_stats
|
||||
• 📅 Управление публикациями: /schedule_post
|
||||
• 🔍 Работа с ошибками: /admin_errors
|
||||
• 📤 Экспорт данных: /export_stats
|
||||
|
||||
Обычные команды: /rank /leaderboard /play_game и др."""
|
||||
}
|
||||
|
||||
def get_welcome_message(self, user_role: UserRole, user_name: Optional[str] = None) -> str:
|
||||
"""
|
||||
Получение приветственного сообщения для указанной роли.
|
||||
|
||||
Args:
|
||||
user_role: Роль пользователя
|
||||
user_name: Имя пользователя (опционально)
|
||||
|
||||
Returns:
|
||||
Отформатированное приветственное сообщение
|
||||
|
||||
Raises:
|
||||
ValidationError: Если роль не поддерживается
|
||||
"""
|
||||
if not isinstance(user_role, UserRole):
|
||||
raise ValidationError(f"Неверная роль пользователя: {user_role}")
|
||||
|
||||
# Получаем базовое сообщение для роли
|
||||
base_message = self.welcome_messages.get(user_role.value)
|
||||
|
||||
if not base_message:
|
||||
# Fallback на сообщение для обычного пользователя
|
||||
base_message = self.welcome_messages[UserRole.USER.value]
|
||||
|
||||
# Форматируем с именем пользователя, если указано
|
||||
if user_name:
|
||||
# Добавляем персональное приветствие в начало
|
||||
personal_greeting = f"👋 Привет, {user_name}!\n\n"
|
||||
return personal_greeting + base_message
|
||||
else:
|
||||
# Безымянное приветствие
|
||||
return "👋 Привет!\n\n" + base_message
|
||||
|
||||
def get_available_commands_for_role(self, user_role: UserRole) -> Dict[str, list]:
|
||||
"""
|
||||
Получение доступных команд для указанной роли.
|
||||
|
||||
Args:
|
||||
user_role: Роль пользователя
|
||||
|
||||
Returns:
|
||||
Словарь с доступными командами по категориям
|
||||
"""
|
||||
if not isinstance(user_role, UserRole):
|
||||
raise ValidationError(f"Неверная роль пользователя: {user_role}")
|
||||
|
||||
# Базовые команды для всех пользователей
|
||||
base_commands = {
|
||||
'user_commands': [
|
||||
'/start - Приветствие и начало работы',
|
||||
'/help - Справка по командам',
|
||||
'/rank - Просмотр личного ранга и прогресса',
|
||||
'/leaderboard - Таблица лидеров по очкам',
|
||||
'/info - Информация о боте',
|
||||
'/weather - Погода',
|
||||
'/news - Новости',
|
||||
'/translate - Перевод текста',
|
||||
'/donate - Поддержать проект'
|
||||
],
|
||||
'games': [
|
||||
'/play_game - Выбор игры',
|
||||
'/rock_paper_scissors - Камень-ножницы-бумага',
|
||||
'/tic_tac_toe - Крестики-нолики',
|
||||
'/quiz - Викторина',
|
||||
'/battleship - Морской бой',
|
||||
'/game_2048 - Игра 2048',
|
||||
'/tetris - Тетрис',
|
||||
'/snake - Змейка'
|
||||
]
|
||||
}
|
||||
|
||||
# Добавляем команды в зависимости от роли
|
||||
if user_role in [UserRole.MODERATOR, UserRole.ADMIN, UserRole.SUPER_ADMIN]:
|
||||
base_commands['moderation'] = [
|
||||
'/warn - Выдать предупреждение',
|
||||
'/mute - Заглушить пользователя',
|
||||
'/unmute - Снять заглушку'
|
||||
]
|
||||
|
||||
if user_role in [UserRole.ADMIN, UserRole.SUPER_ADMIN]:
|
||||
base_commands['admin'] = [
|
||||
'/ban - Заблокировать пользователя',
|
||||
'/unban - Разблокировать пользователя',
|
||||
'/kick - Выгнать из чата',
|
||||
'/admin_stats - Статистика админа',
|
||||
'/schedule_post - Запланировать публикацию',
|
||||
'/list_posts - Список запланированных постов',
|
||||
'/delete_post - Удалить пост',
|
||||
'/publish_now - Опубликовать немедленно',
|
||||
'/report_error - Сообщить об ошибке',
|
||||
'/admin_errors - Управление ошибками',
|
||||
'/export_stats - Экспорт статистики'
|
||||
]
|
||||
|
||||
if user_role == UserRole.SUPER_ADMIN:
|
||||
base_commands['super_admin'] = [
|
||||
'/admin_system - Системные настройки',
|
||||
'/admin_logs - Системные логи',
|
||||
'/admin_restart - Управление перезапуском'
|
||||
]
|
||||
|
||||
return base_commands
|
||||
|
||||
def validate_user_name(self, name: str) -> bool:
|
||||
"""
|
||||
Валидация имени пользователя.
|
||||
|
||||
Args:
|
||||
name: Имя для проверки
|
||||
|
||||
Returns:
|
||||
True если имя корректное
|
||||
"""
|
||||
if not name or not isinstance(name, str):
|
||||
return False
|
||||
|
||||
# Проверяем длину
|
||||
if len(name.strip()) < 1 or len(name) > 100:
|
||||
return False
|
||||
|
||||
# Проверяем на запрещенные символы (простая проверка)
|
||||
forbidden_chars = ['<', '>', '&', '\n', '\r']
|
||||
if any(char in name for char in forbidden_chars):
|
||||
return False
|
||||
|
||||
return True
|
||||
Reference in New Issue
Block a user