Подготовка к релизу

This commit is contained in:
2025-10-30 18:28:26 +03:00
parent 69c0162c6b
commit 4981db90a0
191 changed files with 45557 additions and 3651 deletions
+16
View File
@@ -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
View File
@@ -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}")
+985
View File
@@ -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()
+335
View File
@@ -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
View File
@@ -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', []))})"
+70
View File
@@ -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
+451
View File
@@ -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
+517
View File
@@ -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")
+382
View File
@@ -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)
+339
View File
@@ -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
+40
View File
@@ -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
+392
View File
@@ -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
+126
View File
@@ -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 = []
+641
View File
@@ -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}")
+377
View File
@@ -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()
+291
View File
@@ -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)
+165
View File
@@ -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
+325
View File
@@ -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)