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,16 @@
|
||||
"""
|
||||
Ядро приложения телеграм-бота.
|
||||
Содержит базовые компоненты и настройки.
|
||||
"""
|
||||
|
||||
from .application import Application
|
||||
from .config import Config
|
||||
from .exceptions import BotException, DatabaseError, ValidationError
|
||||
from .monitoring import MetricsCollector, structured_logger, measure_time, error_handler
|
||||
from .alerts import AlertManager
|
||||
|
||||
__all__ = [
|
||||
'Application', 'Config', 'BotException', 'DatabaseError', 'ValidationError',
|
||||
'MetricsCollector', 'structured_logger', 'measure_time', 'error_handler',
|
||||
'AlertManager'
|
||||
]
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
"""
|
||||
Система алертов для телеграм-бота.
|
||||
Отвечает за отправку уведомлений о критических событиях.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Callable
|
||||
from datetime import datetime, timedelta
|
||||
import requests
|
||||
from .config import Config
|
||||
from .monitoring import MetricsCollector
|
||||
|
||||
|
||||
class AlertManager:
|
||||
"""Менеджер алертов для мониторинга системы"""
|
||||
|
||||
def __init__(self, config: Config, metrics: MetricsCollector):
|
||||
self.config = config
|
||||
self.metrics = metrics
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
# Настройки алертов
|
||||
self.alert_rules = {
|
||||
'high_error_rate': {
|
||||
'enabled': True,
|
||||
'threshold': 10, # ошибок в минуту
|
||||
'window': 60, # секунды
|
||||
'cooldown': 300, # 5 минут между алертами
|
||||
'last_alert': None
|
||||
},
|
||||
'bot_down': {
|
||||
'enabled': True,
|
||||
'cooldown': 60,
|
||||
'last_alert': None
|
||||
},
|
||||
'high_response_time': {
|
||||
'enabled': True,
|
||||
'threshold': 5.0, # секунды
|
||||
'cooldown': 300,
|
||||
'last_alert': None
|
||||
},
|
||||
'database_connection_failed': {
|
||||
'enabled': True,
|
||||
'cooldown': 60,
|
||||
'last_alert': None
|
||||
}
|
||||
}
|
||||
|
||||
# Обработчики алертов
|
||||
self.alert_handlers: List[Callable] = [
|
||||
self._send_telegram_alert,
|
||||
self._send_email_alert,
|
||||
self._send_webhook_alert
|
||||
]
|
||||
|
||||
async def check_alerts(self):
|
||||
"""Проверка и отправка алертов"""
|
||||
try:
|
||||
# Проверяем правила алертов
|
||||
await self._check_error_rate_alert()
|
||||
await self._check_bot_status_alert()
|
||||
await self._check_response_time_alert()
|
||||
await self._check_database_alert()
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error checking alerts: {e}")
|
||||
|
||||
async def _check_error_rate_alert(self):
|
||||
"""Проверка алерта на высокую частоту ошибок"""
|
||||
rule = self.alert_rules['high_error_rate']
|
||||
|
||||
if not rule['enabled']:
|
||||
return
|
||||
|
||||
# Получаем количество ошибок за последние N секунд
|
||||
# В реальной реализации можно использовать Prometheus метрики
|
||||
current_time = datetime.now()
|
||||
if (rule['last_alert'] and
|
||||
current_time - rule['last_alert'] < timedelta(seconds=rule['cooldown'])):
|
||||
return
|
||||
|
||||
# Имитация проверки ошибок (в реальности использовать реальные метрики)
|
||||
error_count = self._get_error_count_last_minute()
|
||||
|
||||
if error_count > rule['threshold']:
|
||||
await self._trigger_alert(
|
||||
'high_error_rate',
|
||||
f"Высокая частота ошибок: {error_count} ошибок в минуту (порог: {rule['threshold']})",
|
||||
{'error_count': error_count, 'threshold': rule['threshold']}
|
||||
)
|
||||
rule['last_alert'] = current_time
|
||||
|
||||
async def _check_bot_status_alert(self):
|
||||
"""Проверка алерта о статусе бота"""
|
||||
rule = self.alert_rules['bot_down']
|
||||
|
||||
if not rule['enabled']:
|
||||
return
|
||||
|
||||
current_time = datetime.now()
|
||||
if (rule['last_alert'] and
|
||||
current_time - rule['last_alert'] < timedelta(seconds=rule['cooldown'])):
|
||||
return
|
||||
|
||||
# Проверяем статус бота
|
||||
if self.metrics.bot_status._value.get() == 0: # Бот остановлен
|
||||
await self._trigger_alert(
|
||||
'bot_down',
|
||||
"Бот остановлен!",
|
||||
{'status': 'down'}
|
||||
)
|
||||
rule['last_alert'] = current_time
|
||||
|
||||
async def _check_response_time_alert(self):
|
||||
"""Проверка алерта на высокое время отклика"""
|
||||
rule = self.alert_rules['high_response_time']
|
||||
|
||||
if not rule['enabled']:
|
||||
return
|
||||
|
||||
current_time = datetime.now()
|
||||
if (rule['last_alert'] and
|
||||
current_time - rule['last_alert'] < timedelta(seconds=rule['cooldown'])):
|
||||
return
|
||||
|
||||
# Имитация проверки времени отклика
|
||||
avg_response_time = self._get_average_response_time()
|
||||
|
||||
if avg_response_time > rule['threshold']:
|
||||
await self._trigger_alert(
|
||||
'high_response_time',
|
||||
f"Высокое время отклика: {avg_response_time:.2f}с (порог: {rule['threshold']}с)",
|
||||
{'response_time': avg_response_time, 'threshold': rule['threshold']}
|
||||
)
|
||||
rule['last_alert'] = current_time
|
||||
|
||||
async def _check_database_alert(self):
|
||||
"""Проверка алерта о проблемах с базой данных"""
|
||||
rule = self.alert_rules['database_connection_failed']
|
||||
|
||||
if not rule['enabled']:
|
||||
return
|
||||
|
||||
current_time = datetime.now()
|
||||
if (rule['last_alert'] and
|
||||
current_time - rule['last_alert'] < timedelta(seconds=rule['cooldown'])):
|
||||
return
|
||||
|
||||
# Имитация проверки подключения к БД
|
||||
if not self._check_database_connection():
|
||||
await self._trigger_alert(
|
||||
'database_connection_failed',
|
||||
"Не удалось подключиться к базе данных!",
|
||||
{'status': 'connection_failed'}
|
||||
)
|
||||
rule['last_alert'] = current_time
|
||||
|
||||
async def _trigger_alert(self, alert_type: str, message: str, extra_data: Dict = None):
|
||||
"""Отправка алерта через все обработчики"""
|
||||
self.logger.warning(f"ALERT [{alert_type}]: {message}")
|
||||
|
||||
# Отправляем алерт через все обработчики
|
||||
for handler in self.alert_handlers:
|
||||
try:
|
||||
await handler(alert_type, message, extra_data or {})
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in alert handler {handler.__name__}: {e}")
|
||||
|
||||
async def _send_telegram_alert(self, alert_type: str, message: str, extra_data: Dict):
|
||||
"""Отправка алерта в Telegram"""
|
||||
if not self.config.bot_config.enable_developer_notifications:
|
||||
return
|
||||
|
||||
try:
|
||||
developer_chat_id = self.config.bot_config.developer_chat_id
|
||||
if not developer_chat_id:
|
||||
return
|
||||
|
||||
# Добавляем эмодзи для типа алерта
|
||||
alert_emojis = {
|
||||
'high_error_rate': '🚨',
|
||||
'bot_down': '💥',
|
||||
'high_response_time': '🐌',
|
||||
'database_connection_failed': '🗄️'
|
||||
}
|
||||
|
||||
emoji = alert_emojis.get(alert_type, '⚠️')
|
||||
alert_message = f"{emoji} <b>АЛЕРТ: {alert_type.replace('_', ' ').title()}</b>\n\n{message}"
|
||||
|
||||
# Добавляем время
|
||||
alert_message += f"\n\n⏰ Время: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
|
||||
|
||||
# Добавляем дополнительные данные
|
||||
if extra_data:
|
||||
alert_message += "\n\n📊 Дополнительно:"
|
||||
for key, value in extra_data.items():
|
||||
alert_message += f"\n• {key}: {value}"
|
||||
|
||||
# Здесь нужно отправить сообщение через Telegram API
|
||||
# В реальной реализации использовать уже созданный бот
|
||||
self.logger.info(f"Telegram alert sent: {alert_message[:100]}...")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to send Telegram alert: {e}")
|
||||
|
||||
async def _send_email_alert(self, alert_type: str, message: str, extra_data: Dict):
|
||||
"""Отправка алерта по email"""
|
||||
# Заглушка для отправки email
|
||||
# В реальной реализации использовать SMTP или сервис вроде SendGrid
|
||||
self.logger.info(f"Email alert for {alert_type}: {message}")
|
||||
|
||||
async def _send_webhook_alert(self, alert_type: str, message: str, extra_data: Dict):
|
||||
"""Отправка алерта через webhook"""
|
||||
# Заглушка для webhook
|
||||
# В реальной реализации отправлять POST запрос на webhook URL
|
||||
self.logger.info(f"Webhook alert for {alert_type}: {message}")
|
||||
|
||||
def _get_error_count_last_minute(self) -> int:
|
||||
"""Получение количества ошибок за последнюю минуту"""
|
||||
# Имитация: в реальности использовать Prometheus метрики
|
||||
return 5 # Заглушка
|
||||
|
||||
def _get_average_response_time(self) -> float:
|
||||
"""Получение среднего времени отклика"""
|
||||
# Имитация: в реальности использовать Prometheus метрики
|
||||
return 2.5 # Заглушка
|
||||
|
||||
def _check_database_connection(self) -> bool:
|
||||
"""Проверка подключения к базе данных"""
|
||||
# Имитация: в реальности проверить реальное подключение
|
||||
return True # Заглушка
|
||||
|
||||
def add_alert_rule(self, name: str, rule: Dict):
|
||||
"""Добавление нового правила алерта"""
|
||||
self.alert_rules[name] = rule
|
||||
self.logger.info(f"Added alert rule: {name}")
|
||||
|
||||
def disable_alert_rule(self, name: str):
|
||||
"""Отключение правила алерта"""
|
||||
if name in self.alert_rules:
|
||||
self.alert_rules[name]['enabled'] = False
|
||||
self.logger.info(f"Disabled alert rule: {name}")
|
||||
|
||||
def enable_alert_rule(self, name: str):
|
||||
"""Включение правила алерта"""
|
||||
if name in self.alert_rules:
|
||||
self.alert_rules[name]['enabled'] = True
|
||||
self.logger.info(f"Enabled alert rule: {name}")
|
||||
@@ -0,0 +1,985 @@
|
||||
"""
|
||||
Основной класс приложения телеграм-бота.
|
||||
|
||||
Application - центральный компонент системы, отвечающий за:
|
||||
- Инициализацию всех компонентов (сервисы, репозитории, маршрутизаторы)
|
||||
- Настройку обработчиков команд, сообщений и callback'ов
|
||||
- Управление жизненным циклом бота (запуск/остановка)
|
||||
- Обработку ошибок и мониторинг
|
||||
- Интеграцию с платежными сервисами и уведомлениями
|
||||
# Глобальная переменная для доступа к экземпляру приложения
|
||||
app_instance = None
|
||||
|
||||
Класс поддерживает гибкую настройку логирования через параметр log_level:
|
||||
- ERROR: только критические ошибки
|
||||
- WARNING: ошибки + предупреждения
|
||||
- INFO: ошибки + предупреждения + информационные сообщения (по умолчанию)
|
||||
|
||||
Пример использования:
|
||||
app = Application(log_level='WARNING')
|
||||
app.run()
|
||||
"""
|
||||
|
||||
import logging
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Dict, List, Optional, Any
|
||||
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from telegram.ext import Application as TelegramApplication, ContextTypes
|
||||
from telegram.ext import CommandHandler, MessageHandler, CallbackQueryHandler, filters
|
||||
|
||||
# Основные импорты
|
||||
import logging
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
# Импорты Telegram
|
||||
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from telegram.ext import Application as TelegramApplication, ContextTypes
|
||||
from telegram.ext import CommandHandler, MessageHandler, CallbackQueryHandler, filters
|
||||
|
||||
# Импорты из текущего пакета
|
||||
from .config import Config
|
||||
from .exceptions import BotException, ConfigurationError
|
||||
from .monitoring import MetricsCollector, structured_logger, measure_time, error_handler
|
||||
from .alerts import AlertManager
|
||||
from .single_instance import check_single_instance
|
||||
|
||||
# Репозитории
|
||||
from database.repository import UserRepository, ScoreRepository, ErrorRepository
|
||||
|
||||
# Сервисы
|
||||
from services.welcome_service import WelcomeService
|
||||
|
||||
|
||||
class Application:
|
||||
"""
|
||||
Основной класс приложения телеграм-бота.
|
||||
|
||||
Архитектура:
|
||||
- Использует композицию для управления зависимостями (сервисы, репозитории)
|
||||
- Поддерживает две системы маршрутизации: старую и новую (unified_router)
|
||||
- Обеспечивает graceful shutdown и очистку ресурсов
|
||||
- Интегрируется с системами мониторинга и алертинга
|
||||
|
||||
Атрибуты:
|
||||
config: Конфигурация приложения
|
||||
logger: Логгер с настраиваемым уровнем логирования
|
||||
user_service: Сервис управления пользователями
|
||||
game_service: Сервис игровых механик
|
||||
donation_service: Сервис платежей (опционально)
|
||||
telegram_app: Экземпляр Telegram Application
|
||||
unified_router: Новая система маршрутизации (опционально)
|
||||
|
||||
Методы:
|
||||
run(): Запуск бота с polling
|
||||
stop(): Остановка бота
|
||||
get_status(): Получение статуса приложения
|
||||
"""
|
||||
|
||||
def __init__(self, config_path: Optional[str] = None, log_level: Optional[str] = None):
|
||||
"""
|
||||
Инициализация приложения.
|
||||
|
||||
Args:
|
||||
config_path: Путь к файлу конфигурации
|
||||
log_level: Уровень логирования (ERROR, WARNING, INFO, DEBUG). Если None, используется из конфигурации
|
||||
"""
|
||||
self.config = Config(config_path)
|
||||
# Устанавливаем уровень логирования
|
||||
self.log_level = log_level or self.config.get_log_level()
|
||||
self.logger = structured_logger(__name__, self.config)
|
||||
# Настраиваем уровень логирования через стандартный механизм
|
||||
self.logger.setLevel(self._get_log_level_value())
|
||||
self.logger.info("Logger level set to: %s", self.log_level)
|
||||
|
||||
# Инициализируем мониторинг
|
||||
self.metrics = MetricsCollector(self.config)
|
||||
self.logger.info("Monitoring system initialized")
|
||||
|
||||
# Инициализируем систему алертов
|
||||
self.alert_manager = AlertManager(self.config, self.metrics)
|
||||
self.logger.info("Alert system initialized")
|
||||
|
||||
# Инициализируем репозитории
|
||||
self.user_repo = UserRepository(self.config.get_database_url())
|
||||
self.score_repo = ScoreRepository(self.config.get_database_url())
|
||||
self.error_repo = ErrorRepository(self.config.get_database_url())
|
||||
|
||||
# Импорт сервисов локально для избежания циклических импортов
|
||||
from services import UserService, GameService, ModerationService
|
||||
|
||||
# Инициализируем сервисы
|
||||
# Создаем RoleService для управления ролями
|
||||
from services.role_service import RoleService
|
||||
self.role_service = RoleService(self.config.get_database_url())
|
||||
|
||||
self.user_service = UserService(self.user_repo, self.score_repo, self.role_service)
|
||||
self.game_service = GameService(self.user_repo, self.score_repo)
|
||||
self.moderation_service = ModerationService(self.user_repo, self.score_repo)
|
||||
self.welcome_service = WelcomeService()
|
||||
|
||||
# Инициализируем платежные сервисы (отключены для совместимости)
|
||||
# self._initialize_payment_services()
|
||||
self.donation_service = None
|
||||
self.notification_service = None
|
||||
self.trigger_service = None
|
||||
|
||||
# Инициализируем новую систему маршрутизации
|
||||
self.logger.debug("Initializing unified_router...")
|
||||
self._initialize_unified_router()
|
||||
self.logger.debug(f"Unified router initialized: hasattr={hasattr(self, 'unified_router')}, value={getattr(self, 'unified_router', 'NOT_SET')}")
|
||||
|
||||
# Убеждаемся что unified_router создан
|
||||
if not hasattr(self, 'unified_router') or self.unified_router is None:
|
||||
self.logger.warning("unified_router не инициализирован, создаем fallback")
|
||||
self.unified_router = None # fallback to None for safety
|
||||
|
||||
# Инициализируем обработчики (для обратной совместимости)
|
||||
self.handlers = self._initialize_handlers()
|
||||
|
||||
# Сохраняем ссылку на экземпляр для доступа из других модулей
|
||||
global app_instance
|
||||
app_instance = self
|
||||
|
||||
# Создаем Telegram Application
|
||||
self.telegram_app = TelegramApplication.builder().token(
|
||||
self.config.bot_config.token
|
||||
).build()
|
||||
|
||||
# Настраиваем обработчики
|
||||
self._setup_handlers()
|
||||
|
||||
# Настраиваем новую систему маршрутизации
|
||||
self._setup_unified_router()
|
||||
|
||||
# Запускаем сервер метрик
|
||||
self.metrics.start_metrics_server()
|
||||
|
||||
self.logger.info("Приложение успешно инициализировано")
|
||||
self.logger.info("Новая система маршрутизации инициализирована")
|
||||
|
||||
def _get_log_level_value(self) -> int:
|
||||
"""Получение числового значения уровня логирования"""
|
||||
log_level_map = {
|
||||
'ERROR': logging.ERROR,
|
||||
'WARNING': logging.WARNING,
|
||||
'INFO': logging.INFO,
|
||||
'DEBUG': logging.DEBUG
|
||||
}
|
||||
return log_level_map.get(self.log_level.upper(), logging.INFO)
|
||||
|
||||
def _initialize_handlers(self) -> Dict[str, 'BaseHandler']:
|
||||
"""Инициализация всех обработчиков"""
|
||||
handlers = {}
|
||||
|
||||
# Импорт обработчиков локально для избежания циклических импортов
|
||||
from handlers import BaseHandler, UserHandlers, GameHandlers, AdminHandlers, ModerationHandlers
|
||||
self.logger.debug("_initialize_handlers - imports successful")
|
||||
|
||||
try:
|
||||
handlers['user'] = UserHandlers(self.config, self.metrics, self.user_service, self.error_repo)
|
||||
handlers['game'] = GameHandlers(self.config, self.metrics, self.game_service)
|
||||
handlers['moderation'] = ModerationHandlers(self.config, self.metrics, self.user_service, self.moderation_service)
|
||||
handlers['admin'] = AdminHandlers(self.config, self.metrics, self.user_service, self.moderation_service)
|
||||
|
||||
# Инициализируем PaymentHandler если доступны сервисы платежей
|
||||
if hasattr(self, 'donation_service') and self.donation_service:
|
||||
from handlers import PaymentHandler
|
||||
handlers['payment'] = PaymentHandler(self.donation_service)
|
||||
self.logger.info("PaymentHandler инициализирован")
|
||||
|
||||
self.logger.info(f"Инициализировано {len(handlers)} обработчиков")
|
||||
return handlers
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Ошибка инициализации обработчиков: {e}")
|
||||
raise ConfigurationError(f"Не удалось инициализировать обработчики: {e}")
|
||||
|
||||
def _initialize_unified_router(self):
|
||||
"""Инициализация новой системы маршрутизации"""
|
||||
try:
|
||||
from .command_router import create_command_router
|
||||
from .message_router import create_message_router
|
||||
from .menu_manager import create_menu_manager
|
||||
from .permissions import permission_manager
|
||||
from utils.formatters import KeyboardFormatter
|
||||
|
||||
# Создаем компоненты новой системы
|
||||
self.command_router = create_command_router(self.config, self.metrics)
|
||||
self.message_router = create_message_router()
|
||||
self.menu_manager = create_menu_manager(
|
||||
permission_manager, KeyboardFormatter()
|
||||
)
|
||||
|
||||
# Создаем объединенный маршрутизатор
|
||||
from .unified_router import create_unified_router
|
||||
self.unified_router = create_unified_router(
|
||||
self.command_router, self.message_router, self.menu_manager
|
||||
)
|
||||
|
||||
except NameError as e:
|
||||
if 'permission_manager' in str(e):
|
||||
self.logger.error("Ошибка импорта permission_manager. Проверьте permissions.py")
|
||||
raise ConfigurationError("permission_manager не найден. Проверьте импорты в permissions.py")
|
||||
else:
|
||||
raise
|
||||
except Exception as e:
|
||||
self.logger.error(f"Ошибка при инициализации компонентов маршрутизации: {e}")
|
||||
# Продолжаем без новой системы маршрутизации
|
||||
self.command_router = None
|
||||
self.message_router = None
|
||||
self.menu_manager = None
|
||||
self.unified_router = None
|
||||
# Не вызываем raise, чтобы инициализация продолжилась
|
||||
self.logger.warning("Продолжаем инициализацию без новой системы маршрутизации")
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Ошибка инициализации новой системы маршрутизации: {e}")
|
||||
# Продолжаем без unified_router, но создаем его как None для совместимости
|
||||
self.unified_router = None
|
||||
# Не вызываем raise, чтобы инициализация продолжилась
|
||||
self.logger.warning("Продолжаем инициализацию без unified_router")
|
||||
return
|
||||
|
||||
def _setup_unified_router(self):
|
||||
"""Настройка обработчиков новой системы маршрутизации"""
|
||||
if not self.command_router:
|
||||
self.logger.info("CommandRouter не инициализирован, пропускаем настройку unified router")
|
||||
return
|
||||
|
||||
try:
|
||||
# Регистрируем обработчики команд через CommandRouter
|
||||
self._register_command_handlers()
|
||||
|
||||
# Регистрируем обработчики сообщений через MessageTypeRouter
|
||||
self._register_message_handlers()
|
||||
|
||||
# Регистрируем обработчики callback'ов
|
||||
self._register_callback_handlers()
|
||||
|
||||
self.logger.info("Новая система маршрутизации настроена")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Ошибка настройки новой системы маршрутизации: {e}")
|
||||
raise
|
||||
|
||||
def _register_command_handlers(self):
|
||||
"""Регистрация обработчиков команд"""
|
||||
if not self.command_router:
|
||||
return
|
||||
|
||||
try:
|
||||
self.logger.debug("_register_command_handlers - imports successful")
|
||||
|
||||
# Список типов обработчиков для регистрации
|
||||
handler_types = [
|
||||
('user', 'UserHandlers', [self.config, self.metrics, self.user_service, self.error_repo]),
|
||||
('game', 'GameHandlers', [self.config, self.metrics, self.game_service]),
|
||||
('moderation', 'ModerationHandlers', [self.config, self.metrics, self.user_service, self.moderation_service]),
|
||||
('admin', 'AdminHandlers', [self.config, self.metrics, self.user_service, self.moderation_service])
|
||||
]
|
||||
|
||||
for handler_name, class_name, init_args in handler_types:
|
||||
try:
|
||||
# Импортируем класс обработчика
|
||||
from handlers import UserHandlers, GameHandlers, AdminHandlers, ModerationHandlers
|
||||
handler_class = locals()[class_name]
|
||||
|
||||
# Создаем экземпляр обработчика
|
||||
handler_instance = handler_class(*init_args)
|
||||
|
||||
# Регистрируем все команды обработчика
|
||||
for cmd, handler_func in handler_instance.get_command_handlers().items():
|
||||
self.command_router.register_command_handler(cmd, handler_func)
|
||||
|
||||
self.logger.debug(f"Зарегистрированы команды {handler_name}: {len(handler_instance.get_command_handlers())}")
|
||||
|
||||
except ImportError as e:
|
||||
self.logger.warning(f"Не удалось импортировать {class_name}: {e}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Ошибка при регистрации команд {handler_name}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Ошибка при регистрации командных обработчиков: {e}")
|
||||
raise
|
||||
|
||||
def _register_message_handlers(self):
|
||||
"""Регистрация обработчиков сообщений"""
|
||||
if not self.message_router:
|
||||
return
|
||||
|
||||
try:
|
||||
self.logger.debug("_register_message_handlers - imports successful")
|
||||
|
||||
# Создаем обработчик пользователей для сообщений
|
||||
from handlers import UserHandlers
|
||||
user_handlers = UserHandlers(self.config, self.metrics, self.user_service, self.error_repo)
|
||||
message_handlers = user_handlers.get_message_handlers()
|
||||
|
||||
# Регистрируем обработчики по типам сообщений
|
||||
handler_mappings = {
|
||||
'text': lambda h: self.message_router.register_text_handler(
|
||||
r'.*', h, priority=0, description='Обработка текстовых сообщений'
|
||||
),
|
||||
'voice': lambda h: self.message_router.register_media_handler(
|
||||
'voice', h, description='Обработка голосовых сообщений'
|
||||
)
|
||||
}
|
||||
|
||||
for msg_type, handler in message_handlers.items():
|
||||
if msg_type in handler_mappings:
|
||||
handler_mappings[msg_type](handler)
|
||||
self.logger.debug(f"Зарегистрирован обработчик для {msg_type}")
|
||||
|
||||
except ImportError as e:
|
||||
self.logger.error(f"Ошибка импорта обработчиков сообщений: {e}")
|
||||
raise ConfigurationError("Обработчики сообщений не найдены")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Ошибка при регистрации обработчиков сообщений: {e}")
|
||||
raise
|
||||
|
||||
def _register_callback_handlers(self):
|
||||
"""Регистрация обработчиков callback'ов"""
|
||||
if not self.command_router:
|
||||
return
|
||||
|
||||
try:
|
||||
self.logger.debug("_register_callback_handlers - imports successful")
|
||||
|
||||
# Список обработчиков для callback'ов
|
||||
callback_handler_types = [
|
||||
('user', 'UserHandlers', [self.config, self.metrics, self.user_service, self.error_repo]),
|
||||
('game', 'GameHandlers', [self.config, self.metrics, self.game_service]),
|
||||
('admin', 'AdminHandlers', [self.config, self.metrics, self.user_service, self.moderation_service])
|
||||
]
|
||||
|
||||
for handler_name, class_name, init_args in callback_handler_types:
|
||||
try:
|
||||
# Импортируем класс обработчика
|
||||
from handlers import UserHandlers, GameHandlers, AdminHandlers
|
||||
handler_class = locals()[class_name]
|
||||
|
||||
# Создаем экземпляр обработчика
|
||||
handler_instance = handler_class(*init_args)
|
||||
|
||||
# Регистрируем все callback'и обработчика
|
||||
for callback, handler_func in handler_instance.get_callback_handlers().items():
|
||||
self.command_router.register_callback_handler(callback, handler_func)
|
||||
|
||||
self.logger.debug(f"Зарегистрированы callback'и {handler_name}: {len(handler_instance.get_callback_handlers())}")
|
||||
|
||||
except ImportError as e:
|
||||
self.logger.warning(f"Не удалось импортировать {class_name}: {e}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Ошибка при регистрации callback'ов {handler_name}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Ошибка при регистрации обработчиков callback'ов: {e}")
|
||||
raise
|
||||
|
||||
def _setup_handlers(self):
|
||||
"""Настройка обработчиков команд и сообщений (старая система для совместимости)"""
|
||||
|
||||
# Получаем все обработчики из модулей
|
||||
all_command_handlers = {}
|
||||
all_callback_handlers = {}
|
||||
all_message_handlers = {}
|
||||
|
||||
for handler_name, handler in self.handlers.items():
|
||||
try:
|
||||
# Получаем обработчики команд
|
||||
command_handlers = handler.get_command_handlers()
|
||||
all_command_handlers.update(command_handlers)
|
||||
|
||||
# Получаем обработчики callback запросов
|
||||
callback_handlers = handler.get_callback_handlers()
|
||||
all_callback_handlers.update(callback_handlers)
|
||||
|
||||
# Получаем обработчики сообщений
|
||||
message_handlers = handler.get_message_handlers()
|
||||
all_message_handlers.update(message_handlers)
|
||||
|
||||
self.logger.info(f"Обработчик {handler_name}: команд - {len(command_handlers)}, "
|
||||
f"callback - {len(callback_handlers)}, сообщений - {len(message_handlers)}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Ошибка настройки обработчика {handler_name}: {e}")
|
||||
raise
|
||||
|
||||
# Регистрируем обработчики команд через новую систему
|
||||
for command, handler_func in all_command_handlers.items():
|
||||
# Команды обрабатываются через CommandRouter, но регистрируем fallback
|
||||
self.telegram_app.add_handler(CommandHandler(
|
||||
command,
|
||||
lambda update, context, cmd=command, h=handler_func: self._handle_command_fallback(update, context, cmd, h)
|
||||
))
|
||||
|
||||
# Регистрируем обработчики callback через новую систему маршрутизации
|
||||
if all_callback_handlers:
|
||||
self.telegram_app.add_handler(CallbackQueryHandler(
|
||||
lambda update, context: self._handle_callback_via_unified_router(update, context)
|
||||
))
|
||||
|
||||
def _initialize_payment_services(self):
|
||||
"""Инициализация платежных сервисов"""
|
||||
try:
|
||||
from services import DonationService, NotificationService, TriggerService
|
||||
|
||||
# Инициализируем платежные репозитории
|
||||
try:
|
||||
from database.payment_repository import PaymentRepository, TransactionRepository
|
||||
payment_repo = PaymentRepository(self.config.get_database_url())
|
||||
transaction_repo = TransactionRepository(self.config.get_database_url())
|
||||
|
||||
# Конфигурация платежных провайдеров (пока отключены)
|
||||
payment_configs = {
|
||||
'stripe': {'enabled': False}, # Можно настроить через config
|
||||
'yookassa': {'enabled': False},
|
||||
'sbp': {'enabled': False}
|
||||
}
|
||||
|
||||
# Создаем экземпляр DonationService с правильными параметрами
|
||||
self.donation_service = DonationService(
|
||||
payment_repo=payment_repo,
|
||||
transaction_repo=transaction_repo,
|
||||
user_service=self.user_service,
|
||||
notification_service=None, # будет добавлен позже
|
||||
payment_configs=payment_configs
|
||||
)
|
||||
self.notification_service = NotificationService(self.config, self.user_repo)
|
||||
self.trigger_service = TriggerService(self.config, self.user_repo, self.notification_service)
|
||||
|
||||
# Обновляем donation_service с notification_service
|
||||
self.donation_service.notification_service = self.notification_service
|
||||
|
||||
self.logger.info("Сервисы платежей и уведомлений инициализированы")
|
||||
|
||||
except ImportError:
|
||||
self.logger.warning("Базы данных платежей не найдены, сервисы платежей отключены")
|
||||
self._disable_payment_services()
|
||||
|
||||
except ImportError as e:
|
||||
self.logger.warning(f"Сервисы платежей не найдены: {e}")
|
||||
self._disable_payment_services()
|
||||
|
||||
def _disable_payment_services(self):
|
||||
"""Отключение платежных сервисов"""
|
||||
self.donation_service = None
|
||||
self.notification_service = None
|
||||
def _register_message_handlers_in_telegram(self, all_message_handlers: Dict[str, callable]):
|
||||
"""Регистрация обработчиков сообщений в Telegram Application"""
|
||||
# Регистрируем обработчики сообщений через новую систему
|
||||
for message_type, handler_func in all_message_handlers.items():
|
||||
if message_type == 'text':
|
||||
self.telegram_app.add_handler(MessageHandler(
|
||||
filters.TEXT & ~filters.COMMAND,
|
||||
lambda update, context: self._handle_message_via_unified_router(update, context)
|
||||
))
|
||||
elif message_type == 'voice':
|
||||
self.telegram_app.add_handler(MessageHandler(
|
||||
filters.VOICE,
|
||||
lambda update, context: self._handle_voice_via_unified_router(update, context)
|
||||
))
|
||||
elif message_type == 'audio':
|
||||
self.telegram_app.add_handler(MessageHandler(filters.AUDIO, handler_func))
|
||||
elif message_type == 'video':
|
||||
self.telegram_app.add_handler(MessageHandler(filters.VIDEO, handler_func))
|
||||
self.trigger_service = None
|
||||
# Регистрируем обработчики сообщений
|
||||
self._register_message_handlers_in_telegram(all_message_handlers)
|
||||
|
||||
# Регистрируем обработчики callback запросов
|
||||
if all_callback_handlers:
|
||||
self.telegram_app.add_handler(CallbackQueryHandler(
|
||||
lambda update, context: self._handle_callback_via_unified_router(update, context)
|
||||
))
|
||||
|
||||
# Добавляем обработчик новых участников чата
|
||||
if hasattr(filters, 'CHAT_MEMBER'):
|
||||
# Используем более современный подход для обработки новых участников
|
||||
from telegram.ext import ChatMemberHandler
|
||||
self.telegram_app.add_handler(ChatMemberHandler(
|
||||
self._handle_new_chat_members, ChatMemberHandler.CHAT_MEMBER
|
||||
))
|
||||
|
||||
# Добавляем обработчик инлайновых запросов
|
||||
from telegram.ext import InlineQueryHandler
|
||||
self.telegram_app.add_handler(InlineQueryHandler(self._handle_inline_query))
|
||||
|
||||
# Добавляем обработчик ошибок
|
||||
self.telegram_app.add_error_handler(self._error_handler)
|
||||
|
||||
async def _handle_command_fallback(self, update, context, command, handler):
|
||||
"""Обработка команд через новую систему маршрутизации"""
|
||||
try:
|
||||
if hasattr(self, 'unified_router') and self.unified_router:
|
||||
await self.unified_router.handle_update(update, context)
|
||||
else:
|
||||
# Fallback к старому обработчику если unified_router недоступен
|
||||
await handler(update, context)
|
||||
except AttributeError as e:
|
||||
if "'Application' object has no attribute 'unified_router'" in str(e):
|
||||
self.logger.debug(f"unified_router не инициализирован, используем fallback для команды /{command}")
|
||||
self.logger.warning(f"unified_router не инициализирован, используем fallback для команды /{command}")
|
||||
# Fallback к старому обработчику
|
||||
await handler(update, context)
|
||||
else:
|
||||
self.logger.error(f"Error in unified command handler for /{command}: {e}")
|
||||
# Fallback к старому обработчику
|
||||
await handler(update, context)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in unified command handler for /{command}: {e}")
|
||||
# Fallback к старому обработчику
|
||||
await handler(update, context)
|
||||
|
||||
async def _handle_callback_via_unified_router(self, update, context):
|
||||
"""Обработка callback'ов через новую систему"""
|
||||
try:
|
||||
# Сначала пробуем через CommandRouter (новая система)
|
||||
await self.command_router.handle_callback(update, context)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in unified callback handler: {e}")
|
||||
# Fallback к старому диспетчеру
|
||||
await self._create_callback_dispatcher({})(update, context)
|
||||
|
||||
async def _handle_message_via_unified_router(self, update, context):
|
||||
"""Обработка текстовых сообщений через новую систему"""
|
||||
try:
|
||||
if hasattr(self, 'unified_router') and self.unified_router:
|
||||
await self.unified_router.handle_update(update, context)
|
||||
else:
|
||||
self.logger.debug("Unified router not available, skipping message routing")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in unified message handler: {e}")
|
||||
|
||||
async def _handle_voice_via_unified_router(self, update, context):
|
||||
"""Обработка голосовых сообщений через новую систему"""
|
||||
try:
|
||||
if hasattr(self, 'unified_router') and self.unified_router:
|
||||
await self.unified_router.handle_update(update, context)
|
||||
else:
|
||||
self.logger.debug("Unified router not available, skipping voice message routing")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in unified voice handler: {e}")
|
||||
|
||||
self.logger.info("Все обработчики успешно зарегистрированы (новая система активна)")
|
||||
|
||||
def _create_callback_dispatcher(self, callback_handlers: Dict[str, callable]):
|
||||
"""Создание диспетчера callback запросов"""
|
||||
async def dispatcher(update: Update, context: ContextTypes):
|
||||
query = update.callback_query
|
||||
|
||||
if query and query.data:
|
||||
callback_data = query.data
|
||||
|
||||
# Находим подходящий обработчик
|
||||
for pattern, handler_func in callback_handlers.items():
|
||||
if callback_data.startswith(pattern):
|
||||
try:
|
||||
start_time = time.time()
|
||||
await handler_func(update, context)
|
||||
duration = time.time() - start_time
|
||||
self.metrics.record_command(callback_data, "callback", duration)
|
||||
return
|
||||
except Exception as e:
|
||||
self.logger.error(f"Ошибка в callback обработчике {pattern}: {e}", exc_info=True)
|
||||
self.logger.debug(f"Полная трассировка ошибки в {pattern}:", exc_info=True)
|
||||
self.metrics.record_error(e.__class__.__name__, "callback", e)
|
||||
try:
|
||||
await query.edit_message_text("Произошла ошибка. Попробуйте позже.")
|
||||
except Exception as edit_error:
|
||||
self.logger.error(f"Не удалось отправить сообщение об ошибке: {edit_error}", exc_info=True)
|
||||
return
|
||||
|
||||
# Если не нашли подходящий обработчик
|
||||
await query.edit_message_text("Неизвестная команда.")
|
||||
|
||||
return dispatcher
|
||||
|
||||
async def _handle_new_chat_members(self, update: Update, context: ContextTypes):
|
||||
"""Обработка новых участников чата"""
|
||||
try:
|
||||
# Проверяем, что есть новые участники
|
||||
if not update.chat_member or not update.chat_member.new_chat_members:
|
||||
return
|
||||
|
||||
chat_id = update.effective_chat.id
|
||||
|
||||
# Создаем приветственное сообщение с правилами и кнопками донатов
|
||||
welcome_text = self._get_welcome_message_with_rules()
|
||||
|
||||
# Создаем клавиатуру с кнопками донатов
|
||||
donation_keyboard = [
|
||||
[InlineKeyboardButton("💰 100 ₽", callback_data='donate_100')],
|
||||
[InlineKeyboardButton("💰 500 ₽", callback_data='donate_500')],
|
||||
[InlineKeyboardButton("💰 1000 ₽", callback_data='donate_1000')],
|
||||
[InlineKeyboardButton("🎯 Помощь", callback_data='cmd_help')]
|
||||
]
|
||||
reply_markup = InlineKeyboardMarkup(donation_keyboard)
|
||||
|
||||
# Отправляем приветственное сообщение
|
||||
message = await update.effective_chat.send_message(
|
||||
welcome_text,
|
||||
parse_mode='HTML',
|
||||
reply_markup=reply_markup
|
||||
)
|
||||
|
||||
# Обрабатываем каждого нового участника
|
||||
for member in update.chat_member.new_chat_members:
|
||||
# Добавляем пользователя в базу данных
|
||||
await self.user_service.user_repo._create_user_if_not_exists(
|
||||
member.id, member.username, member.first_name, member.last_name
|
||||
)
|
||||
|
||||
# Начисляем очки за вступление в чат
|
||||
await self.user_service.score_repo.update_score(member.id, 10)
|
||||
|
||||
# Записываем метрику
|
||||
self.metrics.total_messages.labels(message_type='new_member').inc(len(update.chat_member.new_chat_members))
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Ошибка при обработке новых участников чата: {e}", exc_info=True)
|
||||
self.metrics.record_error(e.__class__.__name__, "new_chat_members", e)
|
||||
|
||||
def _get_welcome_message_with_rules(self) -> str:
|
||||
"""Генерация приветственного сообщения с правилами группы"""
|
||||
welcome_text = """🎉 <b>Добро пожаловать в нашу группу!</b>
|
||||
|
||||
👋 Приветствуем всех новых участников!
|
||||
|
||||
📋 <b>Правила нашей группы:</b>
|
||||
• Соблюдайте уважительное общение
|
||||
• Не используйте нецензурную лексику
|
||||
• Спам и реклама запрещены
|
||||
• Уважайте мнение других участников
|
||||
• Используйте /help для получения справки о командах бота
|
||||
|
||||
🎮 В группе работает система рейтингов и игр!
|
||||
🏆 Используйте /rank для просмотра вашего рейтинга
|
||||
|
||||
💰 <b>Поддержите проект:</b>
|
||||
Если вам нравится бот, вы можете поддержать его развитие!
|
||||
|
||||
Используйте кнопки ниже для доната или команду /help для получения дополнительной информации."""
|
||||
return welcome_text
|
||||
|
||||
async def _handle_inline_query(self, update: Update, context: ContextTypes):
|
||||
"""Обработка инлайновых запросов"""
|
||||
try:
|
||||
query = update.inline_query.query
|
||||
results = []
|
||||
|
||||
if not query:
|
||||
results.append(self._create_help_result())
|
||||
elif query.startswith('weather'):
|
||||
results.append(await self._handle_weather_query(query))
|
||||
elif query.startswith('translate'):
|
||||
results.append(await self._handle_translate_query(query))
|
||||
else:
|
||||
results.append(self._create_usage_result())
|
||||
|
||||
await update.inline_query.answer(results)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Ошибка при обработке инлайнового запроса: {e}", exc_info=True)
|
||||
await update.inline_query.answer([])
|
||||
|
||||
def _create_help_result(self):
|
||||
"""Создание результата помощи для пустого запроса"""
|
||||
from telegram import InlineQueryResultArticle, InputTextMessageContent
|
||||
self.metrics.total_messages.labels(message_type='inline_help').inc()
|
||||
return InlineQueryResultArticle(
|
||||
id='1',
|
||||
title="Использование бота",
|
||||
input_message_content=InputTextMessageContent(
|
||||
"Используйте бота для получения погоды и перевода текста.\n\n"
|
||||
"Примеры:\n"
|
||||
"@your_bot weather Moscow\n"
|
||||
"@your_bot translate Hello world en\n"
|
||||
"@your_bot news"
|
||||
)
|
||||
)
|
||||
|
||||
async def _handle_weather_query(self, query):
|
||||
"""Обработка запроса погоды"""
|
||||
from telegram import InlineQueryResultArticle, InputTextMessageContent
|
||||
|
||||
city = query.split(' ', 1)[1] if len(query.split(' ', 1)) > 1 else 'Moscow'
|
||||
|
||||
# Валидация города
|
||||
if len(city) < 2:
|
||||
return InlineQueryResultArticle(
|
||||
id='1',
|
||||
title="Ошибка",
|
||||
input_message_content=InputTextMessageContent("Название города должно содержать минимум 2 символа")
|
||||
)
|
||||
elif len(city) > 50:
|
||||
return InlineQueryResultArticle(
|
||||
id='1',
|
||||
title="Ошибка",
|
||||
input_message_content=InputTextMessageContent("Название города слишком длинное")
|
||||
)
|
||||
|
||||
# Проверка API ключа
|
||||
if not self.config.api_keys.openweather:
|
||||
return InlineQueryResultArticle(
|
||||
id='1',
|
||||
title="API недоступен",
|
||||
input_message_content=InputTextMessageContent("API погоды не настроен")
|
||||
)
|
||||
|
||||
try:
|
||||
import requests
|
||||
url = f"http://api.openweathermap.org/data/2.5/weather?q={city}&appid={self.config.api_keys.openweather}&units=metric&lang=ru"
|
||||
response = requests.get(url, timeout=5)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get('cod') == 200:
|
||||
weather_text = self._format_weather_text(data)
|
||||
return InlineQueryResultArticle(
|
||||
id='1',
|
||||
title=f"Погода в {city}",
|
||||
input_message_content=InputTextMessageContent(weather_text)
|
||||
)
|
||||
else:
|
||||
return InlineQueryResultArticle(
|
||||
id='1',
|
||||
title="Город не найден",
|
||||
input_message_content=InputTextMessageContent(f"Город '{city}' не найден")
|
||||
)
|
||||
else:
|
||||
return InlineQueryResultArticle(
|
||||
id='1',
|
||||
title="Ошибка API",
|
||||
input_message_content=InputTextMessageContent("Ошибка при получении погоды")
|
||||
)
|
||||
|
||||
except Exception:
|
||||
return InlineQueryResultArticle(
|
||||
id='1',
|
||||
title="Ошибка сети",
|
||||
input_message_content=InputTextMessageContent("Ошибка подключения к серверу погоды")
|
||||
)
|
||||
|
||||
def _format_weather_text(self, data):
|
||||
"""Форматирование текста погоды"""
|
||||
return (
|
||||
f"🌤️ Погода в {data['name']}:\n"
|
||||
f"🌡️ Температура: {data['main']['temp']}°C\n"
|
||||
f"🌡️ Ощущается как: {data['main']['feels_like']}°C\n"
|
||||
f"💧 Влажность: {data['main']['humidity']}%\n"
|
||||
f"💬 Описание: {data['weather'][0]['description'].capitalize()}"
|
||||
)
|
||||
|
||||
async def _handle_translate_query(self, query):
|
||||
"""Обработка запроса перевода"""
|
||||
from telegram import InlineQueryResultArticle, InputTextMessageContent
|
||||
|
||||
text_parts = query.split(' ', 2)
|
||||
if len(text_parts) < 3:
|
||||
return InlineQueryResultArticle(
|
||||
id='1',
|
||||
title="Использование",
|
||||
input_message_content=InputTextMessageContent("Использование: weather [город] или translate [текст] [язык]")
|
||||
)
|
||||
|
||||
text = text_parts[1]
|
||||
lang = text_parts[2]
|
||||
|
||||
if not (text and lang and len(text) <= 500 and len(lang) == 2):
|
||||
return InlineQueryResultArticle(
|
||||
id='1',
|
||||
title="Ошибка",
|
||||
input_message_content=InputTextMessageContent("Использование: текст для перевода")
|
||||
)
|
||||
|
||||
translated = self._translate_text(text, lang)
|
||||
result_text = (
|
||||
f"🔄 Перевод текста:\n\n"
|
||||
f"📝 Оригинал: {text}\n"
|
||||
f"🌐 Язык: {lang}\n"
|
||||
f"📋 Перевод: {translated}"
|
||||
)
|
||||
|
||||
return InlineQueryResultArticle(
|
||||
id='1',
|
||||
title=f"Перевод на {lang}",
|
||||
input_message_content=InputTextMessageContent(result_text)
|
||||
)
|
||||
|
||||
def _translate_text(self, text, lang):
|
||||
"""Простая имитация перевода"""
|
||||
if lang.lower() == 'en':
|
||||
translations = {
|
||||
'привет': "Hello",
|
||||
'мир': "World",
|
||||
'спасибо': "Thank you",
|
||||
'да': "Yes",
|
||||
'нет': "No"
|
||||
}
|
||||
return translations.get(text.lower(), f"[{lang.upper()}] {text}")
|
||||
return f"[{lang.upper()}] {text}"
|
||||
|
||||
def _create_usage_result(self):
|
||||
"""Создание результата с инструкцией использования"""
|
||||
from telegram import InlineQueryResultArticle, InputTextMessageContent
|
||||
return InlineQueryResultArticle(
|
||||
id='1',
|
||||
title="Использование",
|
||||
input_message_content=InputTextMessageContent("Использование: weather [город] или translate [текст] [язык]")
|
||||
)
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
Запуск телеграм-бота.
|
||||
|
||||
Выполняет следующие шаги:
|
||||
1. Проверка на единственный экземпляр
|
||||
2. Валидация конфигурации
|
||||
3. Запуск polling для получения обновлений
|
||||
4. Graceful shutdown при получении сигнала завершения
|
||||
|
||||
Raises:
|
||||
ConfigurationError: При ошибках конфигурации
|
||||
Exception: При критических ошибках во время работы
|
||||
"""
|
||||
try:
|
||||
# Проверяем, что это единственный экземпляр
|
||||
if not check_single_instance():
|
||||
self.logger.error("Другой экземпляр бота уже запущен. Завершение работы.")
|
||||
return
|
||||
|
||||
self.logger.info("Запуск телеграм-бота...")
|
||||
self.metrics.set_bot_status(1)
|
||||
|
||||
# Проверяем конфигурацию перед запуском
|
||||
self._validate_startup_config()
|
||||
|
||||
# Запускаем приложение
|
||||
self.telegram_app.run_polling(
|
||||
allowed_updates=Update.ALL_TYPES,
|
||||
drop_pending_updates=True
|
||||
)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
self.logger.info("Получен сигнал завершения")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Критическая ошибка при запуске: {e}", exc_info=True)
|
||||
self.metrics.record_error("CriticalError", "Application", e)
|
||||
raise
|
||||
finally:
|
||||
self.metrics.set_bot_status(0)
|
||||
self._cleanup()
|
||||
|
||||
def _validate_startup_config(self):
|
||||
"""Валидация конфигурации перед запуском"""
|
||||
# Проверяем токен бота
|
||||
if not self.config.bot_config.token:
|
||||
raise ConfigurationError("Токен бота не настроен")
|
||||
|
||||
# Проверяем подключение к базе данных
|
||||
try:
|
||||
# Тестовый запрос к базе данных
|
||||
test_result = self.user_repo._fetch_one("SELECT 1 as test", ())
|
||||
if test_result:
|
||||
self.logger.info("Подключение к базе данных успешно")
|
||||
else:
|
||||
raise ConfigurationError("Не удалось выполнить тестовый запрос к базе данных")
|
||||
|
||||
# Инициализируем достижения
|
||||
self.user_repo.initialize_achievements()
|
||||
self.logger.info("Достижения инициализированы")
|
||||
|
||||
# Инициализируем роли
|
||||
self.role_service.initialize_roles()
|
||||
self.logger.info("Роли инициализированы")
|
||||
|
||||
# Инициализируем администраторов из конфигурации
|
||||
admin_ids = self.config.bot_config.admin_ids
|
||||
if admin_ids:
|
||||
self.role_service.initialize_admin_users(admin_ids)
|
||||
self.logger.info(f"Администраторы инициализированы: {admin_ids}")
|
||||
|
||||
except Exception as e:
|
||||
raise ConfigurationError(f"Не удалось подключиться к базе данных: {e}")
|
||||
|
||||
self.logger.info("Конфигурация проверена успешно")
|
||||
|
||||
def _cleanup(self):
|
||||
"""Очистка ресурсов при завершении"""
|
||||
try:
|
||||
# Закрываем соединения с базой данных
|
||||
if hasattr(self.user_repo, 'close'):
|
||||
self.user_repo.close()
|
||||
if hasattr(self.score_repo, 'close'):
|
||||
self.score_repo.close()
|
||||
if hasattr(self.error_repo, 'close'):
|
||||
self.error_repo.close()
|
||||
|
||||
self.logger.info("Ресурсы освобождены")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Ошибка при очистке ресурсов: {e}", exc_info=True)
|
||||
|
||||
async def _error_handler(self, update: Update, context: ContextTypes):
|
||||
"""Обработчик ошибок приложения"""
|
||||
try:
|
||||
error = context.error
|
||||
self.logger.error(f"Произошла ошибка в обработке обновления: {error}", exc_info=True)
|
||||
self.metrics.record_error(error.__class__.__name__, "Application", error)
|
||||
|
||||
# Для сетевых ошибок, таких как NetworkError, логируем и игнорируем
|
||||
if 'NetworkError' in str(type(error)) or 'ReadError' in str(type(error)):
|
||||
self.logger.warning(f"Сетевая ошибка Telegram API: {error}. Бот продолжит работу.")
|
||||
return # Не отправляем сообщение пользователю, так как это внутренняя ошибка
|
||||
|
||||
# Для других ошибок, если есть update, отправляем сообщение
|
||||
if update and update.effective_chat:
|
||||
await update.effective_chat.send_message(
|
||||
"Произошла внутренняя ошибка. Попробуйте позже."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Ошибка в обработчике ошибок: {e}", exc_info=True)
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Получение текущего статуса приложения.
|
||||
|
||||
Returns:
|
||||
Dict содержащий информацию о:
|
||||
- Статусе приложения (running/stopped)
|
||||
- Конфигурации (токен, количество админов, флаги функций)
|
||||
- Количестве зарегистрированных обработчиков
|
||||
- Статусе сервисов (платежи, уведомления, rate limiting)
|
||||
- Статусе подключения к БД
|
||||
- Наличии unified_router
|
||||
"""
|
||||
return {
|
||||
'status': 'running',
|
||||
'config': {
|
||||
'bot_token': '***' if self.config.bot_config.token else None,
|
||||
'admin_count': len(self.config.bot_config.admin_ids),
|
||||
'ai_enabled': self.config.bot_config.enable_ai_processing,
|
||||
'developer_notifications': self.config.bot_config.enable_developer_notifications
|
||||
},
|
||||
'handlers_count': len(self.handlers),
|
||||
'services': {
|
||||
'donation_service': self.donation_service is not None,
|
||||
'notification_service': self.notification_service is not None,
|
||||
'trigger_service': self.trigger_service is not None,
|
||||
'rate_limiter': hasattr(self, 'rate_limiter')
|
||||
},
|
||||
'database_connected': True, # Здесь можно добавить реальную проверку
|
||||
'unified_router': self.unified_router is not None
|
||||
}
|
||||
|
||||
async def stop(self):
|
||||
"""Остановка приложения"""
|
||||
self.logger.info("Остановка приложения...")
|
||||
await self.telegram_app.stop()
|
||||
self._cleanup()
|
||||
@@ -0,0 +1,335 @@
|
||||
"""
|
||||
Единый маршрутизатор команд для централизованной обработки.
|
||||
Отвечает за маршрутизацию команд к соответствующим обработчикам.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Callable, Awaitable, Any, Optional
|
||||
from telegram import Update
|
||||
from telegram.ext import ContextTypes
|
||||
from .middleware import create_middleware_chain
|
||||
from .permissions import permission_manager, UserRole
|
||||
|
||||
|
||||
class CommandHandler:
|
||||
"""
|
||||
Описание обработчика команды с метаданными.
|
||||
"""
|
||||
|
||||
def __init__(self, handler: Callable, required_role: UserRole = UserRole.USER,
|
||||
description: str = "", category: str = "general"):
|
||||
self.handler = handler
|
||||
self.required_role = required_role
|
||||
self.description = description
|
||||
self.category = category
|
||||
|
||||
|
||||
class CommandRouter:
|
||||
"""
|
||||
Центральный маршрутизатор команд бота.
|
||||
|
||||
Обеспечивает:
|
||||
- Регистрацию обработчиков команд
|
||||
- Автоматическую маршрутизацию
|
||||
- Проверку прав доступа
|
||||
- Обработку через middleware
|
||||
"""
|
||||
|
||||
def __init__(self, config, metrics=None):
|
||||
"""
|
||||
Инициализация маршрутизатора.
|
||||
|
||||
Args:
|
||||
config: Конфигурация приложения
|
||||
metrics: Сборщик метрик
|
||||
"""
|
||||
self.config = config
|
||||
self.metrics = metrics
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
# Создаем цепочку middleware
|
||||
self.middleware_chain = create_middleware_chain(config, metrics)
|
||||
|
||||
# Регистрируемые обработчики
|
||||
self.command_handlers: Dict[str, CommandHandler] = {}
|
||||
self.callback_handlers: Dict[str, CommandHandler] = {}
|
||||
self.message_handlers: Dict[str, CommandHandler] = {}
|
||||
|
||||
# Категории команд
|
||||
self.categories = {
|
||||
'user': 'Пользовательские команды',
|
||||
'moderation': 'Модерация',
|
||||
'admin': 'Администрирование',
|
||||
'system': 'Системные команды',
|
||||
'games': 'Игры'
|
||||
}
|
||||
|
||||
def register_command_handler(self, command: str, handler: Callable,
|
||||
required_role: UserRole = UserRole.USER,
|
||||
description: str = "", category: str = "user"):
|
||||
"""
|
||||
Регистрация обработчика команды.
|
||||
|
||||
Args:
|
||||
command: Название команды (без слеша)
|
||||
handler: Функция обработчик
|
||||
required_role: Минимальная требуемая роль
|
||||
description: Описание команды
|
||||
category: Категория команды
|
||||
"""
|
||||
self.command_handlers[command] = CommandHandler(
|
||||
handler=handler,
|
||||
required_role=required_role,
|
||||
description=description,
|
||||
category=category
|
||||
)
|
||||
self.logger.debug(f"Registered command handler: /{command} -> {handler.__name__}")
|
||||
|
||||
def register_callback_handler(self, callback_data: str, handler: Callable,
|
||||
required_role: UserRole = UserRole.USER,
|
||||
description: str = "", category: str = "user"):
|
||||
"""
|
||||
Регистрация обработчика callback запросов.
|
||||
|
||||
Args:
|
||||
callback_data: Callback data
|
||||
handler: Функция обработчик
|
||||
required_role: Минимальная требуемая роль
|
||||
description: Описание
|
||||
category: Категория
|
||||
"""
|
||||
self.callback_handlers[callback_data] = CommandHandler(
|
||||
handler=handler,
|
||||
required_role=required_role,
|
||||
description=description,
|
||||
category=category
|
||||
)
|
||||
|
||||
def register_message_handler(self, message_type: str, handler: Callable,
|
||||
required_role: UserRole = UserRole.USER,
|
||||
description: str = "", category: str = "user"):
|
||||
"""
|
||||
Регистрация обработчика сообщений.
|
||||
|
||||
Args:
|
||||
message_type: Тип сообщения (text, voice, audio, etc.)
|
||||
handler: Функция обработчик
|
||||
required_role: Минимальная требуемая роль
|
||||
description: Описание
|
||||
category: Категория
|
||||
"""
|
||||
self.message_handlers[message_type] = CommandHandler(
|
||||
handler=handler,
|
||||
required_role=required_role,
|
||||
description=description,
|
||||
category=category
|
||||
)
|
||||
|
||||
async def handle_command(self, update: Update, context: ContextTypes):
|
||||
"""
|
||||
Обработка команды через middleware и маршрутизацию.
|
||||
|
||||
Args:
|
||||
update: Обновление от Telegram
|
||||
context: Контекст бота
|
||||
"""
|
||||
# Извлекаем команду из сообщения
|
||||
command = self._extract_command(update)
|
||||
if not command:
|
||||
return
|
||||
|
||||
# Находим обработчик
|
||||
handler_info = self.command_handlers.get(command)
|
||||
if not handler_info:
|
||||
await self._send_unknown_command(update, command)
|
||||
return
|
||||
|
||||
# Создаем обертку обработчика с проверкой роли
|
||||
async def wrapped_handler(update: Update, context: ContextTypes):
|
||||
await handler_info.handler(update, context)
|
||||
|
||||
# Обрабатываем через middleware
|
||||
await self.middleware_chain.process_request(update, context, wrapped_handler)
|
||||
|
||||
async def handle_callback(self, update: Update, context: ContextTypes):
|
||||
"""
|
||||
Обработка callback запросов.
|
||||
|
||||
Args:
|
||||
update: Обновление от Telegram
|
||||
context: Контекст бота
|
||||
"""
|
||||
if not update.callback_query:
|
||||
return
|
||||
|
||||
callback_data = update.callback_query.data
|
||||
if not callback_data:
|
||||
return
|
||||
|
||||
self.logger.debug(f"Processing callback: {callback_data}")
|
||||
|
||||
# Находим обработчик (точное совпадение или префикс)
|
||||
handler_info = None
|
||||
|
||||
# Сначала ищем точное совпадение
|
||||
if callback_data in self.callback_handlers:
|
||||
handler_info = self.callback_handlers[callback_data]
|
||||
self.logger.debug(f"Found exact match for callback: {callback_data}")
|
||||
else:
|
||||
# Ищем по префиксу
|
||||
for prefix, handler in self.callback_handlers.items():
|
||||
if callback_data.startswith(prefix):
|
||||
handler_info = handler
|
||||
self.logger.debug(f"Found prefix match for callback: {callback_data} -> {prefix}")
|
||||
break
|
||||
|
||||
if not handler_info:
|
||||
self.logger.warning(f"No handler found for callback: {callback_data}")
|
||||
await update.callback_query.answer("Неизвестное действие")
|
||||
return
|
||||
|
||||
self.logger.debug(f"Found handler for callback {callback_data}: {handler_info.handler.__name__}")
|
||||
|
||||
# Создаем обертку обработчика
|
||||
async def wrapped_handler(update: Update, context: ContextTypes):
|
||||
await handler_info.handler(update, context)
|
||||
|
||||
# Обрабатываем через middleware
|
||||
await self.middleware_chain.process_request(update, context, wrapped_handler)
|
||||
|
||||
async def handle_message(self, update: Update, context: ContextTypes, message_type: str = "text"):
|
||||
"""
|
||||
Обработка сообщений.
|
||||
|
||||
Args:
|
||||
update: Обновление от Telegram
|
||||
context: Контекст бота
|
||||
message_type: Тип сообщения
|
||||
"""
|
||||
handler_info = self.message_handlers.get(message_type)
|
||||
if not handler_info:
|
||||
return
|
||||
|
||||
# Создаем обертку обработчика
|
||||
async def wrapped_handler(update: Update, context: ContextTypes):
|
||||
await handler_info.handler(update, context)
|
||||
|
||||
# Обрабатываем через middleware
|
||||
await self.middleware_chain.process_request(update, context, wrapped_handler)
|
||||
|
||||
def _extract_command(self, update: Update) -> Optional[str]:
|
||||
"""
|
||||
Извлечение команды из обновления.
|
||||
|
||||
Args:
|
||||
update: Обновление от Telegram
|
||||
|
||||
Returns:
|
||||
Название команды или None
|
||||
"""
|
||||
if not update.message or not update.message.text:
|
||||
return None
|
||||
|
||||
text = update.message.text.strip()
|
||||
if not text.startswith('/'):
|
||||
return None
|
||||
|
||||
# Извлекаем команду (убираем слеш и username бота)
|
||||
command = text.split()[0][1:].split('@')[0]
|
||||
return command
|
||||
|
||||
async def _send_unknown_command(self, update: Update, command: str):
|
||||
"""
|
||||
Отправка сообщения о неизвестной команде.
|
||||
|
||||
Args:
|
||||
update: Обновление от Telegram
|
||||
command: Название неизвестной команды
|
||||
"""
|
||||
try:
|
||||
message = f"❌ Неизвестная команда: /{command}\n\nИспользуйте /help для списка доступных команд."
|
||||
if update.message:
|
||||
await update.message.reply_text(message)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to send unknown command message: {e}")
|
||||
|
||||
def get_registered_commands(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Получение списка зарегистрированных команд сгруппированных по категориям.
|
||||
|
||||
Returns:
|
||||
Словарь категорий с командами
|
||||
"""
|
||||
result = {}
|
||||
|
||||
for category, category_name in self.categories.items():
|
||||
result[category] = {
|
||||
'name': category_name,
|
||||
'commands': {}
|
||||
}
|
||||
|
||||
for command, handler_info in self.command_handlers.items():
|
||||
category = handler_info.category
|
||||
if category not in result:
|
||||
result[category] = {'name': category, 'commands': {}}
|
||||
|
||||
result[category]['commands'][command] = {
|
||||
'description': handler_info.description,
|
||||
'required_role': handler_info.required_role.value
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
def get_available_commands_for_role(self, user_role: UserRole) -> List[str]:
|
||||
"""
|
||||
Получение списка команд доступных для роли.
|
||||
|
||||
Args:
|
||||
user_role: Роль пользователя
|
||||
|
||||
Returns:
|
||||
Список доступных команд
|
||||
"""
|
||||
available_commands = []
|
||||
|
||||
for command, handler_info in self.command_handlers.items():
|
||||
if permission_manager.can_execute_command(user_role, command):
|
||||
available_commands.append(command)
|
||||
|
||||
return sorted(available_commands)
|
||||
|
||||
def get_command_info(self, command: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Получение информации о команде.
|
||||
|
||||
Args:
|
||||
command: Название команды
|
||||
|
||||
Returns:
|
||||
Информация о команде или None
|
||||
"""
|
||||
handler_info = self.command_handlers.get(command)
|
||||
if not handler_info:
|
||||
return None
|
||||
|
||||
return {
|
||||
'command': command,
|
||||
'description': handler_info.description,
|
||||
'required_role': handler_info.required_role.value,
|
||||
'category': handler_info.category
|
||||
}
|
||||
|
||||
|
||||
# Функция для создания настроенного маршрутизатора
|
||||
def create_command_router(config, metrics=None):
|
||||
"""
|
||||
Создание и настройка маршрутизатора команд.
|
||||
|
||||
Args:
|
||||
config: Конфигурация приложения
|
||||
metrics: Сборщик метрик
|
||||
|
||||
Returns:
|
||||
CommandRouter: Настроенный маршрутизатор
|
||||
"""
|
||||
return CommandRouter(config, metrics)
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
"""
|
||||
Система управления конфигурацией телеграм-бота.
|
||||
Поддерживает загрузку из переменных окружения и файлов конфигурации.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
from typing import Dict, Any, Optional
|
||||
from dataclasses import dataclass
|
||||
from .exceptions import ConfigurationError
|
||||
|
||||
|
||||
@dataclass
|
||||
class APIKeys:
|
||||
"""Ключи внешних API"""
|
||||
openweather: Optional[str] = None
|
||||
news: Optional[str] = None
|
||||
openai: Optional[str] = None
|
||||
|
||||
def is_configured(self, service: str) -> bool:
|
||||
"""Проверяет, настроен ли сервис"""
|
||||
return getattr(self, service.lower()) is not None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BotConfig:
|
||||
"""Основная конфигурация бота"""
|
||||
token: str
|
||||
admin_ids: list[int]
|
||||
moderator_ids: list[int] = None
|
||||
super_admin_ids: list[int] = None
|
||||
developer_chat_id: Optional[int] = None
|
||||
enable_ai_processing: bool = False
|
||||
enable_developer_notifications: bool = False
|
||||
enable_sentry: bool = False
|
||||
sentry_dsn: Optional[str] = None
|
||||
prometheus_port: int = 8000
|
||||
|
||||
def __post_init__(self):
|
||||
if self.moderator_ids is None:
|
||||
self.moderator_ids = []
|
||||
if self.super_admin_ids is None:
|
||||
self.super_admin_ids = []
|
||||
|
||||
|
||||
class Config:
|
||||
"""
|
||||
Централизованная система конфигурации.
|
||||
|
||||
Поддерживает:
|
||||
- Переменные окружения
|
||||
- JSON файлы конфигурации
|
||||
- Валидацию обязательных параметров
|
||||
- Безопасное хранение чувствительных данных
|
||||
"""
|
||||
|
||||
def __init__(self, config_path: Optional[str] = None):
|
||||
"""
|
||||
Инициализация конфигурации.
|
||||
|
||||
Args:
|
||||
config_path: Путь к файлу конфигурации (опционально)
|
||||
"""
|
||||
self.config_path = config_path or "telegram_bot/config_local.py"
|
||||
self._config = {}
|
||||
self._load_configuration()
|
||||
|
||||
def _load_configuration(self):
|
||||
"""Загрузка конфигурации из всех источников"""
|
||||
# Загружаем переменные окружения
|
||||
self._load_from_environment()
|
||||
|
||||
# Загружаем из файла конфигурации
|
||||
self._load_from_file()
|
||||
|
||||
# Валидируем обязательные параметры
|
||||
self._validate_required_config()
|
||||
|
||||
def _load_from_environment(self):
|
||||
"""Загрузка конфигурации из переменных окружения"""
|
||||
# Базовые настройки бота
|
||||
if token := os.getenv('BOT_TOKEN'):
|
||||
self._config['bot_token'] = token
|
||||
|
||||
if admin_ids := os.getenv('ADMIN_IDS'):
|
||||
try:
|
||||
self._config['admin_ids'] = [
|
||||
int(id_str.strip()) for id_str in admin_ids.split(',')
|
||||
]
|
||||
except ValueError as e:
|
||||
raise ConfigurationError(f"Неверный формат ADMIN_IDS: {e}")
|
||||
|
||||
if moderator_ids := os.getenv('MODERATOR_IDS'):
|
||||
try:
|
||||
self._config['moderator_ids'] = [
|
||||
int(id_str.strip()) for id_str in moderator_ids.split(',')
|
||||
]
|
||||
except ValueError as e:
|
||||
raise ConfigurationError(f"Неверный формат MODERATOR_IDS: {e}")
|
||||
|
||||
if super_admin_ids := os.getenv('SUPER_ADMIN_IDS'):
|
||||
try:
|
||||
self._config['super_admin_ids'] = [
|
||||
int(id_str.strip()) for id_str in super_admin_ids.split(',')
|
||||
]
|
||||
except ValueError as e:
|
||||
raise ConfigurationError(f"Неверный формат SUPER_ADMIN_IDS: {e}")
|
||||
|
||||
if dev_id := os.getenv('DEVELOPER_CHAT_ID'):
|
||||
try:
|
||||
self._config['developer_chat_id'] = int(dev_id)
|
||||
except ValueError as e:
|
||||
raise ConfigurationError(f"Неверный формат DEVELOPER_CHAT_ID: {e}")
|
||||
|
||||
# Флаги функций
|
||||
self._config['enable_developer_notifications'] = self._str_to_bool(
|
||||
os.getenv('ENABLE_DEVELOPER_NOTIFICATIONS', 'false')
|
||||
)
|
||||
self._config['enable_ai_error_processing'] = self._str_to_bool(
|
||||
os.getenv('ENABLE_AI_ERROR_PROCESSING', 'false')
|
||||
)
|
||||
self._config['enable_sentry'] = self._str_to_bool(
|
||||
os.getenv('ENABLE_SENTRY', 'false')
|
||||
)
|
||||
self._config['sentry_dsn'] = os.getenv('SENTRY_DSN')
|
||||
self._config['prometheus_port'] = int(os.getenv('PROMETHEUS_PORT', '8000'))
|
||||
|
||||
# API ключи
|
||||
self._config['api_keys'] = {
|
||||
'openweather': os.getenv('OPENWEATHER_API_KEY'),
|
||||
'news': os.getenv('NEWS_API_KEY'),
|
||||
'openai': os.getenv('OPENAI_API_KEY')
|
||||
}
|
||||
|
||||
def _load_from_file(self):
|
||||
"""Загрузка конфигурации из Python файла"""
|
||||
if not os.path.exists(self.config_path):
|
||||
return
|
||||
|
||||
try:
|
||||
# Используем importlib для безопасной загрузки модуля
|
||||
import importlib.util
|
||||
|
||||
spec = importlib.util.spec_from_file_location("config", self.config_path)
|
||||
if spec and spec.loader:
|
||||
config_module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(config_module)
|
||||
|
||||
# Извлекаем переменные из модуля
|
||||
if hasattr(config_module, 'BOT_TOKEN'):
|
||||
self._config['bot_token'] = config_module.BOT_TOKEN
|
||||
|
||||
if hasattr(config_module, 'ADMIN_IDS'):
|
||||
admin_ids = config_module.ADMIN_IDS
|
||||
print(f"[DEBUG] Loading ADMIN_IDS from file: {admin_ids}")
|
||||
if isinstance(admin_ids, list):
|
||||
self._config['admin_ids'] = admin_ids
|
||||
elif isinstance(admin_ids, str):
|
||||
self._config['admin_ids'] = [int(x.strip()) for x in admin_ids.split(',')]
|
||||
print(f"[DEBUG] Set _config['admin_ids'] = {self._config.get('admin_ids')}")
|
||||
|
||||
if hasattr(config_module, 'MODERATOR_IDS'):
|
||||
moderator_ids = config_module.MODERATOR_IDS
|
||||
if isinstance(moderator_ids, list):
|
||||
self._config['moderator_ids'] = moderator_ids
|
||||
elif isinstance(moderator_ids, str):
|
||||
self._config['moderator_ids'] = [int(x.strip()) for x in moderator_ids.split(',')]
|
||||
|
||||
if hasattr(config_module, 'SUPER_ADMIN_IDS'):
|
||||
super_admin_ids = config_module.SUPER_ADMIN_IDS
|
||||
print(f"[DEBUG] Loading SUPER_ADMIN_IDS from file: {super_admin_ids}")
|
||||
if isinstance(super_admin_ids, list):
|
||||
self._config['super_admin_ids'] = super_admin_ids
|
||||
elif isinstance(super_admin_ids, str):
|
||||
self._config['super_admin_ids'] = [int(x.strip()) for x in super_admin_ids.split(',')]
|
||||
print(f"[DEBUG] Set _config['super_admin_ids'] = {self._config.get('super_admin_ids')}")
|
||||
|
||||
if hasattr(config_module, 'DEVELOPER_CHAT_ID'):
|
||||
self._config['developer_chat_id'] = config_module.DEVELOPER_CHAT_ID
|
||||
|
||||
if hasattr(config_module, 'ENABLE_DEVELOPER_NOTIFICATIONS'):
|
||||
self._config['enable_developer_notifications'] = config_module.ENABLE_DEVELOPER_NOTIFICATIONS
|
||||
|
||||
if hasattr(config_module, 'OPENAI_API_KEY'):
|
||||
self._config['api_keys']['openai'] = config_module.OPENAI_API_KEY
|
||||
|
||||
if hasattr(config_module, 'ENABLE_AI_ERROR_PROCESSING'):
|
||||
self._config['enable_ai_error_processing'] = config_module.ENABLE_AI_ERROR_PROCESSING
|
||||
|
||||
if hasattr(config_module, 'ENABLE_SENTRY'):
|
||||
self._config['enable_sentry'] = config_module.ENABLE_SENTRY
|
||||
|
||||
if hasattr(config_module, 'SENTRY_DSN'):
|
||||
self._config['sentry_dsn'] = config_module.SENTRY_DSN
|
||||
|
||||
if hasattr(config_module, 'PROMETHEUS_PORT'):
|
||||
self._config['prometheus_port'] = config_module.PROMETHEUS_PORT
|
||||
|
||||
if hasattr(config_module, 'AI_MODEL'):
|
||||
self._config['ai_model'] = config_module.AI_MODEL
|
||||
|
||||
except Exception as e:
|
||||
raise ConfigurationError(f"Ошибка загрузки файла конфигурации: {e}")
|
||||
|
||||
def _validate_required_config(self):
|
||||
"""Валидация обязательных параметров конфигурации"""
|
||||
if not self._config.get('bot_token'):
|
||||
raise ConfigurationError("BOT_TOKEN обязателен для работы бота")
|
||||
|
||||
# ADMIN_IDS обязателен только если нет SUPER_ADMIN_IDS
|
||||
if not self._config.get('admin_ids') and not self._config.get('super_admin_ids'):
|
||||
raise ConfigurationError("ADMIN_IDS или SUPER_ADMIN_IDS обязателен для работы бота")
|
||||
|
||||
def _str_to_bool(self, value: str) -> bool:
|
||||
"""Преобразование строки в булево значение"""
|
||||
return value.lower() in ('true', '1', 'yes', 'on')
|
||||
|
||||
@property
|
||||
def bot_config(self) -> BotConfig:
|
||||
"""Получение конфигурации бота"""
|
||||
return BotConfig(
|
||||
token=self._config['bot_token'],
|
||||
admin_ids=self._config.get('admin_ids', []),
|
||||
moderator_ids=self._config.get('moderator_ids', []),
|
||||
super_admin_ids=self._config.get('super_admin_ids', []),
|
||||
developer_chat_id=self._config.get('developer_chat_id'),
|
||||
enable_ai_processing=self._config.get('enable_ai_error_processing', False),
|
||||
enable_developer_notifications=self._config.get('enable_developer_notifications', False),
|
||||
enable_sentry=self._config.get('enable_sentry', False),
|
||||
sentry_dsn=self._config.get('sentry_dsn'),
|
||||
prometheus_port=self._config.get('prometheus_port', 8000)
|
||||
)
|
||||
|
||||
@property
|
||||
def api_keys(self) -> APIKeys:
|
||||
"""Получение ключей API"""
|
||||
keys = self._config.get('api_keys', {})
|
||||
return APIKeys(
|
||||
openweather=keys.get('openweather'),
|
||||
news=keys.get('news'),
|
||||
openai=keys.get('openai')
|
||||
)
|
||||
|
||||
@property
|
||||
def ai_model(self) -> str:
|
||||
"""Модель ИИ для обработки ошибок"""
|
||||
return self._config.get('ai_model', 'gpt-3.5-turbo')
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Получение значения конфигурации по ключу"""
|
||||
return self._config.get(key, default)
|
||||
|
||||
def is_development(self) -> bool:
|
||||
"""Проверка режима разработки"""
|
||||
return self._str_to_bool(os.getenv('DEVELOPMENT_MODE', 'false'))
|
||||
|
||||
def is_production(self) -> bool:
|
||||
"""Проверка продакшн режима"""
|
||||
return not self.is_development()
|
||||
|
||||
def get_database_url(self) -> str:
|
||||
"""Получение URL базы данных"""
|
||||
return os.getenv('DATABASE_URL', 'telegram_bot.db')
|
||||
|
||||
def get_log_level(self) -> str:
|
||||
"""Получение уровня логирования"""
|
||||
# Устанавливаем DEBUG уровень для детальной записи ошибок
|
||||
return os.getenv('LOG_LEVEL', 'DEBUG')
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Строковое представление конфигурации"""
|
||||
return f"Config(bot_token={'***' if self._config.get('bot_token') else None}, admin_count={len(self._config.get('admin_ids', []))})"
|
||||
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
Кастомные исключения для телеграм-бота.
|
||||
Обеспечивают единообразную обработку ошибок во всем приложении.
|
||||
"""
|
||||
|
||||
|
||||
class BotException(Exception):
|
||||
"""Базовое исключение для всех ошибок бота"""
|
||||
|
||||
def __init__(self, message: str, error_code: str = None):
|
||||
super().__init__(message)
|
||||
self.error_code = error_code
|
||||
self.message = message
|
||||
|
||||
|
||||
class DatabaseError(BotException):
|
||||
"""Ошибки работы с базой данных"""
|
||||
|
||||
def __init__(self, message: str, original_error: Exception = None):
|
||||
super().__init__(message, "DATABASE_ERROR")
|
||||
self.original_error = original_error
|
||||
|
||||
|
||||
class ValidationError(BotException):
|
||||
"""Ошибки валидации данных"""
|
||||
|
||||
def __init__(self, message: str, field: str = None):
|
||||
super().__init__(message, "VALIDATION_ERROR")
|
||||
self.field = field
|
||||
|
||||
|
||||
class ConfigurationError(BotException):
|
||||
"""Ошибки конфигурации"""
|
||||
|
||||
def __init__(self, message: str, config_key: str = None):
|
||||
super().__init__(message, "CONFIG_ERROR")
|
||||
self.config_key = config_key
|
||||
|
||||
|
||||
class APIError(BotException):
|
||||
"""Ошибки внешних API"""
|
||||
|
||||
def __init__(self, message: str, api_name: str = None, status_code: int = None):
|
||||
super().__init__(message, "API_ERROR")
|
||||
self.api_name = api_name
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
class GameError(BotException):
|
||||
"""Ошибки игровых механик"""
|
||||
|
||||
def __init__(self, message: str, game_name: str = None):
|
||||
super().__init__(message, "GAME_ERROR")
|
||||
self.game_name = game_name
|
||||
|
||||
|
||||
class ModerationError(BotException):
|
||||
"""Ошибки системы модерации"""
|
||||
|
||||
def __init__(self, message: str, action: str = None):
|
||||
super().__init__(message, "MODERATION_ERROR")
|
||||
self.action = action
|
||||
|
||||
|
||||
class PermissionError(BotException):
|
||||
"""Ошибки доступа и прав"""
|
||||
|
||||
def __init__(self, message: str, required_permission: str = None):
|
||||
super().__init__(message, "PERMISSION_ERROR")
|
||||
self.required_permission = required_permission
|
||||
@@ -0,0 +1,451 @@
|
||||
"""
|
||||
Централизованный менеджер контекстных меню с учетом прав доступа.
|
||||
Отвечает за создание и управление меню в зависимости от роли пользователя.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Callable, Any, Optional
|
||||
from telegram import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from .permissions import UserRole, permission_manager
|
||||
from utils.formatters import KeyboardFormatter
|
||||
|
||||
|
||||
class MenuConfig:
|
||||
"""Конфигурация меню"""
|
||||
def __init__(self, menu_id: str, builder: Callable, required_role: UserRole,
|
||||
level: str, description: str = ""):
|
||||
self.menu_id = menu_id
|
||||
self.builder = builder
|
||||
self.required_role = required_role
|
||||
self.level = level
|
||||
self.description = description
|
||||
|
||||
|
||||
class ContextMenuManager:
|
||||
"""
|
||||
Централизованный менеджер контекстных меню.
|
||||
|
||||
Обеспечивает:
|
||||
- Регистрацию меню с учетом прав доступа
|
||||
- Динамическое построение меню для пользователей
|
||||
- Разделение меню по уровням доступа
|
||||
- Переиспользование компонентов меню
|
||||
"""
|
||||
|
||||
def __init__(self, permission_manager_instance, formatter: KeyboardFormatter):
|
||||
"""
|
||||
Инициализация менеджера меню.
|
||||
|
||||
Args:
|
||||
permission_manager_instance: Экземпляр менеджера разрешений
|
||||
formatter: Форматтер клавиатур
|
||||
"""
|
||||
self.permission_manager = permission_manager_instance
|
||||
self.formatter = formatter
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
# Регистрация меню по ID
|
||||
self.menus: Dict[str, MenuConfig] = {}
|
||||
|
||||
# Группировка меню по уровням доступа
|
||||
self.menu_levels: Dict[str, List[str]] = {
|
||||
'user': [],
|
||||
'moderator': [],
|
||||
'admin': [],
|
||||
'super_admin': []
|
||||
}
|
||||
|
||||
# Кеш построенных меню для оптимизации
|
||||
self.menu_cache: Dict[str, InlineKeyboardMarkup] = {}
|
||||
|
||||
# Инициализация стандартных меню
|
||||
self._initialize_default_menus()
|
||||
|
||||
def _initialize_default_menus(self):
|
||||
"""Инициализация стандартных меню системы"""
|
||||
# Меню уровня USER
|
||||
self.register_menu(
|
||||
'menu_main', self._build_main_menu, UserRole.USER, 'user',
|
||||
'Главное меню бота'
|
||||
)
|
||||
self.register_menu(
|
||||
'menu_help', self._build_help_menu, UserRole.USER, 'user',
|
||||
'Меню помощи'
|
||||
)
|
||||
self.register_menu(
|
||||
'menu_games', self._build_games_menu, UserRole.USER, 'user',
|
||||
'Меню игр'
|
||||
)
|
||||
self.register_menu(
|
||||
'menu_donate', self._build_donate_menu, UserRole.USER, 'user',
|
||||
'Меню донатов'
|
||||
)
|
||||
self.register_menu(
|
||||
'menu_leaderboard', self._build_leaderboard_menu, UserRole.USER, 'user',
|
||||
'Таблица лидеров'
|
||||
)
|
||||
|
||||
# Меню уровня MODERATOR
|
||||
self.register_menu(
|
||||
'menu_moderation', self._build_moderation_menu, UserRole.MODERATOR, 'moderator',
|
||||
'Меню модерации'
|
||||
)
|
||||
self.register_menu(
|
||||
'mod_warnings', self._build_warnings_menu, UserRole.MODERATOR, 'moderator',
|
||||
'Управление предупреждениями'
|
||||
)
|
||||
self.register_menu(
|
||||
'mod_mutes', self._build_mutes_menu, UserRole.MODERATOR, 'moderator',
|
||||
'Управление заглушками'
|
||||
)
|
||||
self.register_menu(
|
||||
'mod_stats', self._build_mod_stats_menu, UserRole.MODERATOR, 'moderator',
|
||||
'Статистика модерации'
|
||||
)
|
||||
|
||||
# Меню уровня ADMIN
|
||||
self.register_menu(
|
||||
'menu_admin', self._build_admin_menu, UserRole.ADMIN, 'admin',
|
||||
'Меню администрирования'
|
||||
)
|
||||
self.register_menu(
|
||||
'menu_triggers', self._build_triggers_menu, UserRole.ADMIN, 'admin',
|
||||
'Управление триггерами'
|
||||
)
|
||||
self.register_menu(
|
||||
'admin_users', self._build_admin_users_menu, UserRole.ADMIN, 'admin',
|
||||
'Управление пользователями'
|
||||
)
|
||||
self.register_menu(
|
||||
'admin_stats', self._build_admin_stats_menu, UserRole.ADMIN, 'admin',
|
||||
'Статистика администрирования'
|
||||
)
|
||||
self.register_menu(
|
||||
'admin_settings', self._build_admin_settings_menu, UserRole.ADMIN, 'admin',
|
||||
'Настройки системы'
|
||||
)
|
||||
self.register_menu(
|
||||
'admin_errors', self._build_admin_errors_menu, UserRole.ADMIN, 'admin',
|
||||
'Отчеты об ошибках'
|
||||
)
|
||||
|
||||
# Меню уровня SUPER_ADMIN
|
||||
self.register_menu(
|
||||
'admin_system', self._build_system_menu, UserRole.SUPER_ADMIN, 'super_admin',
|
||||
'Системные настройки'
|
||||
)
|
||||
self.register_menu(
|
||||
'admin_logs', self._build_logs_menu, UserRole.SUPER_ADMIN, 'super_admin',
|
||||
'Системные логи'
|
||||
)
|
||||
self.register_menu(
|
||||
'admin_restart', self._build_restart_menu, UserRole.SUPER_ADMIN, 'super_admin',
|
||||
'Управление перезапуском'
|
||||
)
|
||||
|
||||
def register_menu(self, menu_id: str, menu_builder: Callable, required_role: UserRole,
|
||||
level: str, description: str = ""):
|
||||
"""
|
||||
Регистрация нового меню.
|
||||
|
||||
Args:
|
||||
menu_id: Уникальный идентификатор меню
|
||||
menu_builder: Функция-строитель меню
|
||||
required_role: Минимальная требуемая роль
|
||||
level: Уровень доступа меню
|
||||
description: Описание меню
|
||||
"""
|
||||
menu_config = MenuConfig(menu_id, menu_builder, required_role, level, description)
|
||||
self.menus[menu_id] = menu_config
|
||||
|
||||
if level not in self.menu_levels:
|
||||
self.menu_levels[level] = []
|
||||
self.menu_levels[level].append(menu_id)
|
||||
|
||||
self.logger.debug(f"Registered menu: {menu_id} (level: {level}, role: {required_role.value})")
|
||||
|
||||
async def get_menu_for_user(self, menu_id: str, user_role: UserRole,
|
||||
**context) -> Optional[InlineKeyboardMarkup]:
|
||||
"""
|
||||
Получение меню для пользователя с учетом его роли.
|
||||
|
||||
Args:
|
||||
menu_id: Идентификатор меню
|
||||
user_role: Роль пользователя
|
||||
**context: Дополнительный контекст для построения меню
|
||||
|
||||
Returns:
|
||||
InlineKeyboardMarkup или None если доступ запрещен
|
||||
"""
|
||||
# Проверка доступности меню
|
||||
if not self.is_menu_available(menu_id, user_role):
|
||||
self.logger.warning(f"Menu {menu_id} not available for role {user_role.value}")
|
||||
return None
|
||||
|
||||
# Получение конфигурации меню
|
||||
menu_config = self.menus.get(menu_id)
|
||||
if not menu_config:
|
||||
self.logger.error(f"Menu configuration not found: {menu_id}")
|
||||
return None
|
||||
|
||||
try:
|
||||
# Построение меню
|
||||
menu = await menu_config.builder(user_role, **context)
|
||||
|
||||
# Кеширование результата
|
||||
cache_key = f"{menu_id}_{user_role.value}_{hash(str(context))}"
|
||||
self.menu_cache[cache_key] = menu
|
||||
|
||||
return menu
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error building menu {menu_id}: {e}")
|
||||
return None
|
||||
|
||||
def is_menu_available(self, menu_id: str, user_role: UserRole) -> bool:
|
||||
"""
|
||||
Проверка доступности меню для роли пользователя.
|
||||
|
||||
Args:
|
||||
menu_id: Идентификатор меню
|
||||
user_role: Роль пользователя
|
||||
|
||||
Returns:
|
||||
True если меню доступно
|
||||
"""
|
||||
menu_config = self.menus.get(menu_id)
|
||||
if not menu_config:
|
||||
return False
|
||||
|
||||
# Проверка иерархии ролей (USER < MODERATOR < ADMIN < SUPER_ADMIN)
|
||||
role_hierarchy = {
|
||||
UserRole.USER: 1,
|
||||
UserRole.MODERATOR: 2,
|
||||
UserRole.ADMIN: 3,
|
||||
UserRole.SUPER_ADMIN: 4
|
||||
}
|
||||
|
||||
user_level = role_hierarchy.get(user_role, 0)
|
||||
required_level = role_hierarchy.get(menu_config.required_role, 999)
|
||||
|
||||
return user_level >= required_level
|
||||
|
||||
def get_available_menus_for_role(self, user_role: UserRole) -> List[str]:
|
||||
"""
|
||||
Получение списка доступных меню для роли.
|
||||
|
||||
Args:
|
||||
user_role: Роль пользователя
|
||||
|
||||
Returns:
|
||||
Список идентификаторов доступных меню
|
||||
"""
|
||||
available_menus = []
|
||||
|
||||
for menu_id, menu_config in self.menus.items():
|
||||
if self.is_menu_available(menu_id, user_role):
|
||||
available_menus.append(menu_id)
|
||||
|
||||
return sorted(available_menus)
|
||||
|
||||
def get_menus_by_level(self, level: str) -> List[str]:
|
||||
"""
|
||||
Получение меню определенного уровня.
|
||||
|
||||
Args:
|
||||
level: Уровень доступа
|
||||
|
||||
Returns:
|
||||
Список идентификаторов меню уровня
|
||||
"""
|
||||
return self.menu_levels.get(level, [])
|
||||
|
||||
def clear_cache(self):
|
||||
"""Очистка кеша меню"""
|
||||
self.menu_cache.clear()
|
||||
self.logger.debug("Menu cache cleared")
|
||||
|
||||
# Методы построения стандартных меню
|
||||
|
||||
async def _build_main_menu(self, user_role: UserRole, **context) -> InlineKeyboardMarkup:
|
||||
"""Построение главного меню"""
|
||||
chat_type = context.get('chat_type', 'private')
|
||||
return self.formatter.create_main_menu(user_role.value, chat_type)
|
||||
|
||||
async def _build_help_menu(self, user_role: UserRole, **context) -> InlineKeyboardMarkup:
|
||||
"""Построение меню помощи"""
|
||||
# Базовые кнопки для всех
|
||||
keyboard = [
|
||||
[InlineKeyboardButton("📋 Помощь", callback_data='menu_help')],
|
||||
[InlineKeyboardButton("🎮 Игры", callback_data='menu_games')],
|
||||
[InlineKeyboardButton("💰 Донат", callback_data='menu_donate')],
|
||||
[InlineKeyboardButton("🏆 Лидеры", callback_data='menu_leaderboard')]
|
||||
]
|
||||
|
||||
# Добавление кнопок в зависимости от роли
|
||||
if user_role in [UserRole.MODERATOR, UserRole.ADMIN, UserRole.SUPER_ADMIN]:
|
||||
keyboard.insert(1, [InlineKeyboardButton("🛡️ Модерация", callback_data='menu_moderation')])
|
||||
|
||||
if user_role in [UserRole.ADMIN, UserRole.SUPER_ADMIN]:
|
||||
keyboard.insert(2, [InlineKeyboardButton("👑 Админ", callback_data='menu_admin')])
|
||||
|
||||
keyboard.append([InlineKeyboardButton("❌ Закрыть", callback_data='close_menu')])
|
||||
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
async def _build_games_menu(self, user_role: UserRole, **context) -> InlineKeyboardMarkup:
|
||||
"""Построение меню игр"""
|
||||
chat_type = context.get('chat_type', 'private')
|
||||
return self.formatter.create_games_menu(chat_type)
|
||||
|
||||
async def _build_donate_menu(self, user_role: UserRole, **context) -> InlineKeyboardMarkup:
|
||||
"""Построение меню донатов"""
|
||||
return self.formatter.create_donation_menu()
|
||||
|
||||
async def _build_leaderboard_menu(self, user_role: UserRole, **context) -> InlineKeyboardMarkup:
|
||||
"""Построение меню таблицы лидеров"""
|
||||
# Здесь будет логика получения и отображения лидеров
|
||||
keyboard = [
|
||||
[InlineKeyboardButton("📊 Общая таблица", callback_data='leaderboard_general')],
|
||||
[InlineKeyboardButton("🎮 По играм", callback_data='leaderboard_games')],
|
||||
[InlineKeyboardButton("⬅️ Назад", callback_data='menu_main')]
|
||||
]
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
async def _build_moderation_menu(self, user_role: UserRole, **context) -> InlineKeyboardMarkup:
|
||||
"""Построение меню модерации"""
|
||||
return self.formatter.create_moderation_menu(user_role.value)
|
||||
|
||||
async def _build_warnings_menu(self, user_role: UserRole, **context) -> InlineKeyboardMarkup:
|
||||
"""Построение меню предупреждений"""
|
||||
keyboard = [
|
||||
[InlineKeyboardButton("⚠️ Активные предупреждения", callback_data='warnings_active')],
|
||||
[InlineKeyboardButton("📋 История", callback_data='warnings_history')],
|
||||
[InlineKeyboardButton("🔍 Поиск пользователя", callback_data='warnings_search')],
|
||||
[InlineKeyboardButton("⬅️ Назад", callback_data='menu_moderation')]
|
||||
]
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
async def _build_mutes_menu(self, user_role: UserRole, **context) -> InlineKeyboardMarkup:
|
||||
"""Построение меню заглушек"""
|
||||
keyboard = [
|
||||
[InlineKeyboardButton("🔇 Активные заглушки", callback_data='mutes_active')],
|
||||
[InlineKeyboardButton("📋 История", callback_data='mutes_history')],
|
||||
[InlineKeyboardButton("🔍 Поиск пользователя", callback_data='mutes_search')],
|
||||
[InlineKeyboardButton("⬅️ Назад", callback_data='menu_moderation')]
|
||||
]
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
async def _build_mod_stats_menu(self, user_role: UserRole, **context) -> InlineKeyboardMarkup:
|
||||
"""Построение меню статистики модерации"""
|
||||
keyboard = [
|
||||
[InlineKeyboardButton("📊 Общая статистика", callback_data='mod_stats_general')],
|
||||
[InlineKeyboardButton("👥 По пользователям", callback_data='mod_stats_users')],
|
||||
[InlineKeyboardButton("📅 По периодам", callback_data='mod_stats_periods')],
|
||||
[InlineKeyboardButton("⬅️ Назад", callback_data='menu_moderation')]
|
||||
]
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
async def _build_admin_menu(self, user_role: UserRole, **context) -> InlineKeyboardMarkup:
|
||||
"""Построение меню администрирования"""
|
||||
return self.formatter.create_admin_menu(user_role.value)
|
||||
|
||||
async def _build_admin_users_menu(self, user_role: UserRole, **context) -> InlineKeyboardMarkup:
|
||||
"""Построение меню управления пользователями"""
|
||||
keyboard = [
|
||||
[InlineKeyboardButton("👥 Список пользователей", callback_data='admin_users_list')],
|
||||
[InlineKeyboardButton("🔍 Поиск пользователя", callback_data='admin_users_search')],
|
||||
[InlineKeyboardButton("📊 Статистика пользователей", callback_data='admin_users_stats')],
|
||||
[InlineKeyboardButton("⬅️ Назад", callback_data='menu_admin')]
|
||||
]
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
async def _build_admin_stats_menu(self, user_role: UserRole, **context) -> InlineKeyboardMarkup:
|
||||
"""Построение меню статистики администрирования"""
|
||||
keyboard = [
|
||||
[InlineKeyboardButton("📊 Общая статистика", callback_data='admin_stats_general')],
|
||||
[InlineKeyboardButton("🎮 Статистика игр", callback_data='admin_stats_games')],
|
||||
[InlineKeyboardButton("💰 Статистика донатов", callback_data='admin_stats_donates')],
|
||||
[InlineKeyboardButton("⬅️ Назад", callback_data='menu_admin')]
|
||||
]
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
async def _build_admin_settings_menu(self, user_role: UserRole, **context) -> InlineKeyboardMarkup:
|
||||
"""Построение меню настроек"""
|
||||
keyboard = [
|
||||
[InlineKeyboardButton("🤖 Настройки бота", callback_data='admin_settings_bot')],
|
||||
[InlineKeyboardButton("🛡️ Безопасность", callback_data='admin_settings_security')],
|
||||
[InlineKeyboardButton("🔧 Технические настройки", callback_data='admin_settings_tech')],
|
||||
[InlineKeyboardButton("⬅️ Назад", callback_data='menu_admin')]
|
||||
]
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
async def _build_admin_errors_menu(self, user_role: UserRole, **context) -> InlineKeyboardMarkup:
|
||||
"""Построение меню отчетов об ошибках"""
|
||||
keyboard = [
|
||||
[InlineKeyboardButton("🐛 Последние ошибки", callback_data='admin_errors_recent')],
|
||||
[InlineKeyboardButton("📊 Статистика ошибок", callback_data='admin_errors_stats')],
|
||||
[InlineKeyboardButton("🔍 Поиск ошибок", callback_data='admin_errors_search')],
|
||||
[InlineKeyboardButton("⬅️ Назад", callback_data='menu_admin')]
|
||||
]
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
async def _build_system_menu(self, user_role: UserRole, **context) -> InlineKeyboardMarkup:
|
||||
"""Построение меню системных настроек"""
|
||||
keyboard = [
|
||||
[InlineKeyboardButton("⚙️ Конфигурация системы", callback_data='admin_system_config')],
|
||||
[InlineKeyboardButton("💾 Резервное копирование", callback_data='admin_system_backup')],
|
||||
[InlineKeyboardButton("🔄 Обновления", callback_data='admin_system_updates')],
|
||||
[InlineKeyboardButton("⬅️ Назад", callback_data='menu_admin')]
|
||||
]
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
async def _build_logs_menu(self, user_role: UserRole, **context) -> InlineKeyboardMarkup:
|
||||
"""Построение меню системных логов"""
|
||||
keyboard = [
|
||||
[InlineKeyboardButton("📋 Логи приложения", callback_data='admin_logs_app')],
|
||||
[InlineKeyboardButton("🔗 Логи базы данных", callback_data='admin_logs_db')],
|
||||
[InlineKeyboardButton("🌐 Логи API", callback_data='admin_logs_api')],
|
||||
[InlineKeyboardButton("⬅️ Назад", callback_data='menu_admin')]
|
||||
]
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
async def _build_restart_menu(self, user_role: UserRole, **context) -> InlineKeyboardMarkup:
|
||||
"""Построение меню управления перезапуском"""
|
||||
keyboard = [
|
||||
[InlineKeyboardButton("🔄 Мягкий перезапуск", callback_data='admin_restart_soft')],
|
||||
[InlineKeyboardButton("💥 Полный перезапуск", callback_data='admin_restart_hard')],
|
||||
[InlineKeyboardButton("⚠️ Экстренная остановка", callback_data='admin_restart_emergency')],
|
||||
[InlineKeyboardButton("⬅️ Назад", callback_data='menu_admin')]
|
||||
]
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
async def _build_triggers_menu(self, user_role: UserRole, **context) -> InlineKeyboardMarkup:
|
||||
"""Построение меню управления триггерами"""
|
||||
keyboard = [
|
||||
[InlineKeyboardButton("📋 Список триггеров", callback_data='triggers_list')],
|
||||
[InlineKeyboardButton("➕ Создать триггер", callback_data='triggers_create')],
|
||||
[InlineKeyboardButton("📝 Шаблоны триггеров", callback_data='triggers_templates')],
|
||||
[InlineKeyboardButton("📊 Статистика триггеров", callback_data='triggers_stats')],
|
||||
[InlineKeyboardButton("⬅️ Назад", callback_data='menu_admin')]
|
||||
]
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
|
||||
# Глобальный экземпляр менеджера меню
|
||||
menu_manager = None
|
||||
|
||||
def create_menu_manager(permission_manager_instance, formatter: KeyboardFormatter):
|
||||
"""
|
||||
Создание глобального экземпляра менеджера меню.
|
||||
|
||||
Args:
|
||||
permission_manager_instance: Экземпляр менеджера разрешений
|
||||
formatter: Форматтер клавиатур
|
||||
|
||||
Returns:
|
||||
ContextMenuManager: Настроенный менеджер меню
|
||||
"""
|
||||
global menu_manager
|
||||
menu_manager = ContextMenuManager(permission_manager_instance, formatter)
|
||||
return menu_manager
|
||||
@@ -0,0 +1,517 @@
|
||||
"""
|
||||
Маршрутизатор сообщений по типам с учетом прав доступа.
|
||||
Отвечает за разделение обработки различных типов сообщений.
|
||||
"""
|
||||
|
||||
import re
|
||||
import logging
|
||||
from typing import Dict, List, Callable, Pattern, Any, Optional
|
||||
from telegram import Update
|
||||
from telegram.ext import ContextTypes
|
||||
from .permissions import UserRole, permission_manager
|
||||
from services.trigger_service import TriggerService
|
||||
|
||||
|
||||
class MessageHandlerConfig:
|
||||
"""Конфигурация обработчика сообщений"""
|
||||
def __init__(self, handler: Callable, required_role: UserRole = UserRole.USER,
|
||||
priority: int = 0, description: str = ""):
|
||||
self.handler = handler
|
||||
self.required_role = required_role
|
||||
self.priority = priority # Приоритет для сортировки (выше = раньше)
|
||||
self.description = description
|
||||
|
||||
|
||||
class MessageTypeRouter:
|
||||
"""
|
||||
Маршрутизатор сообщений по типам.
|
||||
|
||||
Обеспечивает:
|
||||
- Разделение обработки по типам контента
|
||||
- Регистрацию обработчиков с учетом прав
|
||||
- Приоритетную обработку сообщений
|
||||
- Валидацию доступа к обработчикам
|
||||
"""
|
||||
|
||||
def __init__(self, trigger_service: Optional[TriggerService] = None):
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.trigger_service = trigger_service
|
||||
|
||||
# Обработчики текстовых сообщений (с паттернами)
|
||||
self.text_handlers: Dict[Pattern, MessageHandlerConfig] = {}
|
||||
|
||||
# Обработчики медиа-сообщений
|
||||
self.media_handlers: Dict[str, MessageHandlerConfig] = {}
|
||||
self.voice_handlers: List[MessageHandlerConfig] = []
|
||||
self.audio_handlers: List[MessageHandlerConfig] = []
|
||||
self.document_handlers: List[MessageHandlerConfig] = []
|
||||
self.video_handlers: List[MessageHandlerConfig] = []
|
||||
self.photo_handlers: List[MessageHandlerConfig] = []
|
||||
|
||||
# Обработчики callback запросов
|
||||
self.callback_handlers: Dict[str, MessageHandlerConfig] = {}
|
||||
|
||||
# Обработчики инлайновых запросов
|
||||
self.inline_handlers: List[MessageHandlerConfig] = []
|
||||
|
||||
def register_text_handler(self, pattern: str, handler: Callable,
|
||||
required_role: UserRole = UserRole.USER,
|
||||
priority: int = 0, description: str = ""):
|
||||
"""
|
||||
Регистрация обработчика текстовых сообщений.
|
||||
|
||||
Args:
|
||||
pattern: Регулярное выражение для сопоставления текста
|
||||
handler: Функция обработчик
|
||||
required_role: Требуемая роль
|
||||
priority: Приоритет (выше = обрабатывается раньше)
|
||||
description: Описание обработчика
|
||||
"""
|
||||
try:
|
||||
compiled_pattern = re.compile(pattern, re.IGNORECASE | re.MULTILINE)
|
||||
config = MessageHandlerConfig(handler, required_role, priority, description)
|
||||
self.text_handlers[compiled_pattern] = config
|
||||
self.logger.debug(f"Registered text handler: {pattern} (role: {required_role.value})")
|
||||
except re.error as e:
|
||||
self.logger.error(f"Invalid regex pattern '{pattern}': {e}")
|
||||
|
||||
def register_media_handler(self, media_type: str, handler: Callable,
|
||||
required_role: UserRole = UserRole.USER, description: str = ""):
|
||||
"""
|
||||
Регистрация обработчика медиа-сообщений.
|
||||
|
||||
Args:
|
||||
media_type: Тип медиа (photo, video, audio, document, voice)
|
||||
handler: Функция обработчик
|
||||
required_role: Требуемая роль
|
||||
description: Описание обработчика
|
||||
"""
|
||||
config = MessageHandlerConfig(handler, required_role, description=description)
|
||||
|
||||
if media_type == 'photo':
|
||||
self.photo_handlers.append(config)
|
||||
elif media_type == 'video':
|
||||
self.video_handlers.append(config)
|
||||
elif media_type == 'audio':
|
||||
self.audio_handlers.append(config)
|
||||
elif media_type == 'document':
|
||||
self.document_handlers.append(config)
|
||||
elif media_type == 'voice':
|
||||
self.voice_handlers.append(config)
|
||||
else:
|
||||
self.media_handlers[media_type] = config
|
||||
|
||||
self.logger.debug(f"Registered media handler: {media_type} (role: {required_role.value})")
|
||||
|
||||
def register_callback_handler(self, callback_pattern: str, handler: Callable,
|
||||
required_role: UserRole = UserRole.USER, description: str = ""):
|
||||
"""
|
||||
Регистрация обработчика callback запросов.
|
||||
|
||||
Args:
|
||||
callback_pattern: Паттерн callback данных
|
||||
handler: Функция обработчик
|
||||
required_role: Требуемая роль
|
||||
description: Описание обработчика
|
||||
"""
|
||||
config = MessageHandlerConfig(handler, required_role, description=description)
|
||||
self.callback_handlers[callback_pattern] = config
|
||||
self.logger.debug(f"Registered callback handler: {callback_pattern} (role: {required_role.value})")
|
||||
|
||||
def register_inline_handler(self, handler: Callable,
|
||||
required_role: UserRole = UserRole.USER, description: str = ""):
|
||||
"""
|
||||
Регистрация обработчика инлайновых запросов.
|
||||
|
||||
Args:
|
||||
handler: Функция обработчик
|
||||
required_role: Требуемая роль
|
||||
description: Описание обработчика
|
||||
"""
|
||||
config = MessageHandlerConfig(handler, required_role, description=description)
|
||||
self.inline_handlers.append(config)
|
||||
self.logger.debug(f"Registered inline handler (role: {required_role.value})")
|
||||
|
||||
async def route_text_message(self, update: Update, context: ContextTypes) -> bool:
|
||||
"""
|
||||
Маршрутизация текстовых сообщений.
|
||||
|
||||
Args:
|
||||
update: Обновление от Telegram
|
||||
context: Контекст бота
|
||||
|
||||
Returns:
|
||||
True если сообщение было обработано
|
||||
"""
|
||||
if not update.message or not update.message.text:
|
||||
return False
|
||||
|
||||
text = update.message.text.strip()
|
||||
if not text:
|
||||
return False
|
||||
|
||||
# Определяем тип чата
|
||||
chat_type = "private" if update.message.chat.type == "private" else "group"
|
||||
|
||||
# Проверяем триггеры только в групповых чатах
|
||||
if chat_type == "group" and self.trigger_service:
|
||||
try:
|
||||
matched_triggers = await self.trigger_service.check_triggers(text, chat_type)
|
||||
if matched_triggers:
|
||||
await self._execute_trigger_actions(matched_triggers, update, context)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error checking triggers: {e}")
|
||||
|
||||
# Получение роли пользователя
|
||||
user_role = await self._get_user_role(update)
|
||||
|
||||
# Сортировка обработчиков по приоритету (выше приоритет = раньше)
|
||||
sorted_handlers = sorted(
|
||||
self.text_handlers.items(),
|
||||
key=lambda x: x[1].priority,
|
||||
reverse=True
|
||||
)
|
||||
|
||||
# Поиск подходящего обработчика
|
||||
for pattern, config in sorted_handlers:
|
||||
if pattern.search(text):
|
||||
# Проверка прав доступа
|
||||
if not self._check_permission(user_role, config.required_role):
|
||||
continue
|
||||
|
||||
try:
|
||||
self.logger.debug(f"Routing text message to handler: {config.description}")
|
||||
await config.handler(update, context)
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in text handler: {e}")
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
async def route_media_message(self, update: Update, context: ContextTypes) -> bool:
|
||||
"""
|
||||
Маршрутизация медиа-сообщений.
|
||||
|
||||
Args:
|
||||
update: Обновление от Telegram
|
||||
context: Контекст бота
|
||||
|
||||
Returns:
|
||||
True если сообщение было обработано
|
||||
"""
|
||||
if not update.message:
|
||||
return False
|
||||
|
||||
message = update.message
|
||||
user_role = await self._get_user_role(update)
|
||||
|
||||
# Определение типа медиа и выбор обработчиков
|
||||
handlers_to_check = []
|
||||
|
||||
if message.photo:
|
||||
handlers_to_check = self.photo_handlers
|
||||
elif message.video:
|
||||
handlers_to_check = self.video_handlers
|
||||
elif message.audio:
|
||||
handlers_to_check = self.audio_handlers
|
||||
elif message.voice:
|
||||
handlers_to_check = self.voice_handlers
|
||||
elif message.document:
|
||||
handlers_to_check = self.document_handlers
|
||||
|
||||
# Обработка первым доступным обработчиком
|
||||
for config in handlers_to_check:
|
||||
if self._check_permission(user_role, config.required_role):
|
||||
try:
|
||||
self.logger.debug(f"Routing media message to handler: {config.description}")
|
||||
await config.handler(update, context)
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in media handler: {e}")
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
async def route_voice_message(self, update: Update, context: ContextTypes) -> bool:
|
||||
"""
|
||||
Маршрутизация голосовых сообщений.
|
||||
|
||||
Args:
|
||||
update: Обновление от Telegram
|
||||
context: Контекст бота
|
||||
|
||||
Returns:
|
||||
True если сообщение было обработано
|
||||
"""
|
||||
if not update.message or not update.message.voice:
|
||||
return False
|
||||
|
||||
user_role = await self._get_user_role(update)
|
||||
|
||||
for config in self.voice_handlers:
|
||||
if self._check_permission(user_role, config.required_role):
|
||||
try:
|
||||
self.logger.debug(f"Routing voice message to handler: {config.description}")
|
||||
await config.handler(update, context)
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in voice handler: {e}")
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
async def route_callback(self, update: Update, context: ContextTypes) -> bool:
|
||||
"""
|
||||
Маршрутизация callback запросов.
|
||||
|
||||
Args:
|
||||
update: Обновление от Telegram
|
||||
context: Контекст бота
|
||||
|
||||
Returns:
|
||||
True если callback был обработан
|
||||
"""
|
||||
if not update.callback_query or not update.callback_query.data:
|
||||
return False
|
||||
|
||||
callback_data = update.callback_query.data
|
||||
user_role = await self._get_user_role(update)
|
||||
|
||||
# Поиск точного совпадения
|
||||
if callback_data in self.callback_handlers:
|
||||
config = self.callback_handlers[callback_data]
|
||||
if self._check_permission(user_role, config.required_role):
|
||||
try:
|
||||
self.logger.debug(f"Routing callback to handler: {config.description}")
|
||||
await config.handler(update, context)
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in callback handler: {e}")
|
||||
return False
|
||||
|
||||
# Поиск по префиксу
|
||||
for pattern, config in self.callback_handlers.items():
|
||||
if callback_data.startswith(pattern) and pattern != callback_data:
|
||||
if self._check_permission(user_role, config.required_role):
|
||||
try:
|
||||
self.logger.debug(f"Routing callback (prefix) to handler: {config.description}")
|
||||
await config.handler(update, context)
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in callback handler: {e}")
|
||||
return False
|
||||
|
||||
self.logger.debug(f"No handler found for callback: {callback_data}")
|
||||
return False
|
||||
|
||||
async def route_inline_query(self, update: Update, context: ContextTypes) -> bool:
|
||||
"""
|
||||
Маршрутизация инлайновых запросов.
|
||||
|
||||
Args:
|
||||
update: Обновление от Telegram
|
||||
context: Контекст бота
|
||||
|
||||
Returns:
|
||||
True если запрос был обработан
|
||||
"""
|
||||
if not update.inline_query:
|
||||
return False
|
||||
|
||||
user_role = await self._get_user_role(update)
|
||||
|
||||
for config in self.inline_handlers:
|
||||
if self._check_permission(user_role, config.required_role):
|
||||
try:
|
||||
self.logger.debug(f"Routing inline query to handler: {config.description}")
|
||||
await config.handler(update, context)
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in inline handler: {e}")
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
async def _get_user_role(self, update) -> UserRole:
|
||||
"""Получение роли пользователя"""
|
||||
try:
|
||||
user = update.effective_user
|
||||
if user:
|
||||
# Интеграция с permission_manager для получения эффективной роли
|
||||
# Попытка получить реальный конфиг из Application
|
||||
try:
|
||||
from .application import app_instance
|
||||
if app_instance and hasattr(app_instance, 'config'):
|
||||
config = app_instance.config
|
||||
else:
|
||||
# Создаем минимальный конфиг для работы 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()
|
||||
except ImportError:
|
||||
# Создаем минимальный конфиг если Application недоступен
|
||||
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 await permission_manager.get_effective_role(update, user.id, config)
|
||||
return UserRole.USER
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting user role: {e}")
|
||||
return UserRole.USER
|
||||
|
||||
def _check_permission(self, user_role: UserRole, required_role: UserRole) -> bool:
|
||||
"""Проверка прав доступа"""
|
||||
# permission_manager.has_permission проверяет наличие required_role у user_role
|
||||
# Но нам нужно проверить, что user_role >= required_role
|
||||
# Для этого сравниваем приоритеты ролей
|
||||
role_priority = {
|
||||
UserRole.SUPER_ADMIN: 4,
|
||||
UserRole.ADMIN: 3,
|
||||
UserRole.MODERATOR: 2,
|
||||
UserRole.USER: 1
|
||||
}
|
||||
|
||||
user_priority = role_priority.get(user_role, 0)
|
||||
required_priority = role_priority.get(required_role, 0)
|
||||
|
||||
return user_priority >= required_priority
|
||||
|
||||
def get_registered_handlers_count(self) -> Dict[str, int]:
|
||||
"""Получение количества зарегистрированных обработчиков"""
|
||||
return {
|
||||
'text': len(self.text_handlers),
|
||||
'media': len(self.media_handlers),
|
||||
'voice': len(self.voice_handlers),
|
||||
'audio': len(self.audio_handlers),
|
||||
'document': len(self.document_handlers),
|
||||
'video': len(self.video_handlers),
|
||||
'photo': len(self.photo_handlers),
|
||||
'callback': len(self.callback_handlers),
|
||||
'inline': len(self.inline_handlers)
|
||||
}
|
||||
|
||||
# Глобальный экземпляр маршрутизатора
|
||||
message_router = MessageTypeRouter()
|
||||
|
||||
def create_message_router(trigger_service: Optional[TriggerService] = None) -> MessageTypeRouter:
|
||||
"""Создание экземпляра маршрутизатора сообщений"""
|
||||
return MessageTypeRouter(trigger_service)
|
||||
|
||||
async def _execute_trigger_actions(self, matched_triggers: List[Dict], update: Update, context: ContextTypes):
|
||||
"""
|
||||
Выполнение действий сработавших триггеров.
|
||||
|
||||
Args:
|
||||
matched_triggers: Список сработавших триггеров
|
||||
update: Обновление от Telegram
|
||||
context: Контекст бота
|
||||
"""
|
||||
if not self.trigger_service:
|
||||
return
|
||||
|
||||
try:
|
||||
chat_id = update.message.chat_id
|
||||
message_text = update.message.text
|
||||
|
||||
# Получаем действия для выполнения
|
||||
actions = await self.trigger_service.execute_trigger_actions(
|
||||
matched_triggers, chat_id, message_text, update.message.message_id
|
||||
)
|
||||
|
||||
# Выполняем действия с учетом приоритета для реакций
|
||||
reaction_actions = [a for a in actions if a.get('type') == 'reaction']
|
||||
other_actions = [a for a in actions if a.get('type') != 'reaction']
|
||||
|
||||
# Обрабатываем реакции по приоритету (только первую реакцию)
|
||||
if reaction_actions:
|
||||
# Сортируем по trigger_id для детерминированного выбора при равном приоритете
|
||||
reaction_actions.sort(key=lambda x: x.get('trigger_id', 0))
|
||||
# Выполняем только первую реакцию
|
||||
try:
|
||||
await self._perform_trigger_action(reaction_actions[0], update, context)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error performing reaction action: {e}")
|
||||
|
||||
# Выполняем остальные действия
|
||||
for action in other_actions:
|
||||
try:
|
||||
await self._perform_trigger_action(action, update, context)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error performing trigger action {action.get('type')}: {e}")
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error executing trigger actions: {e}")
|
||||
|
||||
async def _perform_trigger_action(self, action: Dict, update: Update, context: ContextTypes):
|
||||
"""
|
||||
Выполнение конкретного действия триггера.
|
||||
|
||||
Args:
|
||||
action: Данные действия
|
||||
update: Обновление от Telegram
|
||||
context: Контекст бота
|
||||
"""
|
||||
action_type = action.get('type')
|
||||
content = action.get('content')
|
||||
|
||||
if action_type == 'text':
|
||||
await context.bot.send_message(
|
||||
chat_id=update.message.chat_id,
|
||||
text=content,
|
||||
reply_to_message_id=update.message.message_id
|
||||
)
|
||||
elif action_type == 'sticker':
|
||||
await context.bot.send_sticker(
|
||||
chat_id=update.message.chat_id,
|
||||
sticker=content,
|
||||
reply_to_message_id=update.message.message_id
|
||||
)
|
||||
elif action_type == 'gif':
|
||||
await context.bot.send_animation(
|
||||
chat_id=update.message.chat_id,
|
||||
animation=content,
|
||||
reply_to_message_id=update.message.message_id
|
||||
)
|
||||
elif action_type == 'reaction':
|
||||
# Реакция на сообщение
|
||||
if action.get('reaction_type') == 'emoji' and action.get('message_id'):
|
||||
try:
|
||||
# Используем set_reaction для установки реакции
|
||||
await context.bot.set_message_reaction(
|
||||
chat_id=update.message.chat_id,
|
||||
message_id=action['message_id'],
|
||||
reaction=content # emoji
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to set reaction: {e}")
|
||||
|
||||
self.logger.debug(f"Executed trigger action: {action_type} for trigger '{action.get('trigger_name', 'unknown')}'")
|
||||
|
||||
def set_trigger_service(self, trigger_service: TriggerService):
|
||||
"""
|
||||
Установка сервиса триггеров.
|
||||
|
||||
Args:
|
||||
trigger_service: Экземпляр TriggerService
|
||||
"""
|
||||
self.trigger_service = trigger_service
|
||||
self.logger.info("Trigger service set for message router")
|
||||
@@ -0,0 +1,382 @@
|
||||
"""
|
||||
Middleware для обработки команд и проверки прав доступа.
|
||||
Реализует цепочку middleware: валидация → логирование → авторизация → обработка.
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
from typing import Dict, Any, Optional, Callable, Awaitable
|
||||
from telegram import Update
|
||||
from telegram.ext import ContextTypes
|
||||
from .permissions import permission_manager, UserRole
|
||||
from .exceptions import PermissionError, ValidationError
|
||||
from .rate_limiter import rate_limiter, RateLimitExceeded
|
||||
|
||||
|
||||
class MiddlewareContext:
|
||||
"""
|
||||
Контекст выполнения middleware.
|
||||
Содержит информацию о текущем запросе и пользователе.
|
||||
"""
|
||||
|
||||
def __init__(self, update: Update, context: ContextTypes):
|
||||
self.update = update
|
||||
self.context = context
|
||||
self.user_id: Optional[int] = None
|
||||
self.user_role: Optional[UserRole] = None
|
||||
self.command: Optional[str] = None
|
||||
self.args: list = []
|
||||
self.metadata: Dict[str, Any] = {}
|
||||
self.registration_time: Optional[float] = None # Время регистрации пользователя
|
||||
|
||||
async def initialize(self, config):
|
||||
"""Инициализация контекста"""
|
||||
# Получаем информацию о пользователе
|
||||
if hasattr(self.update, 'effective_user') and self.update.effective_user:
|
||||
user = self.update.effective_user
|
||||
self.user_id = user.id
|
||||
# Используем тот же конфиг, что и в Application
|
||||
self.user_role = await permission_manager.get_effective_role(
|
||||
self.update, self.user_id, config
|
||||
)
|
||||
print(f"[DEBUG] MiddlewareContext.initialize: user {self.user_id} role = {self.user_role}, config type: {type(config)}")
|
||||
if hasattr(config, 'bot_config'):
|
||||
print(f"[DEBUG] Config has super_admin_ids: {config.bot_config.super_admin_ids}")
|
||||
print(f"[DEBUG] Config has admin_ids: {config.bot_config.admin_ids}")
|
||||
|
||||
# Получаем время регистрации пользователя из базы данных
|
||||
# (предполагаем, что есть метод для получения информации о пользователе)
|
||||
try:
|
||||
from ..database import get_user_registration_time
|
||||
self.registration_time = await get_user_registration_time(self.user_id)
|
||||
except Exception:
|
||||
# Если не удается получить время регистрации, оставляем None
|
||||
self.registration_time = None
|
||||
|
||||
# Парсим команду из сообщения или callback
|
||||
if hasattr(self.update, 'message') and self.update.message and hasattr(self.update.message, 'text') and self.update.message.text:
|
||||
text = self.update.message.text.strip()
|
||||
if text.startswith('/'):
|
||||
parts = text.split()
|
||||
self.command = parts[0][1:].split('@')[0] # Убираем слеш и username бота
|
||||
self.args = parts[1:] if len(parts) > 1 else []
|
||||
elif hasattr(self.update, 'callback_query') and self.update.callback_query and hasattr(self.update.callback_query, 'data') and self.update.callback_query.data:
|
||||
# Для callback'ов парсим команду из callback_data
|
||||
callback_data = self.update.callback_query.data
|
||||
# Если callback_data соответствует команде меню или действия, устанавливаем как команду
|
||||
if callback_data.startswith(('menu_', 'donate_')) or callback_data in ['help', 'rank', 'leaderboard', 'info', 'start']:
|
||||
self.command = callback_data
|
||||
self.args = []
|
||||
|
||||
|
||||
class MiddlewareResult:
|
||||
"""Результат выполнения middleware"""
|
||||
|
||||
def __init__(self, success: bool = True, error_message: str = None, stop_processing: bool = False):
|
||||
self.success = success
|
||||
self.error_message = error_message
|
||||
self.stop_processing = stop_processing
|
||||
|
||||
@classmethod
|
||||
def success(cls):
|
||||
"""Успешный результат"""
|
||||
return cls(success=True)
|
||||
|
||||
@classmethod
|
||||
def error(cls, message: str, stop_processing: bool = True):
|
||||
"""Результат с ошибкой"""
|
||||
return cls(success=False, error_message=message, stop_processing=stop_processing)
|
||||
|
||||
@classmethod
|
||||
def stop(cls):
|
||||
"""Остановить обработку без ошибки"""
|
||||
return cls(success=True, stop_processing=True)
|
||||
|
||||
|
||||
class BaseMiddleware:
|
||||
"""
|
||||
Базовый класс для middleware.
|
||||
"""
|
||||
|
||||
async def process(self, context: MiddlewareContext, config) -> MiddlewareResult:
|
||||
"""
|
||||
Обработка запроса в middleware.
|
||||
|
||||
Args:
|
||||
context: Контекст middleware
|
||||
config: Конфигурация приложения
|
||||
|
||||
Returns:
|
||||
Результат обработки
|
||||
"""
|
||||
raise NotImplementedError("Middleware must implement process method")
|
||||
|
||||
|
||||
class ValidationMiddleware(BaseMiddleware):
|
||||
"""
|
||||
Middleware для валидации входящих данных.
|
||||
"""
|
||||
|
||||
def __init__(self, logger=None):
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
|
||||
async def process(self, context: MiddlewareContext, config) -> MiddlewareResult:
|
||||
"""Валидация данных"""
|
||||
try:
|
||||
# Проверяем наличие пользователя
|
||||
if not context.user_id:
|
||||
return MiddlewareResult.error("Не удалось определить пользователя")
|
||||
|
||||
# Проверяем корректность команды
|
||||
if context.command:
|
||||
# Базовая валидация названия команды
|
||||
if not context.command.replace('_', '').isalnum():
|
||||
return MiddlewareResult.error("Неверный формат команды")
|
||||
|
||||
# Проверяем длину аргументов
|
||||
for arg in context.args:
|
||||
if len(arg) > 1000: # Ограничение на длину аргумента
|
||||
return MiddlewareResult.error("Слишком длинный аргумент команды")
|
||||
|
||||
self.logger.debug(f"Validation passed for user {context.user_id}, command: {context.command}")
|
||||
return MiddlewareResult.success()
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Validation error: {e}")
|
||||
return MiddlewareResult.error("Ошибка валидации данных")
|
||||
|
||||
|
||||
class LoggingMiddleware(BaseMiddleware):
|
||||
"""
|
||||
Middleware для логирования действий пользователей.
|
||||
"""
|
||||
|
||||
def __init__(self, logger=None):
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
|
||||
async def process(self, context: MiddlewareContext, config) -> MiddlewareResult:
|
||||
"""Логирование действия"""
|
||||
try:
|
||||
user_info = f"user_{context.user_id}" if context.user_id else "unknown_user"
|
||||
command_info = context.command or "unknown_command"
|
||||
args_info = f" with {len(context.args)} args" if context.args else ""
|
||||
|
||||
self.logger.info(f"Command executed: {user_info} -> /{command_info}{args_info}")
|
||||
|
||||
# Сохраняем время начала в метаданных
|
||||
context.metadata['start_time'] = time.time()
|
||||
|
||||
return MiddlewareResult.success()
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Logging error: {e}")
|
||||
return MiddlewareResult.error("Ошибка логирования")
|
||||
|
||||
|
||||
class AuthorizationMiddleware(BaseMiddleware):
|
||||
"""
|
||||
Middleware для проверки прав доступа.
|
||||
"""
|
||||
|
||||
def __init__(self, logger=None):
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
|
||||
async def process(self, context: MiddlewareContext, config) -> MiddlewareResult:
|
||||
"""Проверка авторизации"""
|
||||
try:
|
||||
# Если команда не указана, пропускаем проверку (например, для callback'ов)
|
||||
if not context.command:
|
||||
self.logger.debug(f"Authorization skipped for user {context.user_id} (no command)")
|
||||
return MiddlewareResult.success()
|
||||
|
||||
# Проверяем права доступа
|
||||
if not permission_manager.can_execute_command(context.user_role, context.command):
|
||||
role_name = context.user_role.value if context.user_role else "unknown"
|
||||
self.logger.warning(f"Access denied: user {context.user_id} (role: {role_name}) "
|
||||
f"tried to execute /{context.command}")
|
||||
return MiddlewareResult.error("У вас нет прав для выполнения этой команды")
|
||||
|
||||
self.logger.debug(f"Authorization passed for user {context.user_id}, command: {context.command}")
|
||||
return MiddlewareResult.success()
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Authorization error: {e}")
|
||||
return MiddlewareResult.error("Ошибка проверки прав доступа")
|
||||
|
||||
|
||||
class RateLimitMiddleware(BaseMiddleware):
|
||||
"""
|
||||
Middleware для ограничения частоты запросов (rate limiting).
|
||||
"""
|
||||
|
||||
def __init__(self, logger=None):
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
|
||||
async def process(self, context: MiddlewareContext, config) -> MiddlewareResult:
|
||||
"""Проверка rate limit"""
|
||||
try:
|
||||
# Пропускаем проверку для не-команд (сообщения, callback'и)
|
||||
if not context.command:
|
||||
return MiddlewareResult.success()
|
||||
|
||||
# Проверяем лимит запросов
|
||||
allowed, retry_after = await rate_limiter.check_limit(
|
||||
context.user_id,
|
||||
context.user_role,
|
||||
context.registration_time
|
||||
)
|
||||
|
||||
if not allowed:
|
||||
self.logger.warning(f"Rate limit exceeded for user {context.user_id}, "
|
||||
f"command: /{context.command}, retry after: {retry_after}s")
|
||||
|
||||
remaining = rate_limiter.get_remaining_requests(
|
||||
context.user_id,
|
||||
context.user_role,
|
||||
context.registration_time
|
||||
)
|
||||
|
||||
if retry_after and retry_after > 0:
|
||||
message = (f"🚫 Превышен лимит запросов. "
|
||||
f"Повторите попытку через {retry_after} сек. "
|
||||
f"(Осталось запросов: {remaining})")
|
||||
else:
|
||||
message = (f"🚫 Превышен лимит запросов. "
|
||||
f"Попробуйте позже. (Осталось запросов: {remaining})")
|
||||
|
||||
return MiddlewareResult.error(message)
|
||||
|
||||
self.logger.debug(f"Rate limit check passed for user {context.user_id}, command: /{context.command}")
|
||||
return MiddlewareResult.success()
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Rate limit check error: {e}")
|
||||
# В случае ошибки разрешаем выполнение, чтобы не блокировать пользователей
|
||||
return MiddlewareResult.success()
|
||||
|
||||
|
||||
class MetricsMiddleware(BaseMiddleware):
|
||||
"""
|
||||
Middleware для сбора метрик производительности.
|
||||
"""
|
||||
|
||||
def __init__(self, metrics_collector=None, logger=None):
|
||||
self.metrics = metrics_collector
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
|
||||
async def process(self, context: MiddlewareContext, config) -> MiddlewareResult:
|
||||
"""Сбор метрик"""
|
||||
try:
|
||||
# Если есть метрики, собираем их
|
||||
if self.metrics and context.command:
|
||||
start_time = context.metadata.get('start_time', time.time())
|
||||
duration = time.time() - start_time
|
||||
|
||||
# Записываем метрики
|
||||
self.metrics.record_command(
|
||||
command=context.command,
|
||||
user_role=context.user_role.value if context.user_role else "unknown",
|
||||
duration=duration
|
||||
)
|
||||
|
||||
self.logger.debug(f"Metrics recorded for command /{context.command}: {duration:.3f}s")
|
||||
|
||||
return MiddlewareResult.success()
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Metrics collection error: {e}")
|
||||
# Не останавливаем обработку из-за ошибки метрик
|
||||
return MiddlewareResult.success()
|
||||
|
||||
|
||||
class MiddlewareChain:
|
||||
"""
|
||||
Цепочка middleware для последовательной обработки запросов.
|
||||
"""
|
||||
|
||||
def __init__(self, config, metrics=None, logger=None):
|
||||
self.config = config
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
|
||||
# Создаем цепочку middleware
|
||||
self.middlewares = [
|
||||
ValidationMiddleware(self.logger),
|
||||
LoggingMiddleware(self.logger),
|
||||
RateLimitMiddleware(self.logger), # Rate limiting после логирования, но перед авторизацией
|
||||
AuthorizationMiddleware(self.logger),
|
||||
MetricsMiddleware(metrics, self.logger)
|
||||
]
|
||||
|
||||
async def process_request(self, update: Update, context: ContextTypes,
|
||||
handler: Callable[[Update, ContextTypes], Awaitable]) -> bool:
|
||||
"""
|
||||
Обработка запроса через цепочку middleware.
|
||||
|
||||
Args:
|
||||
update: Обновление от Telegram
|
||||
context: Контекст бота
|
||||
handler: Финальный обработчик
|
||||
|
||||
Returns:
|
||||
True если обработка должна продолжиться
|
||||
"""
|
||||
# Создаем контекст middleware
|
||||
middleware_context = MiddlewareContext(update, context)
|
||||
await middleware_context.initialize(self.config)
|
||||
|
||||
# Выполняем цепочку middleware
|
||||
for middleware in self.middlewares:
|
||||
try:
|
||||
result = await middleware.process(middleware_context, self.config)
|
||||
|
||||
if not result.success:
|
||||
# Отправляем сообщение об ошибке пользователю
|
||||
if result.error_message:
|
||||
await self._send_error_message(update, result.error_message)
|
||||
return False # Останавливаем обработку
|
||||
|
||||
if result.stop_processing:
|
||||
return False # Останавливаем обработку без ошибки
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Middleware {middleware.__class__.__name__} failed: {e}")
|
||||
await self._send_error_message(update, "Произошла ошибка при обработке запроса")
|
||||
return False
|
||||
|
||||
# Если все middleware прошли успешно, выполняем финальный обработчик
|
||||
try:
|
||||
await handler(update, context)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Handler execution failed: {e}")
|
||||
await self._send_error_message(update, "Произошла ошибка при выполнении команды")
|
||||
return False
|
||||
|
||||
async def _send_error_message(self, update: Update, message: str):
|
||||
"""Отправка сообщения об ошибке"""
|
||||
try:
|
||||
if update.message:
|
||||
await update.message.reply_text(message)
|
||||
elif update.callback_query:
|
||||
await update.callback_query.answer(message, show_alert=True)
|
||||
except Exception as e:
|
||||
if "Query is too old" in str(e) or "timeout expired" in str(e):
|
||||
self.logger.warning(f"Не удалось отправить сообщение об ошибке (query устарел): {e}")
|
||||
else:
|
||||
self.logger.error(f"Failed to send error message: {e}")
|
||||
|
||||
|
||||
# Глобальная функция для создания цепочки middleware
|
||||
def create_middleware_chain(config, metrics=None):
|
||||
"""
|
||||
Создание цепочки middleware.
|
||||
|
||||
Args:
|
||||
config: Конфигурация приложения
|
||||
metrics: Сборщик метрик
|
||||
|
||||
Returns:
|
||||
MiddlewareChain: Настроенная цепочка middleware
|
||||
"""
|
||||
return MiddlewareChain(config, metrics)
|
||||
@@ -0,0 +1,339 @@
|
||||
"""
|
||||
Система мониторинга для телеграм-бота.
|
||||
Включает метрики Prometheus, интеграцию с Sentry и улучшенное логирование.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import json
|
||||
import time
|
||||
import asyncio
|
||||
from functools import wraps
|
||||
from typing import Optional, Dict, Any, Callable
|
||||
from prometheus_client import Counter, Histogram, Gauge, start_http_server
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.logging import LoggingIntegration
|
||||
from .config import Config
|
||||
|
||||
|
||||
class MetricsCollector:
|
||||
"""Сборщик метрик для мониторинга"""
|
||||
|
||||
def __init__(self, config: Config):
|
||||
self.config = config
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
# Инициализируем метрики Prometheus
|
||||
self._init_prometheus_metrics()
|
||||
|
||||
# Инициализируем Sentry если включено
|
||||
self._init_sentry()
|
||||
|
||||
def _init_prometheus_metrics(self):
|
||||
"""Инициализация метрик Prometheus"""
|
||||
# Счетчики ошибок
|
||||
self.error_counter = Counter(
|
||||
'telegram_bot_errors_total',
|
||||
'Total number of errors by type',
|
||||
['error_type', 'handler']
|
||||
)
|
||||
|
||||
# Время выполнения команд
|
||||
self.command_duration = Histogram(
|
||||
'telegram_bot_command_duration_seconds',
|
||||
'Time spent processing commands',
|
||||
['command', 'handler']
|
||||
)
|
||||
|
||||
# Активные пользователи
|
||||
self.active_users = Gauge(
|
||||
'telegram_bot_active_users',
|
||||
'Number of active users'
|
||||
)
|
||||
|
||||
# Общее количество сообщений
|
||||
self.total_messages = Counter(
|
||||
'telegram_bot_messages_total',
|
||||
'Total number of messages processed',
|
||||
['message_type']
|
||||
)
|
||||
|
||||
# Время отклика API
|
||||
self.api_response_time = Histogram(
|
||||
'telegram_bot_api_response_time_seconds',
|
||||
'Response time for external API calls',
|
||||
['api_name']
|
||||
)
|
||||
|
||||
# Статус бота
|
||||
self.bot_status = Gauge(
|
||||
'telegram_bot_status',
|
||||
'Bot operational status (1 = running, 0 = stopped)'
|
||||
)
|
||||
|
||||
def _init_sentry(self):
|
||||
"""Инициализация Sentry"""
|
||||
if self.config.bot_config.enable_sentry and self.config.bot_config.sentry_dsn:
|
||||
# Настройка интеграции логирования
|
||||
logging_integration = LoggingIntegration(
|
||||
level=logging.INFO, # Захватывать логи уровня INFO и выше
|
||||
event_level=logging.ERROR # Отправлять в Sentry только ERROR и выше
|
||||
)
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn=self.config.bot_config.sentry_dsn,
|
||||
integrations=[logging_integration],
|
||||
traces_sample_rate=1.0, # Захватывать все транзакции для мониторинга производительности
|
||||
environment="production" if self.config.is_production() else "development",
|
||||
release="telegram-bot-v1.5.0",
|
||||
# Дополнительные настройки
|
||||
send_default_pii=False, # Не отправлять личную информацию
|
||||
before_send=self._before_send_sentry, # Фильтр событий
|
||||
before_breadcrumb=self._before_breadcrumb_sentry # Фильтр breadcrumbs
|
||||
)
|
||||
self.logger.info("Sentry initialized successfully")
|
||||
else:
|
||||
self.logger.info("Sentry integration disabled")
|
||||
|
||||
def record_error(self, error_type: str, handler: str, error: Exception = None):
|
||||
"""Запись ошибки в метрики"""
|
||||
self.error_counter.labels(error_type=error_type, handler=handler).inc()
|
||||
|
||||
if error:
|
||||
self.logger.error(
|
||||
f"Error recorded: {error_type} in {handler}",
|
||||
extra={
|
||||
'error_type': error_type,
|
||||
'handler': handler,
|
||||
'error_message': str(error),
|
||||
'error_class': error.__class__.__name__
|
||||
}
|
||||
)
|
||||
|
||||
def record_command(self, command: str, handler: str = None, duration: float = None, user_role: str = None):
|
||||
"""Запись выполнения команды"""
|
||||
# Поддержка старого API (command, handler, duration)
|
||||
if handler is not None and duration is not None and user_role is None:
|
||||
self.command_duration.labels(command=command, handler=handler).observe(duration)
|
||||
self.total_messages.labels(message_type='command').inc()
|
||||
# Поддержка нового API с user_role
|
||||
elif user_role is not None and duration is not None:
|
||||
# Можно расширить метрики для учета роли пользователя в будущем
|
||||
self.command_duration.labels(command=command, handler=user_role).observe(duration)
|
||||
self.total_messages.labels(message_type='command').inc()
|
||||
else:
|
||||
# Fallback для некорректных вызовов
|
||||
self.logger.warning(f"Invalid record_command call: command={command}, handler={handler}, duration={duration}, user_role={user_role}")
|
||||
self.total_messages.labels(message_type='command').inc()
|
||||
|
||||
def record_message(self, message_type: str = 'text'):
|
||||
"""Запись обработки сообщения"""
|
||||
self.total_messages.labels(message_type=message_type).inc()
|
||||
|
||||
def record_api_call(self, api_name: str, duration: float):
|
||||
"""Запись вызова внешнего API"""
|
||||
self.api_response_time.labels(api_name=api_name).observe(duration)
|
||||
|
||||
def update_active_users(self, count: int):
|
||||
"""Обновление количества активных пользователей"""
|
||||
self.active_users.set(count)
|
||||
|
||||
def set_bot_status(self, status: int):
|
||||
"""Установка статуса бота"""
|
||||
self.bot_status.set(status)
|
||||
|
||||
def start_metrics_server(self):
|
||||
"""Запуск HTTP сервера для Prometheus метрик"""
|
||||
try:
|
||||
port = self.config.bot_config.prometheus_port
|
||||
start_http_server(port)
|
||||
self.logger.info(f"Prometheus metrics server started on port {port}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to start Prometheus server: {e}")
|
||||
|
||||
def _before_send_sentry(self, event, hint):
|
||||
"""Фильтр событий перед отправкой в Sentry"""
|
||||
# Не отправлять события с низким приоритетом
|
||||
if event.get('level') == 'info':
|
||||
return None
|
||||
|
||||
# Не отправлять события от тестовых пользователей
|
||||
if self._is_test_user(event):
|
||||
return None
|
||||
|
||||
# Добавляем дополнительный контекст
|
||||
if 'user' not in event.get('contexts', {}):
|
||||
event.setdefault('contexts', {})['bot'] = {
|
||||
'config': {
|
||||
'enable_ai_processing': self.config.bot_config.enable_ai_processing,
|
||||
'prometheus_port': self.config.bot_config.prometheus_port
|
||||
}
|
||||
}
|
||||
|
||||
return event
|
||||
|
||||
def _before_breadcrumb_sentry(self, breadcrumb, hint):
|
||||
"""Фильтр breadcrumbs перед отправкой в Sentry"""
|
||||
# Не отправлять breadcrumbs с логами уровня DEBUG
|
||||
if breadcrumb.get('level') == 'debug':
|
||||
return None
|
||||
|
||||
# Не отправлять breadcrumbs с конфиденциальной информацией
|
||||
message = breadcrumb.get('message', '')
|
||||
if any(sensitive in message.lower() for sensitive in ['token', 'password', 'key', 'secret']):
|
||||
return None
|
||||
|
||||
return breadcrumb
|
||||
|
||||
def _is_test_user(self, event):
|
||||
"""Проверка, является ли событие от тестового пользователя"""
|
||||
user = event.get('user', {})
|
||||
user_id = user.get('id')
|
||||
|
||||
if user_id:
|
||||
try:
|
||||
user_id = int(user_id)
|
||||
# Считаем тестовыми пользователей с ID > 999999999 (или другой критерий)
|
||||
return user_id > 999999999
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
def send_sentry_message(self, message: str, level: str = 'info', extra: Dict = None):
|
||||
"""Отправка сообщения в Sentry"""
|
||||
if self.config.bot_config.enable_sentry:
|
||||
sentry_sdk.capture_message(message, level=level, extra=extra)
|
||||
else:
|
||||
self.logger.log(getattr(logging, level.upper(), logging.INFO), message)
|
||||
|
||||
def set_sentry_context(self, key: str, value: Any):
|
||||
"""Установка контекста для Sentry"""
|
||||
if self.config.bot_config.enable_sentry:
|
||||
sentry_sdk.set_context(key, value)
|
||||
|
||||
|
||||
def structured_logger(logger_name: str, config: Config) -> logging.Logger:
|
||||
"""Создание логгера с структурированным выводом в JSON"""
|
||||
|
||||
class JsonFormatter(logging.Formatter):
|
||||
"""Форматтер для JSON логов"""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
log_entry = {
|
||||
'timestamp': self.formatTime(record),
|
||||
'level': record.levelname,
|
||||
'logger': record.name,
|
||||
'message': record.getMessage(),
|
||||
'module': record.module,
|
||||
'function': record.funcName,
|
||||
'line': record.lineno
|
||||
}
|
||||
|
||||
# Добавляем extra данные если есть
|
||||
if hasattr(record, 'extra'):
|
||||
log_entry.update(record.extra)
|
||||
|
||||
return json.dumps(log_entry, ensure_ascii=False)
|
||||
|
||||
logger = logging.getLogger(logger_name)
|
||||
|
||||
# Устанавливаем уровень логирования
|
||||
log_level = getattr(logging, config.get_log_level().upper(), logging.INFO)
|
||||
logger.setLevel(log_level)
|
||||
|
||||
# Убираем существующие обработчики чтобы избежать дублирования
|
||||
for handler in logger.handlers[:]:
|
||||
logger.removeHandler(handler)
|
||||
|
||||
# Консольный обработчик
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(log_level)
|
||||
console_handler.setFormatter(JsonFormatter())
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# Файловый обработчик
|
||||
log_file = config.get('log_file', 'bot.log')
|
||||
file_handler = logging.FileHandler(log_file, encoding='utf-8')
|
||||
file_handler.setLevel(log_level)
|
||||
file_handler.setFormatter(JsonFormatter())
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
def measure_time(metric: MetricsCollector, api_name: Optional[str] = None):
|
||||
"""Декоратор для измерения времени выполнения"""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
async def async_wrapper(*args, **kwargs):
|
||||
start_time = time.time()
|
||||
try:
|
||||
result = await func(*args, **kwargs)
|
||||
duration = time.time() - start_time
|
||||
|
||||
# Записываем метрику если указан API
|
||||
if api_name:
|
||||
metric.record_api_call(api_name, duration)
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
duration = time.time() - start_time
|
||||
if api_name:
|
||||
metric.record_api_call(api_name, duration)
|
||||
raise
|
||||
|
||||
@wraps(func)
|
||||
def sync_wrapper(*args, **kwargs):
|
||||
start_time = time.time()
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
duration = time.time() - start_time
|
||||
|
||||
# Записываем метрику если указан API
|
||||
if api_name:
|
||||
metric.record_api_call(api_name, duration)
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
duration = time.time() - start_time
|
||||
if api_name:
|
||||
metric.record_api_call(api_name, duration)
|
||||
raise
|
||||
|
||||
# Возвращаем соответствующий wrapper в зависимости от типа функции
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
return async_wrapper
|
||||
else:
|
||||
return sync_wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def error_handler(metric: MetricsCollector, handler_name: str):
|
||||
"""Декоратор для обработки ошибок в обработчиках"""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
async def async_wrapper(*args, **kwargs):
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
metric.record_error(e.__class__.__name__, handler_name, e)
|
||||
raise
|
||||
|
||||
@wraps(func)
|
||||
def sync_wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
metric.record_error(e.__class__.__name__, handler_name, e)
|
||||
raise
|
||||
|
||||
# Возвращаем соответствующий wrapper
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
return async_wrapper
|
||||
else:
|
||||
return sync_wrapper
|
||||
|
||||
return decorator
|
||||
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
Исключения для системы платежей.
|
||||
"""
|
||||
|
||||
from core.exceptions import BotException
|
||||
|
||||
|
||||
class PaymentError(BotException):
|
||||
"""Базовое исключение для платежей"""
|
||||
pass
|
||||
|
||||
|
||||
class PaymentProviderError(PaymentError):
|
||||
"""Ошибка платежного провайдера"""
|
||||
pass
|
||||
|
||||
|
||||
class PaymentValidationError(PaymentError):
|
||||
"""Ошибка валидации платежа"""
|
||||
pass
|
||||
|
||||
|
||||
class PaymentSecurityError(PaymentError):
|
||||
"""Ошибка безопасности платежа"""
|
||||
pass
|
||||
|
||||
|
||||
class PaymentTimeoutError(PaymentError):
|
||||
"""Таймаут платежа"""
|
||||
pass
|
||||
|
||||
|
||||
class PaymentDuplicateError(PaymentError):
|
||||
"""Дубликат платежа"""
|
||||
pass
|
||||
|
||||
|
||||
class PaymentNotFoundError(PaymentError):
|
||||
"""Платеж не найден"""
|
||||
pass
|
||||
@@ -0,0 +1,392 @@
|
||||
"""
|
||||
Метрики и мониторинг платежных операций.
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from collections import defaultdict, deque
|
||||
|
||||
|
||||
class PaymentMetrics:
|
||||
"""
|
||||
Класс для сбора и анализа метрик платежных операций.
|
||||
|
||||
Отвечает за:
|
||||
- Сбор статистики платежей
|
||||
- Мониторинг производительности
|
||||
- Обнаружение аномалий
|
||||
- Генерацию отчетов
|
||||
"""
|
||||
|
||||
def __init__(self, max_history_size: int = 1000):
|
||||
"""
|
||||
Инициализация метрик.
|
||||
|
||||
Args:
|
||||
max_history_size: Максимальный размер истории для каждой метрики
|
||||
"""
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.max_history_size = max_history_size
|
||||
|
||||
# Метрики счетчиков
|
||||
self.counters = defaultdict(int)
|
||||
|
||||
# Метрики временных рядов
|
||||
self.time_series = defaultdict(lambda: deque(maxlen=max_history_size))
|
||||
|
||||
# Метрики гистограмм (для длительности операций)
|
||||
self.histograms = defaultdict(list)
|
||||
|
||||
# Статистика по провайдерам
|
||||
self.provider_stats = defaultdict(lambda: {
|
||||
'total_payments': 0,
|
||||
'successful_payments': 0,
|
||||
'failed_payments': 0,
|
||||
'total_amount': 0.0,
|
||||
'average_amount': 0.0,
|
||||
'last_payment_time': None
|
||||
})
|
||||
|
||||
# Статистика ошибок
|
||||
self.error_stats = defaultdict(lambda: {
|
||||
'count': 0,
|
||||
'last_occurrence': None,
|
||||
'error_messages': deque(maxlen=50)
|
||||
})
|
||||
|
||||
# Время запуска мониторинга
|
||||
self.start_time = datetime.now()
|
||||
|
||||
def record_payment_created(self, amount: float, provider: str, user_id: int):
|
||||
"""
|
||||
Запись метрики создания платежа.
|
||||
|
||||
Args:
|
||||
amount: Сумма платежа
|
||||
provider: Провайдер платежа
|
||||
user_id: ID пользователя
|
||||
"""
|
||||
timestamp = datetime.now()
|
||||
|
||||
# Обновление счетчиков
|
||||
self.counters['payments_created'] += 1
|
||||
self.counters[f'payments_created_{provider}'] += 1
|
||||
|
||||
# Запись в временной ряд
|
||||
self.time_series['payments_created'].append({
|
||||
'timestamp': timestamp,
|
||||
'amount': amount,
|
||||
'provider': provider,
|
||||
'user_id': user_id
|
||||
})
|
||||
|
||||
# Обновление статистики провайдера
|
||||
self.provider_stats[provider]['total_payments'] += 1
|
||||
self.provider_stats[provider]['last_payment_time'] = timestamp
|
||||
|
||||
self.logger.debug(f"Recorded payment creation: amount={amount}, provider={provider}")
|
||||
|
||||
def record_payment_completed(self, payment_id: str, amount: float, provider: str,
|
||||
duration_seconds: float):
|
||||
"""
|
||||
Запись метрики завершенного платежа.
|
||||
|
||||
Args:
|
||||
payment_id: ID платежа
|
||||
amount: Сумма платежа
|
||||
provider: Провайдер
|
||||
duration_seconds: Длительность обработки
|
||||
"""
|
||||
timestamp = datetime.now()
|
||||
|
||||
# Обновление счетчиков
|
||||
self.counters['payments_completed'] += 1
|
||||
self.counters[f'payments_completed_{provider}'] += 1
|
||||
|
||||
# Запись длительности
|
||||
self.histograms['payment_duration'].append(duration_seconds)
|
||||
|
||||
# Запись в временной ряд
|
||||
self.time_series['payments_completed'].append({
|
||||
'timestamp': timestamp,
|
||||
'payment_id': payment_id,
|
||||
'amount': amount,
|
||||
'provider': provider,
|
||||
'duration': duration_seconds
|
||||
})
|
||||
|
||||
# Обновление статистики провайдера
|
||||
self.provider_stats[provider]['successful_payments'] += 1
|
||||
self.provider_stats[provider]['total_amount'] += amount
|
||||
|
||||
# Пересчет средней суммы
|
||||
total_payments = self.provider_stats[provider]['successful_payments']
|
||||
self.provider_stats[provider]['average_amount'] = (
|
||||
self.provider_stats[provider]['total_amount'] / total_payments
|
||||
)
|
||||
|
||||
self.logger.debug(f"Recorded payment completion: id={payment_id}, duration={duration_seconds:.2f}s")
|
||||
|
||||
def record_payment_failed(self, payment_id: str, provider: str, error_type: str,
|
||||
error_message: str = ""):
|
||||
"""
|
||||
Запись метрики неудачного платежа.
|
||||
|
||||
Args:
|
||||
payment_id: ID платежа
|
||||
provider: Провайдер
|
||||
error_type: Тип ошибки
|
||||
error_message: Сообщение об ошибке
|
||||
"""
|
||||
timestamp = datetime.now()
|
||||
|
||||
# Обновление счетчиков
|
||||
self.counters['payments_failed'] += 1
|
||||
self.counters[f'payments_failed_{provider}'] += 1
|
||||
self.counters[f'payments_failed_{error_type}'] += 1
|
||||
|
||||
# Запись в статистику ошибок
|
||||
self.error_stats[error_type]['count'] += 1
|
||||
self.error_stats[error_type]['last_occurrence'] = timestamp
|
||||
self.error_stats[error_type]['error_messages'].append(error_message)
|
||||
|
||||
# Запись в временной ряд
|
||||
self.time_series['payments_failed'].append({
|
||||
'timestamp': timestamp,
|
||||
'payment_id': payment_id,
|
||||
'provider': provider,
|
||||
'error_type': error_type,
|
||||
'error_message': error_message
|
||||
})
|
||||
|
||||
# Обновление статистики провайдера
|
||||
self.provider_stats[provider]['failed_payments'] += 1
|
||||
|
||||
self.logger.warning(f"Recorded payment failure: id={payment_id}, error={error_type}")
|
||||
|
||||
def record_webhook_received(self, provider: str, processing_time: float):
|
||||
"""
|
||||
Запись метрики полученного webhook.
|
||||
|
||||
Args:
|
||||
provider: Провайдер
|
||||
processing_time: Время обработки
|
||||
"""
|
||||
timestamp = datetime.now()
|
||||
|
||||
self.counters['webhooks_received'] += 1
|
||||
self.counters[f'webhooks_received_{provider}'] += 1
|
||||
|
||||
# Запись длительности обработки
|
||||
self.histograms['webhook_processing_time'].append(processing_time)
|
||||
|
||||
self.time_series['webhooks_received'].append({
|
||||
'timestamp': timestamp,
|
||||
'provider': provider,
|
||||
'processing_time': processing_time
|
||||
})
|
||||
|
||||
def record_webhook_validation_failed(self, provider: str, reason: str):
|
||||
"""
|
||||
Запись метрики неудачной валидации webhook.
|
||||
|
||||
Args:
|
||||
provider: Провайдер
|
||||
reason: Причина неудачи
|
||||
"""
|
||||
timestamp = datetime.now()
|
||||
|
||||
self.counters['webhook_validation_failed'] += 1
|
||||
self.counters[f'webhook_validation_failed_{provider}'] += 1
|
||||
|
||||
self.time_series['webhook_validation_failed'].append({
|
||||
'timestamp': timestamp,
|
||||
'provider': provider,
|
||||
'reason': reason
|
||||
})
|
||||
|
||||
self.logger.warning(f"Webhook validation failed: provider={provider}, reason={reason}")
|
||||
|
||||
def get_summary_stats(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Получение сводной статистики.
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Сводная статистика
|
||||
"""
|
||||
total_payments = self.counters['payments_created']
|
||||
completed_payments = self.counters['payments_completed']
|
||||
failed_payments = self.counters['payments_failed']
|
||||
|
||||
# Расчет конверсии
|
||||
conversion_rate = (completed_payments / total_payments * 100) if total_payments > 0 else 0
|
||||
|
||||
# Расчет средней длительности платежей
|
||||
payment_durations = self.histograms.get('payment_duration', [])
|
||||
avg_payment_duration = sum(payment_durations) / len(payment_durations) if payment_durations else 0
|
||||
|
||||
return {
|
||||
'total_payments': total_payments,
|
||||
'completed_payments': completed_payments,
|
||||
'failed_payments': failed_payments,
|
||||
'conversion_rate_percent': round(conversion_rate, 2),
|
||||
'avg_payment_duration_seconds': round(avg_payment_duration, 2),
|
||||
'webhooks_received': self.counters['webhooks_received'],
|
||||
'webhook_validation_failures': self.counters['webhook_validation_failed'],
|
||||
'uptime_seconds': (datetime.now() - self.start_time).total_seconds(),
|
||||
'provider_stats': dict(self.provider_stats),
|
||||
'top_errors': self._get_top_errors()
|
||||
}
|
||||
|
||||
def get_recent_activity(self, minutes: int = 60) -> Dict[str, Any]:
|
||||
"""
|
||||
Получение недавней активности.
|
||||
|
||||
Args:
|
||||
minutes: Период в минутах
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Недавняя активность
|
||||
"""
|
||||
cutoff_time = datetime.now() - timedelta(minutes=minutes)
|
||||
|
||||
recent_payments = [
|
||||
item for item in self.time_series['payments_created']
|
||||
if item['timestamp'] > cutoff_time
|
||||
]
|
||||
|
||||
recent_completions = [
|
||||
item for item in self.time_series['payments_completed']
|
||||
if item['timestamp'] > cutoff_time
|
||||
]
|
||||
|
||||
recent_failures = [
|
||||
item for item in self.time_series['payments_failed']
|
||||
if item['timestamp'] > cutoff_time
|
||||
]
|
||||
|
||||
return {
|
||||
'period_minutes': minutes,
|
||||
'payments_created': len(recent_payments),
|
||||
'payments_completed': len(recent_completions),
|
||||
'payments_failed': len(recent_failures),
|
||||
'total_amount_recent': sum(p['amount'] for p in recent_completions)
|
||||
}
|
||||
|
||||
def detect_anomalies(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Обнаружение аномалий в метриках.
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Найденные аномалии
|
||||
"""
|
||||
anomalies = {
|
||||
'high_failure_rate': False,
|
||||
'unusual_traffic': False,
|
||||
'slow_processing': False,
|
||||
'webhook_spike': False,
|
||||
'details': []
|
||||
}
|
||||
|
||||
# Проверка высокой доли неудачных платежей
|
||||
total = self.counters['payments_created']
|
||||
failed = self.counters['payments_failed']
|
||||
if total > 10 and (failed / total) > 0.3: # > 30% неудач
|
||||
anomalies['high_failure_rate'] = True
|
||||
anomalies['details'].append(f"High failure rate: {failed}/{total} ({failed/total*100:.1f}%)")
|
||||
|
||||
# Проверка необычного трафика (вдруг вырос на 5x по сравнению со средним)
|
||||
recent_activity = self.get_recent_activity(10) # последние 10 минут
|
||||
if recent_activity['payments_created'] > 50: # порог для обнаружения
|
||||
anomalies['unusual_traffic'] = True
|
||||
anomalies['details'].append(f"Unusual traffic: {recent_activity['payments_created']} payments in 10 min")
|
||||
|
||||
# Проверка медленной обработки
|
||||
durations = self.histograms.get('payment_duration', [])
|
||||
if durations and len(durations) > 5:
|
||||
avg_duration = sum(durations[-10:]) / len(durations[-10:]) # среднее за последние 10
|
||||
if avg_duration > 30: # > 30 секунд
|
||||
anomalies['slow_processing'] = True
|
||||
anomalies['details'].append(f"Slow processing: avg {avg_duration:.1f}s")
|
||||
|
||||
# Проверка спайка webhook
|
||||
recent_webhooks = len([
|
||||
w for w in self.time_series['webhooks_received']
|
||||
if (datetime.now() - w['timestamp']).seconds < 300 # последние 5 минут
|
||||
])
|
||||
if recent_webhooks > 100: # порог
|
||||
anomalies['webhook_spike'] = True
|
||||
anomalies['details'].append(f"Webhook spike: {recent_webhooks} in 5 min")
|
||||
|
||||
return anomalies
|
||||
|
||||
def _get_top_errors(self, limit: int = 5) -> list:
|
||||
"""
|
||||
Получение топа самых частых ошибок.
|
||||
|
||||
Args:
|
||||
limit: Количество ошибок для возврата
|
||||
|
||||
Returns:
|
||||
list: Топ ошибок
|
||||
"""
|
||||
sorted_errors = sorted(
|
||||
self.error_stats.items(),
|
||||
key=lambda x: x[1]['count'],
|
||||
reverse=True
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
'error_type': error_type,
|
||||
'count': stats['count'],
|
||||
'last_occurrence': stats['last_occurrence'].isoformat() if stats['last_occurrence'] else None,
|
||||
'recent_messages': list(stats['error_messages'])[-3:] # последние 3 сообщения
|
||||
}
|
||||
for error_type, stats in sorted_errors[:limit]
|
||||
]
|
||||
|
||||
def reset_counters(self):
|
||||
"""Сброс всех счетчиков (для тестирования)"""
|
||||
self.counters.clear()
|
||||
self.time_series.clear()
|
||||
self.histograms.clear()
|
||||
self.provider_stats.clear()
|
||||
self.error_stats.clear()
|
||||
self.start_time = datetime.now()
|
||||
|
||||
def export_metrics(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Экспорт всех метрик для внешнего использования.
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Все метрики
|
||||
"""
|
||||
return {
|
||||
'counters': dict(self.counters),
|
||||
'time_series_lengths': {k: len(v) for k, v in self.time_series.items()},
|
||||
'histogram_stats': {
|
||||
k: {
|
||||
'count': len(v),
|
||||
'avg': sum(v) / len(v) if v else 0,
|
||||
'min': min(v) if v else 0,
|
||||
'max': max(v) if v else 0
|
||||
}
|
||||
for k, v in self.histograms.items()
|
||||
},
|
||||
'provider_stats': dict(self.provider_stats),
|
||||
'summary': self.get_summary_stats(),
|
||||
'anomalies': self.detect_anomalies(),
|
||||
'export_time': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
|
||||
# Глобальный экземпляр метрик
|
||||
payment_metrics = PaymentMetrics()
|
||||
|
||||
|
||||
def get_payment_metrics() -> PaymentMetrics:
|
||||
"""Получение глобального экземпляра метрик"""
|
||||
return payment_metrics
|
||||
@@ -0,0 +1,126 @@
|
||||
"""
|
||||
Модели данных для платежной системы.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class PaymentStatus(Enum):
|
||||
"""Статусы платежа"""
|
||||
PENDING = "pending"
|
||||
PROCESSING = "processing"
|
||||
SUCCEEDED = "succeeded"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
REFUNDED = "refunded"
|
||||
|
||||
|
||||
class PaymentProvider(Enum):
|
||||
"""Платежные провайдеры"""
|
||||
STRIPE = "stripe"
|
||||
YOOKASSA = "yookassa"
|
||||
|
||||
|
||||
class TransactionType(Enum):
|
||||
"""Типы транзакций"""
|
||||
PAYMENT = "payment"
|
||||
REFUND = "refund"
|
||||
CHARGEBACK = "chargeback"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PaymentIntent:
|
||||
"""Интент платежа от провайдера"""
|
||||
id: str
|
||||
url: str
|
||||
client_secret: Optional[str] = None
|
||||
status: str = "pending"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PaymentEvent:
|
||||
"""Событие платежа"""
|
||||
type: str # 'payment.succeeded', 'payment.failed', etc.
|
||||
payment_id: str
|
||||
amount: float
|
||||
currency: str = "RUB"
|
||||
user_id: Optional[int] = None
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
timestamp: Optional[datetime] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.timestamp is None:
|
||||
self.timestamp = datetime.now()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Payment:
|
||||
"""Модель платежа"""
|
||||
id: int
|
||||
user_id: int
|
||||
amount: float
|
||||
currency: str
|
||||
provider: str # PaymentProvider.value
|
||||
external_id: str # ID платежа у провайдера
|
||||
status: str # PaymentStatus.value
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
processed_at: Optional[datetime] = None
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
error_message: Optional[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.metadata is None:
|
||||
self.metadata = {}
|
||||
if isinstance(self.created_at, str):
|
||||
self.created_at = datetime.fromisoformat(self.created_at.replace('Z', '+00:00'))
|
||||
if isinstance(self.updated_at, str):
|
||||
self.updated_at = datetime.fromisoformat(self.updated_at.replace('Z', '+00:00'))
|
||||
if self.processed_at and isinstance(self.processed_at, str):
|
||||
self.processed_at = datetime.fromisoformat(self.processed_at.replace('Z', '+00:00'))
|
||||
|
||||
|
||||
@dataclass
|
||||
class Transaction:
|
||||
"""Модель транзакции"""
|
||||
id: int
|
||||
payment_id: int
|
||||
type: str # TransactionType.value
|
||||
amount: float
|
||||
status: str
|
||||
created_at: datetime
|
||||
external_transaction_id: Optional[str] = None
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.details is None:
|
||||
self.details = {}
|
||||
if isinstance(self.created_at, str):
|
||||
self.created_at = datetime.fromisoformat(self.created_at.replace('Z', '+00:00'))
|
||||
|
||||
|
||||
@dataclass
|
||||
class DonationResponse:
|
||||
"""Ответ на создание доната"""
|
||||
payment_url: str
|
||||
payment_id: int
|
||||
provider: str
|
||||
amount: float
|
||||
currency: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class PaymentValidationResult:
|
||||
"""Результат валидации платежа"""
|
||||
is_valid: bool
|
||||
errors: list[str]
|
||||
warnings: list[str]
|
||||
|
||||
def __post_init__(self):
|
||||
if not self.errors:
|
||||
self.errors = []
|
||||
if not self.warnings:
|
||||
self.warnings = []
|
||||
@@ -0,0 +1,641 @@
|
||||
"""
|
||||
Модуль безопасности для платежных операций.
|
||||
"""
|
||||
|
||||
import hmac
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Dict, Any, Optional
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
class PaymentSecurity:
|
||||
"""
|
||||
Класс для обеспечения безопасности платежных операций.
|
||||
|
||||
Отвечает за:
|
||||
- Валидацию webhook подписей
|
||||
- Предотвращение дублирования платежей
|
||||
- Защиту от фрода
|
||||
- Шифрование чувствительных данных
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
@staticmethod
|
||||
def validate_stripe_signature(payload: bytes, signature: str, secret: str) -> bool:
|
||||
"""
|
||||
Валидация подписи Stripe webhook.
|
||||
|
||||
Args:
|
||||
payload: Тело запроса
|
||||
signature: Подпись из заголовка
|
||||
secret: Секретный ключ webhook
|
||||
|
||||
Returns:
|
||||
bool: Валидна ли подпись
|
||||
"""
|
||||
try:
|
||||
# Stripe отправляет подпись в формате t=123456,v1=abc123
|
||||
timestamp = None
|
||||
signatures = []
|
||||
|
||||
for part in signature.split(','):
|
||||
key, value = part.split('=', 1)
|
||||
if key == 't':
|
||||
timestamp = value
|
||||
elif key.startswith('v1'):
|
||||
signatures.append(value)
|
||||
|
||||
if not timestamp or not signatures:
|
||||
return False
|
||||
|
||||
# Создание ожидаемой подписи
|
||||
signed_payload = f"{timestamp}.{payload.decode('utf-8')}"
|
||||
expected_signature = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
signed_payload.encode('utf-8'),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
# Проверка соответствия
|
||||
for sig in signatures:
|
||||
if hmac.compare_digest(sig, expected_signature):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logging.getLogger(__name__).error(f"Stripe signature validation error: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def validate_yookassa_signature(request_body: str, signature: str, secret: str) -> bool:
|
||||
"""
|
||||
Валидация подписи YooKassa webhook.
|
||||
|
||||
Args:
|
||||
request_body: Тело запроса как строка
|
||||
signature: Подпись из заголовка
|
||||
secret: Секретный ключ
|
||||
|
||||
Returns:
|
||||
bool: Валидна ли подпись
|
||||
"""
|
||||
try:
|
||||
expected_signature = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
request_body.encode('utf-8'),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
return hmac.compare_digest(signature, expected_signature)
|
||||
|
||||
except Exception as e:
|
||||
logging.getLogger(__name__).error(f"YooKassa signature validation error: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def validate_sbp_signature(request_body: str, signature: str, secret: str) -> bool:
|
||||
"""
|
||||
Валидация подписи СБП webhook согласно спецификации СБП.
|
||||
|
||||
СБП использует HMAC-SHA256 с телом запроса и секретным ключом.
|
||||
Формат подписи: base64-encoded HMAC-SHA256 hash.
|
||||
|
||||
Args:
|
||||
request_body: Тело запроса как строка (JSON)
|
||||
signature: Подпись из заголовка (base64)
|
||||
secret: Секретный ключ СБП
|
||||
|
||||
Returns:
|
||||
bool: Валидна ли подпись
|
||||
"""
|
||||
try:
|
||||
# СБП передает подпись в base64 формате
|
||||
# Вычисляем ожидаемую подпись
|
||||
expected_hmac = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
request_body.encode('utf-8'),
|
||||
hashlib.sha256
|
||||
)
|
||||
|
||||
# Кодируем в base64 как ожидает СБП
|
||||
import base64
|
||||
expected_signature = base64.b64encode(expected_hmac.digest()).decode('utf-8')
|
||||
|
||||
return hmac.compare_digest(signature, expected_signature)
|
||||
|
||||
except Exception as e:
|
||||
logging.getLogger(__name__).error(f"SBP signature validation error: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def generate_idempotency_key(payment_data: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Генерация ключа идемпотентности для предотвращения дубликатов.
|
||||
|
||||
Args:
|
||||
payment_data: Данные платежа
|
||||
|
||||
Returns:
|
||||
str: Ключ идемпотентности
|
||||
"""
|
||||
# Создание уникального ключа на основе user_id, amount и timestamp
|
||||
key_data = f"{payment_data['user_id']}:{payment_data['amount']}:{int(datetime.now().timestamp())}"
|
||||
return hashlib.sha256(key_data.encode()).hexdigest()[:16]
|
||||
|
||||
@staticmethod
|
||||
def check_idempotency_key(key: str, ttl_seconds: int = 3600) -> bool:
|
||||
"""
|
||||
Проверка ключа идемпотентности в кэше.
|
||||
|
||||
Args:
|
||||
key: Ключ идемпотентности
|
||||
ttl_seconds: Время жизни ключа
|
||||
|
||||
Returns:
|
||||
bool: True если ключ уже использовался
|
||||
"""
|
||||
# В реальной реализации здесь был бы Redis или другой кэш
|
||||
# Пока просто логируем
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.debug(f"Checking idempotency key: {key}")
|
||||
return False # Для тестирования всегда возвращаем False
|
||||
|
||||
@staticmethod
|
||||
def sanitize_payment_data(payment_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Очистка платежных данных от чувствительной информации.
|
||||
|
||||
Args:
|
||||
payment_data: Исходные данные платежа
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Очищенные данные
|
||||
"""
|
||||
sanitized = payment_data.copy()
|
||||
|
||||
# Список полей для удаления
|
||||
sensitive_fields = [
|
||||
'card_number', 'cvv', 'cardholder_name',
|
||||
'password', 'secret_key', 'api_key',
|
||||
'webhook_secret', 'private_key'
|
||||
]
|
||||
|
||||
# Удаление чувствительных полей
|
||||
for field in sensitive_fields:
|
||||
if field in sanitized:
|
||||
sanitized[field] = '***'
|
||||
|
||||
# Маскировка частичных данных
|
||||
if 'card_number' in payment_data:
|
||||
card = payment_data['card_number']
|
||||
if len(card) > 4:
|
||||
sanitized['card_number'] = f"**** **** **** {card[-4:]}"
|
||||
|
||||
return sanitized
|
||||
|
||||
@staticmethod
|
||||
def validate_amount_range(amount: float, min_amount: float = 1.0,
|
||||
max_amount: float = 100000.0) -> bool:
|
||||
"""
|
||||
Проверка суммы платежа на допустимый диапазон.
|
||||
|
||||
Args:
|
||||
amount: Сумма платежа
|
||||
min_amount: Минимальная сумма
|
||||
max_amount: Максимальная сумма
|
||||
|
||||
Returns:
|
||||
bool: Входит ли сумма в допустимый диапазон
|
||||
"""
|
||||
return min_amount <= amount <= max_amount
|
||||
|
||||
@staticmethod
|
||||
def validate_currency(currency: str, allowed_currencies: list = None) -> bool:
|
||||
"""
|
||||
Проверка валюты платежа.
|
||||
|
||||
Args:
|
||||
currency: Код валюты
|
||||
allowed_currencies: Список разрешенных валют
|
||||
|
||||
Returns:
|
||||
bool: Разрешена ли валюта
|
||||
"""
|
||||
if allowed_currencies is None:
|
||||
allowed_currencies = ['RUB', 'USD', 'EUR']
|
||||
|
||||
return currency.upper() in allowed_currencies
|
||||
|
||||
@staticmethod
|
||||
def detect_suspicious_activity(payment_data: Dict[str, Any],
|
||||
recent_payments: list) -> Dict[str, Any]:
|
||||
"""
|
||||
Обнаружение подозрительной активности.
|
||||
|
||||
Args:
|
||||
payment_data: Данные текущего платежа
|
||||
recent_payments: Список недавних платежей пользователя
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Результаты анализа
|
||||
"""
|
||||
result = {
|
||||
'is_suspicious': False,
|
||||
'risk_level': 'low',
|
||||
'reasons': []
|
||||
}
|
||||
|
||||
# Проверка на слишком частые платежи
|
||||
recent_count = len([p for p in recent_payments
|
||||
if (datetime.now() - datetime.fromisoformat(p['created_at'])).seconds < 300])
|
||||
if recent_count > 5:
|
||||
result['is_suspicious'] = True
|
||||
result['risk_level'] = 'medium'
|
||||
result['reasons'].append('too_many_recent_payments')
|
||||
|
||||
# Проверка на необычно большую сумму
|
||||
amount = payment_data['amount']
|
||||
avg_amount = sum(p['amount'] for p in recent_payments) / len(recent_payments) if recent_payments else 0
|
||||
if amount > avg_amount * 10 and avg_amount > 0:
|
||||
result['is_suspicious'] = True
|
||||
result['risk_level'] = 'high'
|
||||
result['reasons'].append('unusually_large_amount')
|
||||
|
||||
# Проверка на круглые суммы (часто используется в тестах фрода)
|
||||
if amount in [1000, 5000, 10000, 50000]:
|
||||
result['is_suspicious'] = True
|
||||
result['risk_level'] = 'low'
|
||||
result['reasons'].append('round_amount_suspicious')
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def encrypt_sensitive_data(data: str, key: str) -> str:
|
||||
"""
|
||||
Шифрование чувствительных данных с использованием Fernet (AES 128).
|
||||
|
||||
Args:
|
||||
data: Данные для шифрования
|
||||
key: Ключ шифрования (должен быть 32 байта для Fernet)
|
||||
|
||||
Returns:
|
||||
str: Зашифрованные данные (base64)
|
||||
|
||||
Raises:
|
||||
PaymentSecurityError: Ошибка шифрования
|
||||
"""
|
||||
try:
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
import base64
|
||||
|
||||
# Создаем ключ подходящего размера если необходимо
|
||||
if len(key) != 32:
|
||||
# Используем SHA256 для генерации 32-байтового ключа
|
||||
key = hashlib.sha256(key.encode('utf-8')).digest()
|
||||
|
||||
# Создаем Fernet ключ (base64-encoded 32-byte key)
|
||||
fernet_key = base64.urlsafe_b64encode(key)
|
||||
fernet = Fernet(fernet_key)
|
||||
|
||||
# Шифруем данные
|
||||
encrypted = fernet.encrypt(data.encode('utf-8'))
|
||||
return encrypted.decode('utf-8')
|
||||
|
||||
except ImportError:
|
||||
from core.payment_exceptions import PaymentSecurityError
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error("cryptography library not installed, encryption unavailable")
|
||||
raise PaymentSecurityError("Encryption library not available")
|
||||
except Exception as e:
|
||||
from core.payment_exceptions import PaymentSecurityError
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Encryption error: {e}")
|
||||
raise PaymentSecurityError(f"Failed to encrypt data: {e}")
|
||||
|
||||
@staticmethod
|
||||
def decrypt_sensitive_data(encrypted_data: str, key: str) -> str:
|
||||
"""
|
||||
Расшифровка чувствительных данных с использованием Fernet (AES 128).
|
||||
|
||||
Args:
|
||||
encrypted_data: Зашифрованные данные (base64)
|
||||
key: Ключ шифрования
|
||||
|
||||
Returns:
|
||||
str: Расшифрованные данные
|
||||
|
||||
Raises:
|
||||
PaymentSecurityError: Ошибка расшифровки
|
||||
"""
|
||||
try:
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
import base64
|
||||
|
||||
# Создаем ключ подходящего размера если необходимо
|
||||
if len(key) != 32:
|
||||
# Используем SHA256 для генерации 32-байтового ключа
|
||||
key = hashlib.sha256(key.encode('utf-8')).digest()
|
||||
|
||||
# Создаем Fernet ключ (base64-encoded 32-byte key)
|
||||
fernet_key = base64.urlsafe_b64encode(key)
|
||||
fernet = Fernet(fernet_key)
|
||||
|
||||
# Расшифровываем данные
|
||||
decrypted = fernet.decrypt(encrypted_data.encode('utf-8'))
|
||||
return decrypted.decode('utf-8')
|
||||
|
||||
except ImportError:
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error("cryptography library not installed, decryption unavailable")
|
||||
raise PaymentSecurityError("Decryption library not available")
|
||||
except InvalidToken:
|
||||
from core.payment_exceptions import PaymentSecurityError
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error("Invalid encryption token or key")
|
||||
raise PaymentSecurityError("Invalid encryption token")
|
||||
except Exception as e:
|
||||
from core.payment_exceptions import PaymentSecurityError
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Decryption error: {e}")
|
||||
raise PaymentSecurityError(f"Failed to decrypt data: {e}")
|
||||
|
||||
@staticmethod
|
||||
def validate_ffmpeg_command(command: list) -> bool:
|
||||
"""
|
||||
Проверка безопасности команды ffmpeg с дополнительными мерами защиты.
|
||||
|
||||
Args:
|
||||
command: Список аргументов команды ffmpeg
|
||||
|
||||
Returns:
|
||||
bool: Безопасна ли команда
|
||||
"""
|
||||
if not command or command[0] != 'ffmpeg':
|
||||
return False
|
||||
|
||||
# Запрещенные опции и паттерны
|
||||
dangerous_patterns = [
|
||||
r'-f\s+srt', # SRT формат может быть опасным
|
||||
r'-i\s+.*\.\.', # Path traversal
|
||||
r'-i\s+.*\|', # Command injection
|
||||
r'-i\s+.*;', # Command injection
|
||||
r'-i\s+.*\$', # Shell variables
|
||||
r'-i\s+.*`', # Command substitution
|
||||
r'-y\s+.*\.\.', # Path traversal в output
|
||||
r'-y\s+.*\|', # Command injection в output
|
||||
r'-y\s+.*;', # Command injection в output
|
||||
r'-y\s+.*\$', # Shell variables в output
|
||||
r'-y\s+.*`', # Command substitution в output
|
||||
r'-i\s+.*\.\.', # Path traversal в input
|
||||
r'-i\s+.*\|', # Command injection в input
|
||||
r'-i\s+.*;', # Command injection в input
|
||||
r'-i\s+.*\$', # Shell variables в input
|
||||
r'-i\s+.*`', # Command substitution в input
|
||||
r'-vf\s+.*\|', # Command injection в video filters
|
||||
r'-vf\s+.*;', # Command injection в video filters
|
||||
r'-af\s+.*\|', # Command injection в audio filters
|
||||
r'-af\s+.*;', # Command injection в audio filters
|
||||
r'-filter_complex\s+.*\|', # Command injection в complex filters
|
||||
r'-filter_complex\s+.*;', # Command injection в complex filters
|
||||
]
|
||||
|
||||
command_str = ' '.join(command)
|
||||
|
||||
for pattern in dangerous_patterns:
|
||||
if re.search(pattern, command_str, re.IGNORECASE):
|
||||
logging.getLogger(__name__).warning(f"Dangerous ffmpeg pattern detected: {pattern}")
|
||||
return False
|
||||
|
||||
# Проверка на допустимые входные форматы
|
||||
allowed_input_formats = ['mp4', 'avi', 'mov', 'mkv', 'webm', 'flv', 'wmv', 'mpg', 'mpeg', 'mp3', 'wav', 'aac']
|
||||
input_files = [arg for arg in command if not arg.startswith('-') and '.' in arg and not arg.endswith('/')]
|
||||
|
||||
for file in input_files:
|
||||
ext = file.split('.')[-1].lower()
|
||||
if ext not in allowed_input_formats:
|
||||
logging.getLogger(__name__).warning(f"Unsupported input format: {ext}")
|
||||
return False
|
||||
|
||||
# Проверка на допустимые выходные форматы
|
||||
allowed_output_formats = ['mp4', 'avi', 'mov', 'mkv', 'webm', 'gif', 'jpg', 'png', 'mp3', 'wav', 'aac']
|
||||
if '-f' in command:
|
||||
format_idx = command.index('-f') + 1
|
||||
if format_idx < len(command):
|
||||
output_format = command[format_idx].lower()
|
||||
if output_format not in allowed_output_formats:
|
||||
logging.getLogger(__name__).warning(f"Unsupported output format: {output_format}")
|
||||
return False
|
||||
|
||||
# Проверка на наличие выходного файла
|
||||
if '-y' not in command:
|
||||
logging.getLogger(__name__).warning("Output file not specified with -y flag")
|
||||
return False
|
||||
|
||||
# Проверка на разумные ограничения
|
||||
if len(command) > 50: # Слишком много аргументов
|
||||
logging.getLogger(__name__).warning("Too many ffmpeg arguments")
|
||||
return False
|
||||
|
||||
# Проверка на запрещенные кодеки и фильтры
|
||||
dangerous_codecs = ['libx264', 'libx265', 'h264', 'hevc'] # Требуют проверки
|
||||
for arg in command:
|
||||
if any(codec in arg for codec in dangerous_codecs):
|
||||
logging.getLogger(__name__).warning(f"Potentially dangerous codec: {arg}")
|
||||
# Пока разрешаем, но логируем для мониторинга
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def sanitize_ffmpeg_output_path(output_path: str) -> Optional[str]:
|
||||
"""
|
||||
Санитизация пути вывода ffmpeg.
|
||||
|
||||
Args:
|
||||
output_path: Исходный путь вывода
|
||||
|
||||
Returns:
|
||||
Optional[str]: Санитизированный путь или None если небезопасный
|
||||
"""
|
||||
# Проверка на path traversal
|
||||
if '..' in output_path or output_path.startswith('/'):
|
||||
logging.getLogger(__name__).warning(f"Potentially dangerous output path: {output_path}")
|
||||
return None
|
||||
|
||||
# Проверка на специальные символы
|
||||
if any(char in output_path for char in ['|', ';', '$', '`', '&', '<', '>']):
|
||||
logging.getLogger(__name__).warning(f"Special characters in output path: {output_path}")
|
||||
return None
|
||||
|
||||
# Ограничение на допустимые расширения
|
||||
allowed_extensions = ['.mp4', '.avi', '.mov', '.mkv', '.webm', '.gif', '.jpg', '.png']
|
||||
if not any(output_path.endswith(ext) for ext in allowed_extensions):
|
||||
logging.getLogger(__name__).warning(f"Unsupported output extension: {output_path}")
|
||||
return None
|
||||
|
||||
return output_path
|
||||
|
||||
@staticmethod
|
||||
def secure_ffmpeg_execute(command: list, timeout_seconds: int = 300) -> tuple[bool, str]:
|
||||
"""
|
||||
Безопасное выполнение команды ffmpeg с дополнительными проверками.
|
||||
|
||||
Args:
|
||||
command: Список аргументов команды ffmpeg
|
||||
timeout_seconds: Максимальное время выполнения
|
||||
|
||||
Returns:
|
||||
tuple[bool, str]: (успешно ли выполнено, сообщение об ошибке или пустая строка)
|
||||
"""
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Валидация команды
|
||||
if not PaymentSecurity.validate_ffmpeg_command(command):
|
||||
return False, "Command validation failed"
|
||||
|
||||
try:
|
||||
# Дополнительная валидация пути вывода
|
||||
if '-y' in command:
|
||||
output_idx = command.index('-y') + 1
|
||||
if output_idx < len(command):
|
||||
output_path = command[output_idx]
|
||||
sanitized_path = PaymentSecurity.sanitize_ffmpeg_output_path(output_path)
|
||||
if sanitized_path is None:
|
||||
return False, "Output path validation failed"
|
||||
command[output_idx] = sanitized_path
|
||||
|
||||
# Выполнение с таймаутом и ограничениями
|
||||
import subprocess
|
||||
|
||||
result = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout_seconds,
|
||||
# Ограничения безопасности
|
||||
preexec_fn=None if subprocess.os.name == 'nt' else subprocess.os.setpgrp,
|
||||
env={
|
||||
'PATH': subprocess.os.environ.get('PATH', ''),
|
||||
# Убираем потенциально опасные переменные окружения
|
||||
'LD_LIBRARY_PATH': '',
|
||||
'LD_PRELOAD': '',
|
||||
}
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
logger.info(f"FFmpeg command executed successfully")
|
||||
return True, ""
|
||||
else:
|
||||
error_msg = f"FFmpeg failed with code {result.returncode}: {result.stderr}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
error_msg = f"FFmpeg command timed out after {timeout_seconds} seconds"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
except Exception as e:
|
||||
error_msg = f"FFmpeg execution error: {e}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
|
||||
class PaymentRateLimiter:
|
||||
"""
|
||||
Ограничитель частоты платежей для предотвращения злоупотреблений.
|
||||
"""
|
||||
|
||||
def __init__(self, max_payments_per_hour: int = 10, max_amount_per_hour: float = 50000):
|
||||
"""
|
||||
Инициализация ограничителя.
|
||||
|
||||
Args:
|
||||
max_payments_per_hour: Максимальное количество платежей в час
|
||||
max_amount_per_hour: Максимальная сумма платежей в час
|
||||
"""
|
||||
self.max_payments_per_hour = max_payments_per_hour
|
||||
self.max_amount_per_hour = max_amount_per_hour
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
# В реальной реализации здесь был бы Redis для хранения счетчиков
|
||||
# Пока используем in-memory словарь для демонстрации
|
||||
self._counters = {} # user_id -> {'payments': [], 'amounts': []}
|
||||
|
||||
def check_rate_limit(self, user_id: int, amount: float) -> Dict[str, Any]:
|
||||
"""
|
||||
Проверка ограничений частоты платежей.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
amount: Сумма платежа
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Результат проверки
|
||||
"""
|
||||
current_time = time.time()
|
||||
window_start = current_time - 3600 # 1 час в секундах
|
||||
|
||||
if user_id not in self._counters:
|
||||
self._counters[user_id] = {'payments': [], 'amounts': []}
|
||||
|
||||
user_counters = self._counters[user_id]
|
||||
|
||||
# Очистка старых записей
|
||||
user_counters['payments'] = [t for t in user_counters['payments'] if t > window_start]
|
||||
user_counters['amounts'] = [(t, a) for t, a in user_counters['amounts'] if t > window_start]
|
||||
|
||||
# Подсчет текущих значений
|
||||
current_payments = len(user_counters['payments'])
|
||||
current_amount = sum(a for _, a in user_counters['amounts'])
|
||||
|
||||
# Проверка лимитов
|
||||
payments_allowed = current_payments < self.max_payments_per_hour
|
||||
amount_allowed = (current_amount + amount) <= self.max_amount_per_hour
|
||||
allowed = payments_allowed and amount_allowed
|
||||
|
||||
remaining_payments = max(0, self.max_payments_per_hour - current_payments)
|
||||
remaining_amount = max(0, self.max_amount_per_hour - current_amount - amount)
|
||||
|
||||
if not allowed:
|
||||
self.logger.warning(f"Rate limit exceeded for user {user_id}: "
|
||||
f"payments {current_payments}/{self.max_payments_per_hour}, "
|
||||
f"amount {current_amount:.2f}/{self.max_amount_per_hour}")
|
||||
|
||||
return {
|
||||
'allowed': allowed,
|
||||
'remaining_payments': remaining_payments,
|
||||
'remaining_amount': remaining_amount,
|
||||
'reset_time': datetime.fromtimestamp(current_time + 3600)
|
||||
}
|
||||
|
||||
def record_payment(self, user_id: int, amount: float):
|
||||
"""
|
||||
Запись платежа для учета в ограничениях.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
amount: Сумма платежа
|
||||
"""
|
||||
current_time = time.time()
|
||||
|
||||
if user_id not in self._counters:
|
||||
self._counters[user_id] = {'payments': [], 'amounts': []}
|
||||
|
||||
user_counters = self._counters[user_id]
|
||||
|
||||
# Добавление новой записи
|
||||
user_counters['payments'].append(current_time)
|
||||
user_counters['amounts'].append((current_time, amount))
|
||||
|
||||
# Очистка записей старше 1 часа
|
||||
window_start = current_time - 3600
|
||||
user_counters['payments'] = [t for t in user_counters['payments'] if t > window_start]
|
||||
user_counters['amounts'] = [(t, a) for t, a in user_counters['amounts'] if t > window_start]
|
||||
|
||||
self.logger.debug(f"Recorded payment for user {user_id}: amount {amount}")
|
||||
@@ -0,0 +1,377 @@
|
||||
"""
|
||||
Система ролей и разрешений бота.
|
||||
Определяет уровни доступа и правила для различных операций.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Set
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class UserRole(Enum):
|
||||
"""Перечисление ролей пользователей"""
|
||||
USER = "user" # Обычный пользователь
|
||||
MODERATOR = "moderator" # Модератор
|
||||
ADMIN = "admin" # Администратор
|
||||
SUPER_ADMIN = "super_admin" # Супер-администратор
|
||||
|
||||
|
||||
class Permission(Enum):
|
||||
"""Перечисление разрешений"""
|
||||
# Базовые команды (доступны всем)
|
||||
USE_BASIC_COMMANDS = "use_basic_commands"
|
||||
USE_GAMES = "use_games"
|
||||
USE_INFO = "use_info"
|
||||
USE_DONATE = "use_donate"
|
||||
|
||||
# Модераторские разрешения
|
||||
WARN_USERS = "warn_users"
|
||||
MUTE_USERS = "mute_users"
|
||||
VIEW_MODERATION_STATS = "view_moderation_stats"
|
||||
|
||||
# Администраторские разрешения
|
||||
BAN_USERS = "ban_users"
|
||||
KICK_USERS = "kick_users"
|
||||
MANAGE_SCHEDULED_POSTS = "manage_scheduled_posts"
|
||||
EXPORT_DATA = "export_data"
|
||||
VIEW_ADMIN_STATS = "view_admin_stats"
|
||||
|
||||
# Супер-администраторские разрешения
|
||||
MANAGE_SYSTEM = "manage_system"
|
||||
MANAGE_ADMINS = "manage_admins"
|
||||
VIEW_SYSTEM_LOGS = "view_system_logs"
|
||||
MANAGE_ERRORS = "manage_errors"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RolePermissions:
|
||||
"""Разрешения для конкретной роли"""
|
||||
role: UserRole
|
||||
permissions: Set[Permission]
|
||||
description: str
|
||||
|
||||
|
||||
# Определение разрешений для каждой роли
|
||||
ROLE_PERMISSIONS = {
|
||||
UserRole.USER: RolePermissions(
|
||||
role=UserRole.USER,
|
||||
permissions={
|
||||
Permission.USE_BASIC_COMMANDS,
|
||||
Permission.USE_GAMES,
|
||||
Permission.USE_INFO,
|
||||
Permission.USE_DONATE,
|
||||
},
|
||||
description="Обычный пользователь с базовым доступом"
|
||||
),
|
||||
|
||||
UserRole.MODERATOR: RolePermissions(
|
||||
role=UserRole.MODERATOR,
|
||||
permissions={
|
||||
Permission.USE_BASIC_COMMANDS,
|
||||
Permission.USE_GAMES,
|
||||
Permission.USE_INFO,
|
||||
Permission.USE_DONATE,
|
||||
Permission.WARN_USERS,
|
||||
Permission.MUTE_USERS,
|
||||
Permission.VIEW_MODERATION_STATS,
|
||||
},
|
||||
description="Модератор с правами на предупреждения и заглушку"
|
||||
),
|
||||
|
||||
UserRole.ADMIN: RolePermissions(
|
||||
role=UserRole.ADMIN,
|
||||
permissions={
|
||||
Permission.USE_BASIC_COMMANDS,
|
||||
Permission.USE_GAMES,
|
||||
Permission.USE_INFO,
|
||||
Permission.USE_DONATE,
|
||||
Permission.WARN_USERS,
|
||||
Permission.MUTE_USERS,
|
||||
Permission.VIEW_MODERATION_STATS,
|
||||
Permission.BAN_USERS,
|
||||
Permission.KICK_USERS,
|
||||
Permission.MANAGE_SCHEDULED_POSTS,
|
||||
Permission.EXPORT_DATA,
|
||||
Permission.VIEW_ADMIN_STATS,
|
||||
},
|
||||
description="Администратор с полными правами модерации и управления"
|
||||
),
|
||||
|
||||
UserRole.SUPER_ADMIN: RolePermissions(
|
||||
role=UserRole.SUPER_ADMIN,
|
||||
permissions=set(Permission), # Все разрешения
|
||||
description="Супер-администратор с полным доступом к системе"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class PermissionManager:
|
||||
"""
|
||||
Менеджер разрешений для проверки прав доступа.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.role_permissions = ROLE_PERMISSIONS
|
||||
|
||||
def get_role_permissions(self, role: UserRole) -> Set[Permission]:
|
||||
"""
|
||||
Получение множества разрешений для роли.
|
||||
|
||||
Args:
|
||||
role: Роль пользователя
|
||||
|
||||
Returns:
|
||||
Множество разрешений
|
||||
"""
|
||||
if role in self.role_permissions:
|
||||
return self.role_permissions[role].permissions
|
||||
return set()
|
||||
|
||||
def has_permission(self, user_role: UserRole, permission: Permission) -> bool:
|
||||
"""
|
||||
Проверка наличия разрешения у роли.
|
||||
|
||||
Args:
|
||||
user_role: Роль пользователя
|
||||
permission: Проверяемое разрешение
|
||||
|
||||
Returns:
|
||||
True если разрешение есть
|
||||
"""
|
||||
role_permissions = self.get_role_permissions(user_role)
|
||||
return permission in role_permissions
|
||||
|
||||
def get_user_role_from_config(self, user_id: int, config) -> UserRole:
|
||||
"""
|
||||
Определение роли пользователя на основе конфигурации.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
config: Конфигурация приложения
|
||||
|
||||
Returns:
|
||||
Роль пользователя
|
||||
"""
|
||||
print(f"[DEBUG] get_user_role_from_config: user_id={user_id}")
|
||||
|
||||
# Проверяем супер-администраторов
|
||||
if hasattr(config, 'bot_config') and hasattr(config.bot_config, 'super_admin_ids'):
|
||||
super_admin_ids = config.bot_config.super_admin_ids
|
||||
print(f"[DEBUG] get_user_role_from_config: super_admin_ids={super_admin_ids}")
|
||||
if user_id in super_admin_ids:
|
||||
print(f"[DEBUG] get_user_role_from_config: user {user_id} is SUPER_ADMIN")
|
||||
return UserRole.SUPER_ADMIN
|
||||
|
||||
# Проверяем администраторов
|
||||
if hasattr(config, 'bot_config') and hasattr(config.bot_config, 'admin_ids'):
|
||||
admin_ids = config.bot_config.admin_ids
|
||||
print(f"[DEBUG] get_user_role_from_config: admin_ids={admin_ids}")
|
||||
if user_id in admin_ids:
|
||||
print(f"[DEBUG] get_user_role_from_config: user {user_id} is ADMIN")
|
||||
return UserRole.ADMIN
|
||||
|
||||
# Проверяем модераторов
|
||||
if hasattr(config, 'bot_config') and hasattr(config.bot_config, 'moderator_ids'):
|
||||
if user_id in config.bot_config.moderator_ids:
|
||||
return UserRole.MODERATOR
|
||||
|
||||
# По умолчанию - обычный пользователь
|
||||
print(f"[DEBUG] get_user_role_from_config: user {user_id} is USER (default)")
|
||||
return UserRole.USER
|
||||
|
||||
async def get_user_role_from_chat(self, update, user_id: int) -> UserRole:
|
||||
"""
|
||||
Определение роли пользователя на основе прав в чате.
|
||||
|
||||
Args:
|
||||
update: Обновление от Telegram
|
||||
user_id: ID пользователя
|
||||
|
||||
Returns:
|
||||
Роль пользователя
|
||||
"""
|
||||
try:
|
||||
chat = update.effective_chat
|
||||
if chat:
|
||||
member = await chat.get_member(user_id)
|
||||
if member.status == 'creator':
|
||||
return UserRole.SUPER_ADMIN
|
||||
elif member.status == 'administrator':
|
||||
return UserRole.ADMIN
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return UserRole.USER
|
||||
|
||||
async def get_effective_role(self, update, user_id: int, config) -> UserRole:
|
||||
"""
|
||||
Получение эффективной роли пользователя (максимальная из всех источников).
|
||||
|
||||
Args:
|
||||
update: Обновление от Telegram
|
||||
user_id: ID пользователя
|
||||
config: Конфигурация приложения
|
||||
|
||||
Returns:
|
||||
Эффективная роль пользователя
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Проверяем роли в порядке приоритета
|
||||
role_priority = {
|
||||
UserRole.SUPER_ADMIN: 4,
|
||||
UserRole.ADMIN: 3,
|
||||
UserRole.MODERATOR: 2,
|
||||
UserRole.USER: 1
|
||||
}
|
||||
|
||||
# Получаем роль из конфигурации
|
||||
config_role = UserRole.USER
|
||||
if config is not None:
|
||||
config_role = self.get_user_role_from_config(user_id, config)
|
||||
logger.debug(f"[DEBUG] get_effective_role: config_role for user {user_id} = {config_role} (config provided: {config is not None})")
|
||||
|
||||
# Получаем роль из базы данных (если есть доступ к репозиторию)
|
||||
db_role = await self._get_user_role_from_database(user_id)
|
||||
logger.debug(f"[DEBUG] get_effective_role: db_role for user {user_id} = {db_role}")
|
||||
|
||||
# Получаем роль из чата
|
||||
chat_role = UserRole.USER
|
||||
if update is not None:
|
||||
chat_role = await self.get_user_role_from_chat(update, user_id)
|
||||
logger.debug(f"[DEBUG] get_effective_role: chat_role for user {user_id} = {chat_role} (update provided: {update is not None})")
|
||||
|
||||
# Возвращаем роль с максимальным приоритетом
|
||||
roles = [config_role, db_role, chat_role]
|
||||
max_priority_role = max(roles, key=lambda r: role_priority[r])
|
||||
logger.info(f"[ROLE_DEBUG] User {user_id}: config={config_role}, db={db_role}, chat={chat_role} -> FINAL={max_priority_role}")
|
||||
|
||||
return max_priority_role
|
||||
|
||||
async def _get_user_role_from_database(self, user_id: int) -> UserRole:
|
||||
"""
|
||||
Получение роли пользователя из базы данных.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
|
||||
Returns:
|
||||
Роль пользователя из базы данных
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
# Импортируем репозиторий для получения данных пользователя
|
||||
from database.repository import UserRepository
|
||||
|
||||
logger.debug(f"[DEBUG] _get_user_role_from_database: Checking role for user {user_id}")
|
||||
|
||||
# Получаем путь к базе данных из переменной окружения или по умолчанию
|
||||
db_path = os.getenv('DATABASE_URL', 'telegram_bot/telegram_bot.db')
|
||||
logger.debug(f"[DEBUG] _get_user_role_from_database: db_path = {db_path}")
|
||||
user_repo = UserRepository(db_path)
|
||||
user_data = await user_repo.get_by_id_async(user_id)
|
||||
|
||||
logger.debug(f"[DEBUG] _get_user_role_from_database: user_data = {user_data}")
|
||||
|
||||
if user_data and 'role_id' in user_data:
|
||||
role_id = user_data['role_id']
|
||||
logger.debug(f"[DEBUG] _get_user_role_from_database: role_id = {role_id}")
|
||||
|
||||
# Преобразование role_id в UserRole
|
||||
role_mapping = {
|
||||
1: UserRole.USER,
|
||||
2: UserRole.MODERATOR,
|
||||
3: UserRole.ADMIN,
|
||||
4: UserRole.SUPER_ADMIN
|
||||
}
|
||||
role = role_mapping.get(role_id, UserRole.USER)
|
||||
logger.debug(f"[DEBUG] _get_user_role_from_database: mapped to role = {role}")
|
||||
return role
|
||||
else:
|
||||
logger.debug(f"[DEBUG] _get_user_role_from_database: no role_id in user_data")
|
||||
|
||||
except Exception as e:
|
||||
# Если не удалось получить роль из базы данных, возвращаем USER
|
||||
logger.warning(f"[DEBUG] _get_user_role_from_database: exception {e} (returning USER role)")
|
||||
|
||||
return UserRole.USER
|
||||
|
||||
def get_required_permissions_for_command(self, command: str) -> Set[Permission]:
|
||||
"""
|
||||
Получение необходимых разрешений для команды.
|
||||
|
||||
Args:
|
||||
command: Название команды (без слеша)
|
||||
|
||||
Returns:
|
||||
Множество необходимых разрешений
|
||||
"""
|
||||
# Базовые команды доступны всем
|
||||
basic_commands = {
|
||||
'start', 'help', 'info', 'rank', 'leaderboard',
|
||||
'weather', 'news', 'translate', 'donate'
|
||||
}
|
||||
|
||||
if command in basic_commands:
|
||||
return {Permission.USE_BASIC_COMMANDS}
|
||||
|
||||
# Игровые команды
|
||||
game_commands = {'play_game', 'game_rps_start', 'game_2048_start'}
|
||||
if command in game_commands or command.startswith('game_'):
|
||||
return {Permission.USE_GAMES}
|
||||
|
||||
# Модераторские команды
|
||||
moderator_commands = {'warn', 'mute', 'unmute'}
|
||||
if command in moderator_commands:
|
||||
return {Permission.WARN_USERS, Permission.MUTE_USERS}
|
||||
|
||||
# Администраторские команды
|
||||
admin_commands = {
|
||||
'ban', 'unban', 'kick', 'admin_stats', 'export_stats',
|
||||
'schedule_post', 'list_posts', 'delete_post', 'publish_now',
|
||||
'menu_moderation', 'menu_admin', 'menu_triggers', 'admin_users'
|
||||
}
|
||||
if command in admin_commands:
|
||||
return {Permission.BAN_USERS, Permission.VIEW_ADMIN_STATS}
|
||||
|
||||
# Команды системы ошибок (доступны администраторам)
|
||||
error_commands = {
|
||||
'report_error', 'admin_errors', 'analyze_error_ai',
|
||||
'process_all_errors_ai', 'add_error_to_todo', 'add_all_analyzed_to_todo'
|
||||
}
|
||||
if command in error_commands:
|
||||
return {Permission.MANAGE_ERRORS}
|
||||
|
||||
# Меню команды (доступны всем)
|
||||
menu_commands = {
|
||||
'menu_main', 'menu_help', 'menu_rank', 'menu_donate', 'menu_leaderboard'
|
||||
}
|
||||
if command in menu_commands:
|
||||
return {Permission.USE_BASIC_COMMANDS}
|
||||
|
||||
# По умолчанию требуем базовые разрешения
|
||||
return {Permission.USE_BASIC_COMMANDS}
|
||||
|
||||
def can_execute_command(self, user_role: UserRole, command: str) -> bool:
|
||||
"""
|
||||
Проверка возможности выполнения команды пользователем.
|
||||
|
||||
Args:
|
||||
user_role: Роль пользователя
|
||||
command: Название команды
|
||||
|
||||
Returns:
|
||||
True если команда может быть выполнена
|
||||
"""
|
||||
required_permissions = self.get_required_permissions_for_command(command)
|
||||
user_permissions = self.get_role_permissions(user_role)
|
||||
|
||||
return required_permissions.issubset(user_permissions)
|
||||
|
||||
|
||||
# Глобальный экземпляр менеджера разрешений
|
||||
permission_manager = PermissionManager()
|
||||
@@ -0,0 +1,291 @@
|
||||
"""
|
||||
Rate limiter для ограничения частоты запросов пользователей.
|
||||
Обеспечивает защиту от спама и злоупотреблений.
|
||||
|
||||
Особенности:
|
||||
- Sliding window алгоритм для точного учета запросов во времени
|
||||
- Поддержка ролевой системы (администраторы не ограничены)
|
||||
- Дифференцированные лимиты для пользователей разных рангов:
|
||||
* Новые пользователи (Рядовой, Ефрейтор): 5 запросов/минуту
|
||||
* Обычные пользователи: 10 запросов/минуту
|
||||
* Администраторы: без ограничений
|
||||
- Потокобезопасная реализация с автоматической очисткой старых записей
|
||||
- Интеграция с системой рангов для определения статуса пользователя
|
||||
"""
|
||||
|
||||
import time
|
||||
import threading
|
||||
from typing import Dict, Optional, Tuple
|
||||
from collections import defaultdict
|
||||
from .permissions import UserRole
|
||||
|
||||
|
||||
class RateLimitExceeded(Exception):
|
||||
"""Исключение при превышении лимита запросов"""
|
||||
|
||||
def __init__(self, retry_after: int):
|
||||
self.retry_after = retry_after
|
||||
super().__init__(f"Rate limit exceeded. Retry after {retry_after} seconds")
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""
|
||||
Rate limiter на основе sliding window алгоритма.
|
||||
Отслеживает запросы пользователей в скользящем окне времени.
|
||||
|
||||
Основные возможности:
|
||||
- Проверка лимитов без записи запроса (check_limit)
|
||||
- Запись запроса с проверкой лимита (record_request)
|
||||
- Получение количества оставшихся запросов (get_remaining_requests)
|
||||
- Получение статистики использования (get_stats)
|
||||
- Автоматическая очистка устаревших записей
|
||||
- Поддержка ранговой системы для дифференциации лимитов
|
||||
|
||||
Лимиты по умолчанию:
|
||||
- Новые пользователи (Рядовой/Ефрейтор): 5 запросов в минуту
|
||||
- Обычные пользователи: 10 запросов в минуту
|
||||
- Администраторы: без ограничений
|
||||
"""
|
||||
|
||||
def __init__(self, window_size: int = 60, max_requests: int = 10, cleanup_interval: int = 300):
|
||||
"""
|
||||
Инициализация rate limiter.
|
||||
|
||||
Args:
|
||||
window_size: Размер окна в секундах (по умолчанию 60 секунд = 1 минута)
|
||||
max_requests: Максимальное количество запросов в окне (по умолчанию 10)
|
||||
cleanup_interval: Интервал очистки старых записей в секундах (по умолчанию 5 минут)
|
||||
"""
|
||||
self.window_size = window_size
|
||||
self.max_requests = max_requests
|
||||
self.cleanup_interval = cleanup_interval
|
||||
|
||||
# Структура: user_id -> список временных меток запросов
|
||||
self.requests: Dict[int, list] = defaultdict(list)
|
||||
|
||||
# Блокировка для потокобезопасности
|
||||
self.lock = threading.RLock()
|
||||
|
||||
# Настройки для новых пользователей (более строгие ограничения)
|
||||
self.new_user_max_requests = 5 # 5 запросов в минуту для новых пользователей
|
||||
|
||||
# Исключения для ролей (администраторы не ограничены)
|
||||
self.exempt_roles = {UserRole.ADMIN, UserRole.SUPER_ADMIN}
|
||||
|
||||
# Запуск автоматической очистки
|
||||
self._start_cleanup_thread()
|
||||
|
||||
def _start_cleanup_thread(self):
|
||||
"""Запуск фонового потока для очистки старых записей"""
|
||||
def cleanup_worker():
|
||||
while True:
|
||||
time.sleep(self.cleanup_interval)
|
||||
self._cleanup_old_requests()
|
||||
|
||||
thread = threading.Thread(target=cleanup_worker, daemon=True)
|
||||
thread.start()
|
||||
|
||||
def _cleanup_old_requests(self):
|
||||
"""Очистка старых временных меток запросов"""
|
||||
current_time = time.time()
|
||||
cutoff_time = current_time - self.window_size
|
||||
|
||||
with self.lock:
|
||||
for user_id in list(self.requests.keys()):
|
||||
# Оставляем только свежие запросы
|
||||
self.requests[user_id] = [
|
||||
timestamp for timestamp in self.requests[user_id]
|
||||
if timestamp > cutoff_time
|
||||
]
|
||||
|
||||
# Удаляем пустые записи
|
||||
if not self.requests[user_id]:
|
||||
del self.requests[user_id]
|
||||
|
||||
def _get_max_requests_for_user(self, user_role: Optional[UserRole], is_new_user: bool = False) -> int:
|
||||
"""
|
||||
Определение максимального количества запросов для пользователя.
|
||||
|
||||
Args:
|
||||
user_role: Роль пользователя
|
||||
is_new_user: Является ли пользователь новым
|
||||
|
||||
Returns:
|
||||
Максимальное количество запросов в окне
|
||||
"""
|
||||
# Администраторы не ограничены
|
||||
if user_role in self.exempt_roles:
|
||||
return float('inf')
|
||||
|
||||
# Новые пользователи имеют более строгие ограничения
|
||||
if is_new_user:
|
||||
return self.new_user_max_requests
|
||||
|
||||
return self.max_requests
|
||||
|
||||
def _is_new_user(self, user_id: int, registration_time: Optional[float] = None, user_rank: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Определение, является ли пользователь новым на основе ранга или количества запросов.
|
||||
|
||||
Логика определения новых пользователей:
|
||||
1. Если указано время регистрации - проверяем, прошло ли менее 24 часов
|
||||
2. Если известен ранг - проверяем, является ли он начальным ("Рядовой", "Ефрейтор")
|
||||
3. Иначе проверяем количество предыдущих запросов (< max_requests)
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
registration_time: Время регистрации (если известно)
|
||||
user_rank: Ранг пользователя (если известен)
|
||||
|
||||
Returns:
|
||||
True если пользователь новый (низкий ранг или мало запросов)
|
||||
"""
|
||||
# Если время регистрации известно, проверяем его
|
||||
if registration_time:
|
||||
return (time.time() - registration_time) < 86400 # 24 часа
|
||||
|
||||
# Если ранг известен, считаем нового пользователями с низкими рангами
|
||||
if user_rank:
|
||||
# Новички: Рядовой и Ефрейтор считаются новыми пользователями
|
||||
low_ranks = ["Рядовой", "Ефрейтор"]
|
||||
return user_rank in low_ranks
|
||||
|
||||
# Иначе проверяем по количеству предыдущих запросов
|
||||
# Если запросов меньше максимального лимита, считаем пользователя новым
|
||||
with self.lock:
|
||||
return len(self.requests.get(user_id, [])) < self.max_requests
|
||||
|
||||
async def check_limit(self, user_id: int, user_role: Optional[UserRole] = None,
|
||||
registration_time: Optional[float] = None, user_rank: Optional[str] = None) -> Tuple[bool, Optional[int]]:
|
||||
"""
|
||||
Проверка, не превышен ли лимит запросов.
|
||||
Только проверяет, не добавляет запрос в счетчик.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
user_role: Роль пользователя
|
||||
registration_time: Время регистрации пользователя
|
||||
user_rank: Ранг пользователя
|
||||
|
||||
Returns:
|
||||
Кортеж (разрешено: bool, время_ожидания: Optional[int])
|
||||
"""
|
||||
current_time = time.time()
|
||||
|
||||
with self.lock:
|
||||
# Определяем лимиты для пользователя
|
||||
is_new_user = self._is_new_user(user_id, registration_time, user_rank)
|
||||
max_requests = self._get_max_requests_for_user(user_role, is_new_user)
|
||||
|
||||
# Администраторы всегда разрешены
|
||||
if max_requests == float('inf'):
|
||||
return True, None
|
||||
|
||||
# Очищаем старые запросы для этого пользователя
|
||||
cutoff_time = current_time - self.window_size
|
||||
user_requests = self.requests[user_id]
|
||||
user_requests[:] = [t for t in user_requests if t > cutoff_time]
|
||||
|
||||
# Проверяем лимит
|
||||
if len(user_requests) >= max_requests:
|
||||
# Вычисляем время до следующего разрешенного запроса
|
||||
oldest_request = min(user_requests)
|
||||
retry_after = int(self.window_size - (current_time - oldest_request))
|
||||
return False, max(1, retry_after)
|
||||
|
||||
# Возвращаем разрешение без добавления запроса
|
||||
return True, None
|
||||
|
||||
async def record_request(self, user_id: int, user_role: Optional[UserRole] = None,
|
||||
registration_time: Optional[float] = None, user_rank: Optional[str] = None) -> None:
|
||||
"""
|
||||
Запись факта выполнения запроса (для случаев, когда проверка уже пройдена).
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
user_role: Роль пользователя
|
||||
registration_time: Время регистрации пользователя
|
||||
user_rank: Ранг пользователя
|
||||
"""
|
||||
current_time = time.time()
|
||||
|
||||
with self.lock:
|
||||
# Определяем лимиты для пользователя
|
||||
is_new_user = self._is_new_user(user_id, registration_time, user_rank)
|
||||
max_requests = self._get_max_requests_for_user(user_role, is_new_user)
|
||||
|
||||
# Администраторы всегда разрешены
|
||||
if max_requests == float('inf'):
|
||||
return
|
||||
|
||||
# Очищаем старые запросы для этого пользователя
|
||||
cutoff_time = current_time - self.window_size
|
||||
user_requests = self.requests[user_id]
|
||||
user_requests[:] = [t for t in user_requests if t > cutoff_time]
|
||||
|
||||
# Проверяем лимит
|
||||
if len(user_requests) >= max_requests:
|
||||
# Вычисляем время до следующего разрешенного запроса
|
||||
oldest_request = min(user_requests)
|
||||
retry_after = int(self.window_size - (current_time - oldest_request))
|
||||
raise RateLimitExceeded(max(1, retry_after))
|
||||
|
||||
# Добавляем текущий запрос
|
||||
user_requests.append(current_time)
|
||||
|
||||
def get_remaining_requests(self, user_id: int, user_role: Optional[UserRole] = None,
|
||||
registration_time: Optional[float] = None, user_rank: Optional[str] = None) -> int:
|
||||
"""
|
||||
Получение количества оставшихся запросов в текущем окне.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
user_role: Роль пользователя
|
||||
registration_time: Время регистрации пользователя
|
||||
user_rank: Ранг пользователя
|
||||
|
||||
Returns:
|
||||
Количество оставшихся запросов
|
||||
"""
|
||||
with self.lock:
|
||||
is_new_user = self._is_new_user(user_id, registration_time, user_rank)
|
||||
max_requests = self._get_max_requests_for_user(user_role, is_new_user)
|
||||
|
||||
if max_requests == float('inf'):
|
||||
return 999 # Для администраторов возвращаем большое число
|
||||
|
||||
# Очищаем старые запросы перед подсчетом
|
||||
current_time = time.time()
|
||||
cutoff_time = current_time - self.window_size
|
||||
user_requests = self.requests[user_id]
|
||||
user_requests[:] = [t for t in user_requests if t > cutoff_time]
|
||||
|
||||
current_requests = len(user_requests)
|
||||
return max(0, max_requests - current_requests)
|
||||
|
||||
def get_stats(self) -> Dict[str, any]:
|
||||
"""
|
||||
Получение статистики rate limiter.
|
||||
|
||||
Returns:
|
||||
Словарь со статистикой
|
||||
"""
|
||||
with self.lock:
|
||||
total_users = len(self.requests)
|
||||
total_requests = sum(len(requests) for requests in self.requests.values())
|
||||
|
||||
return {
|
||||
'total_users_tracked': total_users,
|
||||
'total_requests_in_window': total_requests,
|
||||
'window_size_seconds': self.window_size,
|
||||
'default_max_requests': self.max_requests,
|
||||
'new_user_max_requests': self.new_user_max_requests
|
||||
}
|
||||
|
||||
|
||||
# Глобальный экземпляр rate limiter
|
||||
# Настройки согласно требованиям:
|
||||
# - 10 команд/минуту для обычных пользователей
|
||||
# - 5 команд/минуту для новых пользователей (Рядовой, Ефрейтор)
|
||||
# - Без ограничений для администраторов
|
||||
rate_limiter = RateLimiter(window_size=60, max_requests=10)
|
||||
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
Защита от множественных экземпляров бота.
|
||||
Использует файл-блокировку для предотвращения запуска нескольких копий.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
# fcntl доступен только на Unix-подобных системах
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError:
|
||||
fcntl = None
|
||||
|
||||
|
||||
class SingleInstance:
|
||||
"""
|
||||
Класс для обеспечения запуска только одного экземпляра приложения.
|
||||
Использует файловую блокировку для синхронизации.
|
||||
"""
|
||||
|
||||
def __init__(self, lock_file_path: str = None):
|
||||
"""
|
||||
Инициализация защиты от множественных экземпляров.
|
||||
|
||||
Args:
|
||||
lock_file_path: Путь к файлу блокировки (по умолчанию в директории проекта)
|
||||
"""
|
||||
if lock_file_path is None:
|
||||
# Создаем файл блокировки в директории проекта
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
lock_file_path = os.path.join(project_root, '.bot.lock')
|
||||
|
||||
self.lock_file_path = lock_file_path
|
||||
self.lock_file = None
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def acquire_lock(self) -> bool:
|
||||
"""
|
||||
Попытка получить блокировку.
|
||||
Если блокировка уже установлена другим процессом, возвращает False.
|
||||
|
||||
Returns:
|
||||
True если блокировка получена успешно, False в противном случае
|
||||
"""
|
||||
try:
|
||||
# Создаем директорию если не существует
|
||||
os.makedirs(os.path.dirname(self.lock_file_path), exist_ok=True)
|
||||
|
||||
# Открываем файл блокировки
|
||||
self.lock_file = open(self.lock_file_path, 'w')
|
||||
|
||||
# Пытаемся получить эксклюзивную блокировку
|
||||
# На Windows fcntl не работает, используем альтернативный подход
|
||||
if os.name == 'nt': # Windows
|
||||
try:
|
||||
# На Windows используем простую проверку существования процесса
|
||||
# путем создания PID файла
|
||||
import psutil
|
||||
|
||||
# Читаем PID из файла блокировки если он существует
|
||||
if os.path.exists(self.lock_file_path):
|
||||
try:
|
||||
with open(self.lock_file_path, 'r') as f:
|
||||
old_pid = int(f.read().strip())
|
||||
if psutil.pid_exists(old_pid):
|
||||
self.logger.warning(f"Найден запущенный процесс с PID {old_pid}")
|
||||
return False
|
||||
else:
|
||||
self.logger.info(f"Процесс с PID {old_pid} больше не существует, очищаем блокировку")
|
||||
except (ValueError, FileNotFoundError):
|
||||
pass
|
||||
|
||||
# Записываем текущий PID
|
||||
self.lock_file.write(str(os.getpid()))
|
||||
self.lock_file.flush()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Ошибка при проверке процесса на Windows: {e}")
|
||||
return False
|
||||
else: # Unix-like системы
|
||||
# Используем fcntl для блокировки файла
|
||||
if fcntl is not None:
|
||||
fcntl.flock(self.lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
# Записываем PID для информации
|
||||
self.lock_file.write(str(os.getpid()))
|
||||
self.lock_file.flush()
|
||||
return True
|
||||
|
||||
except BlockingIOError:
|
||||
# Блокировка уже установлена другим процессом
|
||||
self.logger.warning("Блокировка уже установлена другим экземпляром бота")
|
||||
self._cleanup()
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error(f"Ошибка при получении блокировки: {e}")
|
||||
self._cleanup()
|
||||
return False
|
||||
|
||||
def release_lock(self):
|
||||
"""Освобождает блокировку и закрывает файл"""
|
||||
try:
|
||||
if self.lock_file:
|
||||
if os.name != 'nt' and fcntl is not None: # Только для Unix-like систем с fcntl
|
||||
try:
|
||||
fcntl.flock(self.lock_file.fileno(), fcntl.LOCK_UN)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Ошибка снятия блокировки: {e}")
|
||||
|
||||
self.lock_file.close()
|
||||
self.lock_file = None
|
||||
|
||||
# Удаляем файл блокировки
|
||||
try:
|
||||
if os.path.exists(self.lock_file_path):
|
||||
os.remove(self.lock_file_path)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Ошибка удаления файла блокировки: {e}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Ошибка при освобождении блокировки: {e}")
|
||||
|
||||
def _cleanup(self):
|
||||
"""Очистка ресурсов"""
|
||||
try:
|
||||
if self.lock_file:
|
||||
self.lock_file.close()
|
||||
self.lock_file = None
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Ошибка при очистке ресурсов: {e}")
|
||||
|
||||
def __enter__(self):
|
||||
"""Контекстный менеджер - вход"""
|
||||
if not self.acquire_lock():
|
||||
raise RuntimeError("Не удалось получить блокировку. Возможно, другой экземпляр бота уже запущен.")
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Контекстный менеджер - выход"""
|
||||
self.release_lock()
|
||||
|
||||
|
||||
def check_single_instance(lock_file_path: str = None) -> bool:
|
||||
"""
|
||||
Проверяет и устанавливает блокировку для единственного экземпляра.
|
||||
|
||||
Args:
|
||||
lock_file_path: Путь к файлу блокировки
|
||||
|
||||
Returns:
|
||||
True если блокировка установлена успешно, False в противном случае
|
||||
|
||||
Raises:
|
||||
RuntimeError: Если не удалось установить блокировку
|
||||
"""
|
||||
instance = SingleInstance(lock_file_path)
|
||||
if instance.acquire_lock():
|
||||
# Сохраняем ссылку на объект блокировки чтобы GC не удалил его
|
||||
import atexit
|
||||
atexit.register(instance.release_lock)
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
@@ -0,0 +1,325 @@
|
||||
"""
|
||||
Объединенный маршрутизатор для всех типов обновлений.
|
||||
Интегрирует CommandRouter, MessageTypeRouter и ContextMenuManager.
|
||||
|
||||
Архитектура:
|
||||
- CommandRouter: обработка команд и callback'ов через middleware и проверки прав
|
||||
- MessageTypeRouter: маршрутизация сообщений по типам контента
|
||||
- ContextMenuManager: управление контекстными меню и проверка доступности
|
||||
|
||||
Алгоритм обработки callback'ов:
|
||||
1. Проверка роли пользователя и доступности меню
|
||||
2. Попытка обработки через CommandRouter (новая система)
|
||||
3. Fallback к MessageTypeRouter при неудаче
|
||||
|
||||
Преимущества:
|
||||
- Централизованная маршрутизация всех типов обновлений
|
||||
- Гибкая система middleware для команд
|
||||
- Поддержка ролевой модели доступа
|
||||
- Легкая расширяемость новыми типами обработчиков
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
from telegram import Update
|
||||
from telegram.ext import ContextTypes
|
||||
from .command_router import CommandRouter
|
||||
from .message_router import MessageTypeRouter
|
||||
from .menu_manager import ContextMenuManager
|
||||
from .permissions import UserRole
|
||||
|
||||
|
||||
class UnifiedMessageRouter:
|
||||
"""
|
||||
Объединенный маршрутизатор для всех типов обновлений.
|
||||
|
||||
Обеспечивает:
|
||||
- Единый интерфейс для обработки всех типов сообщений
|
||||
- Интеграцию с существующими компонентами
|
||||
- Централизованное управление маршрутизацией
|
||||
- Логирование и мониторинг
|
||||
- Правильную обработку callback'ов через CommandRouter с fallback'ом
|
||||
"""
|
||||
|
||||
def __init__(self, command_router: CommandRouter,
|
||||
message_router: MessageTypeRouter,
|
||||
menu_manager: ContextMenuManager):
|
||||
"""
|
||||
Инициализация объединенного маршрутизатора.
|
||||
|
||||
Args:
|
||||
command_router: Маршрутизатор команд
|
||||
message_router: Маршрутизатор типов сообщений
|
||||
menu_manager: Менеджер контекстных меню
|
||||
"""
|
||||
self.command_router = command_router
|
||||
self.message_router = message_router
|
||||
self.menu_manager = menu_manager
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
async def handle_update(self, update: Update, context: ContextTypes) -> bool:
|
||||
"""
|
||||
Единая точка входа для обработки всех типов обновлений.
|
||||
|
||||
Args:
|
||||
update: Обновление от Telegram
|
||||
context: Контекст бота
|
||||
|
||||
Returns:
|
||||
True если обновление было успешно обработано
|
||||
"""
|
||||
try:
|
||||
# Определение типа обновления и маршрутизация
|
||||
if update.message:
|
||||
return await self._handle_message_update(update, context)
|
||||
elif update.callback_query:
|
||||
return await self._handle_callback_update(update, context)
|
||||
elif update.inline_query:
|
||||
return await self._handle_inline_query(update, context)
|
||||
elif update.chosen_inline_result:
|
||||
return await self._handle_chosen_inline_result(update, context)
|
||||
elif update.chat_member:
|
||||
return await self._handle_chat_member_update(update, context)
|
||||
else:
|
||||
self.logger.debug("Unhandled update type")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in unified router: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
async def _handle_message_update(self, update: Update, context: ContextTypes) -> bool:
|
||||
"""
|
||||
Обработка обновлений типа message.
|
||||
|
||||
Args:
|
||||
update: Обновление с сообщением
|
||||
context: Контекст бота
|
||||
|
||||
Returns:
|
||||
True если сообщение обработано
|
||||
"""
|
||||
message = update.message
|
||||
|
||||
# Проверка типа сообщения
|
||||
if message.text and message.text.startswith('/'):
|
||||
# Команды обрабатываются через CommandRouter
|
||||
self.logger.debug("Routing to command router")
|
||||
await self.command_router.handle_command(update, context)
|
||||
return True
|
||||
|
||||
elif message.text:
|
||||
# Текстовые сообщения через MessageTypeRouter
|
||||
self.logger.debug("Routing text message")
|
||||
return await self.message_router.route_text_message(update, context)
|
||||
|
||||
elif message.voice:
|
||||
# Голосовые сообщения
|
||||
self.logger.debug("Routing voice message")
|
||||
return await self.message_router.route_voice_message(update, context)
|
||||
|
||||
elif message.photo or message.video or message.audio or message.document:
|
||||
# Медиа-сообщения
|
||||
self.logger.debug("Routing media message")
|
||||
return await self.message_router.route_media_message(update, context)
|
||||
|
||||
else:
|
||||
self.logger.debug("Unhandled message type")
|
||||
return False
|
||||
|
||||
async def _handle_callback_update(self, update: Update, context: ContextTypes) -> bool:
|
||||
"""
|
||||
Обработка callback запросов с учетом контекста меню.
|
||||
|
||||
Алгоритм обработки:
|
||||
1. Получение роли пользователя для проверки прав доступа
|
||||
2. Проверка доступности меню (для запросов menu_*)
|
||||
3. Попытка обработки через CommandRouter (новая система маршрутизации)
|
||||
4. Fallback к MessageTypeRouter при неудаче
|
||||
|
||||
Args:
|
||||
update: Обновление с callback
|
||||
context: Контекст бота
|
||||
|
||||
Returns:
|
||||
True если callback обработан
|
||||
"""
|
||||
callback_data = update.callback_query.data
|
||||
self.logger.debug(f"Processing callback: {callback_data}")
|
||||
|
||||
# Получение роли пользователя
|
||||
user_role = await self._get_user_role(update)
|
||||
|
||||
# Проверка доступности меню (если это запрос меню)
|
||||
if callback_data.startswith('menu_'):
|
||||
if not self.menu_manager.is_menu_available(callback_data, user_role):
|
||||
await update.callback_query.answer("Недостаточно прав доступа")
|
||||
return True
|
||||
|
||||
# Сначала пытаемся маршрутизировать через CommandRouter (новая система)
|
||||
try:
|
||||
result = await self.command_router.handle_callback(update, context)
|
||||
if result:
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in CommandRouter callback handler: {e}")
|
||||
|
||||
# Fallback к MessageTypeRouter
|
||||
return await self.message_router.route_callback(update, context)
|
||||
|
||||
async def _handle_inline_query(self, update: Update, context: ContextTypes) -> bool:
|
||||
"""
|
||||
Обработка инлайновых запросов.
|
||||
|
||||
Args:
|
||||
update: Инлайновый запрос
|
||||
context: Контекст бота
|
||||
|
||||
Returns:
|
||||
True если запрос обработан
|
||||
"""
|
||||
self.logger.debug("Processing inline query")
|
||||
return await self.message_router.route_inline_query(update, context)
|
||||
|
||||
async def _handle_chosen_inline_result(self, update: Update, context: ContextTypes) -> bool:
|
||||
"""
|
||||
Обработка выбора результата инлайнового запроса.
|
||||
|
||||
Args:
|
||||
update: Выбранный результат
|
||||
context: Контекст бота
|
||||
|
||||
Returns:
|
||||
True если результат обработан
|
||||
"""
|
||||
self.logger.debug("Processing chosen inline result")
|
||||
# Здесь может быть дополнительная логика обработки выбора
|
||||
return True
|
||||
|
||||
async def _handle_chat_member_update(self, update: Update, context: ContextTypes) -> bool:
|
||||
"""
|
||||
Обработка обновлений участников чата.
|
||||
|
||||
Args:
|
||||
update: Обновление участников чата
|
||||
context: Контекст бота
|
||||
|
||||
Returns:
|
||||
True если обновление обработано
|
||||
"""
|
||||
self.logger.debug("Processing chat member update")
|
||||
# Здесь может быть логика обработки вступления/выхода участников
|
||||
return True
|
||||
|
||||
async def _get_user_role(self, update: Update) -> UserRole:
|
||||
"""
|
||||
Получение роли пользователя для данного обновления.
|
||||
|
||||
Args:
|
||||
update: Обновление от Telegram
|
||||
|
||||
Returns:
|
||||
Роль пользователя
|
||||
"""
|
||||
try:
|
||||
user = update.effective_user
|
||||
if user:
|
||||
# Использование существующей логики определения роли
|
||||
from .permissions import permission_manager
|
||||
# Попытка получить config из Application
|
||||
try:
|
||||
from .application import app_instance
|
||||
if app_instance and hasattr(app_instance, 'config'):
|
||||
config = app_instance.config
|
||||
else:
|
||||
# Создаем минимальный конфиг
|
||||
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()
|
||||
except ImportError:
|
||||
# Создаем минимальный конфиг если Application недоступен
|
||||
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 await permission_manager.get_effective_role(
|
||||
update, user.id, config
|
||||
)
|
||||
return UserRole.USER
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting user role: {e}")
|
||||
return UserRole.USER
|
||||
|
||||
async def get_menu_for_user(self, menu_id: str, user_role: UserRole,
|
||||
**context) -> Optional[object]:
|
||||
"""
|
||||
Получение меню для пользователя через ContextMenuManager.
|
||||
|
||||
Args:
|
||||
menu_id: Идентификатор меню
|
||||
user_role: Роль пользователя
|
||||
**context: Дополнительный контекст
|
||||
|
||||
Returns:
|
||||
Объект меню или None
|
||||
"""
|
||||
# Определение типа чата для контекстного построения меню
|
||||
chat_type = context.get('chat_type', 'private')
|
||||
context['chat_type'] = chat_type
|
||||
|
||||
return await self.menu_manager.get_menu_for_user(menu_id, user_role, **context)
|
||||
|
||||
def get_registered_handlers_count(self) -> dict:
|
||||
"""
|
||||
Получение статистики зарегистрированных обработчиков.
|
||||
|
||||
Returns:
|
||||
Словарь со статистикой
|
||||
"""
|
||||
command_handlers = len(self.command_router.command_handlers)
|
||||
callback_handlers = len(self.command_router.callback_handlers)
|
||||
message_handlers = self.message_router.get_registered_handlers_count()
|
||||
|
||||
return {
|
||||
'command_handlers': command_handlers,
|
||||
'callback_handlers': callback_handlers,
|
||||
'message_handlers': message_handlers,
|
||||
'total': command_handlers + callback_handlers + sum(message_handlers.values())
|
||||
}
|
||||
|
||||
def clear_menu_cache(self):
|
||||
"""Очистка кеша меню"""
|
||||
self.menu_manager.clear_cache()
|
||||
|
||||
|
||||
def create_unified_router(command_router: CommandRouter,
|
||||
message_router: MessageTypeRouter,
|
||||
menu_manager: ContextMenuManager) -> UnifiedMessageRouter:
|
||||
"""
|
||||
Создание объединенного маршрутизатора.
|
||||
|
||||
Args:
|
||||
command_router: Маршрутизатор команд
|
||||
message_router: Маршрутизатор типов сообщений
|
||||
menu_manager: Менеджер меню
|
||||
|
||||
Returns:
|
||||
UnifiedMessageRouter: Настроенный объединенный маршрутизатор
|
||||
"""
|
||||
return UnifiedMessageRouter(command_router, message_router, menu_manager)
|
||||
Reference in New Issue
Block a user