From e15b18fb41b180e7dd3d65f2f058667be321fe85 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 18 Mar 2026 23:16:54 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=8B=D0=B5=20=D1=82=D0=BE=D0=BF=D0=B8=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D1=81?= =?UTF-8?q?=D0=BA=D0=B8=D1=85=20=D1=83=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлены 9 новых env-переменных для маршрутизации уведомлений по отдельным топикам: - PURCHASES, RENEWALS, TRIALS, BALANCE, ADDONS - INFRASTRUCTURE, ERRORS, PROMO, PARTNERS Обратная совместимость: если топик для категории не задан — fallback на ADMIN_NOTIFICATIONS_TOPIC_ID. --- app/config.py | 11 ++ app/handlers/referral.py | 6 +- app/middlewares/global_error.py | 5 +- app/services/admin_notification_service.py | 117 ++++++++++++++----- app/services/backup_service.py | 4 +- app/services/maintenance_service.py | 8 +- app/services/referral_contest_service.py | 2 +- app/services/startup_notification_service.py | 10 +- app/services/system_settings_service.py | 9 ++ 9 files changed, 130 insertions(+), 42 deletions(-) diff --git a/app/config.py b/app/config.py index 214a6b81..d091742f 100644 --- a/app/config.py +++ b/app/config.py @@ -56,6 +56,17 @@ class Settings(BaseSettings): ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID: int | None = None ADMIN_NOTIFICATIONS_NALOG_TOPIC_ID: int | None = None + # Раздельные топики для уведомлений (если не задано — fallback на ADMIN_NOTIFICATIONS_TOPIC_ID) + ADMIN_NOTIFICATIONS_PURCHASES_TOPIC_ID: int | None = None # Покупки подписок + ADMIN_NOTIFICATIONS_RENEWALS_TOPIC_ID: int | None = None # Продления + ADMIN_NOTIFICATIONS_TRIALS_TOPIC_ID: int | None = None # Триалы + ADMIN_NOTIFICATIONS_BALANCE_TOPIC_ID: int | None = None # Пополнение баланса + ADMIN_NOTIFICATIONS_ADDONS_TOPIC_ID: int | None = None # Докупка трафика/устройств/серверов + ADMIN_NOTIFICATIONS_INFRASTRUCTURE_TOPIC_ID: int | None = None # Ноды, техработы, статус панели + ADMIN_NOTIFICATIONS_ERRORS_TOPIC_ID: int | None = None # Ошибки бота + ADMIN_NOTIFICATIONS_PROMO_TOPIC_ID: int | None = None # Промокоды, кампании, промогруппы + ADMIN_NOTIFICATIONS_PARTNERS_TOPIC_ID: int | None = None # Партнёрки, выводы, админ-действия + # Настройки очереди чеков NaloGO NALOGO_QUEUE_CHECK_INTERVAL: int = 300 # Интервал проверки очереди (секунды) NALOGO_QUEUE_RECEIPT_DELAY: int = 3 # Задержка между отправкой чеков (секунды) diff --git a/app/handlers/referral.py b/app/handlers/referral.py index b276fbff..f9bf1351 100644 --- a/app/handlers/referral.py +++ b/app/handlers/referral.py @@ -14,7 +14,7 @@ from app.config import settings from app.database.models import User from app.keyboards.inline import get_referral_keyboard from app.localization.texts import get_texts -from app.services.admin_notification_service import AdminNotificationService +from app.services.admin_notification_service import AdminNotificationService, NotificationCategory from app.services.referral_withdrawal_service import referral_withdrawal_service from app.states import ReferralWithdrawalStates from app.utils.photo_message import edit_or_answer_photo @@ -825,7 +825,9 @@ async def confirm_withdrawal_request(callback: types.CallbackQuery, db_user: Use try: notification_service = AdminNotificationService(callback.bot) - await notification_service.send_admin_notification(admin_text, reply_markup=admin_keyboard) + await notification_service.send_admin_notification( + admin_text, reply_markup=admin_keyboard, category=NotificationCategory.PARTNERS + ) except Exception as e: logger.error('Ошибка отправки уведомления админам о заявке на вывод', error=e) diff --git a/app/middlewares/global_error.py b/app/middlewares/global_error.py index 68f809fd..13faadf6 100644 --- a/app/middlewares/global_error.py +++ b/app/middlewares/global_error.py @@ -219,7 +219,10 @@ async def send_error_to_admin_chat( global _last_error_notification chat_id = getattr(settings, 'ADMIN_NOTIFICATIONS_CHAT_ID', None) - topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None) + # Используем топик для ошибок, если настроен, иначе общий + topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_ERRORS_TOPIC_ID', None) or getattr( + settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None + ) enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) if not enabled or not chat_id: diff --git a/app/services/admin_notification_service.py b/app/services/admin_notification_service.py index ef1b8b5a..1174502e 100644 --- a/app/services/admin_notification_service.py +++ b/app/services/admin_notification_service.py @@ -1,5 +1,6 @@ import html from datetime import UTC, datetime +from enum import StrEnum from typing import Any import structlog @@ -26,6 +27,21 @@ from app.utils.message_patch import caption_exceeds_telegram_limit from app.utils.timezone import format_local_datetime +class NotificationCategory(StrEnum): + """Категории уведомлений для маршрутизации по топикам.""" + + PURCHASES = 'purchases' # Покупки подписок, покупки с лендинга + RENEWALS = 'renewals' # Продления + TRIALS = 'trials' # Триалы + BALANCE = 'balance' # Пополнение баланса + ADDONS = 'addons' # Докупка трафика/устройств/серверов + INFRASTRUCTURE = 'infrastructure' # Ноды, техработы, статус панели, вебхуки + ERRORS = 'errors' # Ошибки бота, краши + PROMO = 'promo' # Промокоды, кампании, промогруппы + PARTNERS = 'partners' # Партнёрки, выводы, админ-действия + TICKETS = 'tickets' # Тикеты (уже существует) + + logger = structlog.get_logger(__name__) @@ -37,6 +53,20 @@ class AdminNotificationService: self.ticket_topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID', None) self.enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) + # Маппинг категорий на topic_id (None = fallback на self.topic_id) + self.category_topics: dict[NotificationCategory, int | None] = { + NotificationCategory.PURCHASES: getattr(settings, 'ADMIN_NOTIFICATIONS_PURCHASES_TOPIC_ID', None), + NotificationCategory.RENEWALS: getattr(settings, 'ADMIN_NOTIFICATIONS_RENEWALS_TOPIC_ID', None), + NotificationCategory.TRIALS: getattr(settings, 'ADMIN_NOTIFICATIONS_TRIALS_TOPIC_ID', None), + NotificationCategory.BALANCE: getattr(settings, 'ADMIN_NOTIFICATIONS_BALANCE_TOPIC_ID', None), + NotificationCategory.ADDONS: getattr(settings, 'ADMIN_NOTIFICATIONS_ADDONS_TOPIC_ID', None), + NotificationCategory.INFRASTRUCTURE: getattr(settings, 'ADMIN_NOTIFICATIONS_INFRASTRUCTURE_TOPIC_ID', None), + NotificationCategory.ERRORS: getattr(settings, 'ADMIN_NOTIFICATIONS_ERRORS_TOPIC_ID', None), + NotificationCategory.PROMO: getattr(settings, 'ADMIN_NOTIFICATIONS_PROMO_TOPIC_ID', None), + NotificationCategory.PARTNERS: getattr(settings, 'ADMIN_NOTIFICATIONS_PARTNERS_TOPIC_ID', None), + NotificationCategory.TICKETS: self.ticket_topic_id, + } + async def _get_referrer_info(self, db: AsyncSession, referred_by_id: int | None) -> str: if not referred_by_id: return 'Нет' @@ -371,7 +401,7 @@ class AdminNotificationService: message_lines.append('') message_lines.append(f'⏰ {format_local_datetime(datetime.now(UTC), "%d.%m.%Y %H:%M:%S")}') - return await self._send_message('\n'.join(message_lines)) + return await self._send_message('\n'.join(message_lines), category=NotificationCategory.TRIALS) except Exception as e: logger.error('Ошибка отправки уведомления о триале', error=e) @@ -502,7 +532,15 @@ class AdminNotificationService: ] ) - return await self._send_message('\n'.join(message_lines)) + # Маршрутизация по категориям (зеркалит логику заголовков выше) + if purchase_type == 'renewal' or ( + not was_trial_conversion and purchase_type is None and user.has_had_paid_subscription + ): + cat = NotificationCategory.RENEWALS + else: + cat = NotificationCategory.PURCHASES + + return await self._send_message('\n'.join(message_lines), category=cat) except Exception as e: logger.error('Ошибка отправки уведомления о покупке', error=e) @@ -565,7 +603,7 @@ class AdminNotificationService: else: message = f'{message_prefix}{message_suffix}' - return await self._send_message(message) + return await self._send_message(message, category=NotificationCategory.INFRASTRUCTURE) except Exception as e: logger.error('Ошибка отправки уведомления об обновлении', error=e) @@ -586,7 +624,7 @@ class AdminNotificationService: ⚙️ Система автоматических обновлений • {format_local_datetime(datetime.now(UTC), '%d.%m.%Y %H:%M:%S')}""" - return await self._send_message(message) + return await self._send_message(message, category=NotificationCategory.ERRORS) except Exception as e: logger.error('Ошибка отправки уведомления об ошибке проверки версий', error=e) @@ -824,7 +862,7 @@ class AdminNotificationService: return False try: - return await self._send_message(message) + return await self._send_message(message, category=NotificationCategory.BALANCE) except Exception as e: logger.error('Ошибка отправки уведомления о пополнении', error=e, exc_info=True) return False @@ -901,7 +939,7 @@ class AdminNotificationService: ⏰ {format_local_datetime(datetime.now(UTC), '%d.%m.%Y %H:%M:%S')}""" - return await self._send_message(message) + return await self._send_message(message, category=NotificationCategory.RENEWALS) except Exception as e: logger.error('Ошибка отправки уведомления о продлении', error=e) @@ -1008,7 +1046,7 @@ class AdminNotificationService: ] ) - return await self._send_message('\n'.join(message_lines)) + return await self._send_message('\n'.join(message_lines), category=NotificationCategory.PROMO) except Exception as e: logger.error('Ошибка отправки уведомления об активации промокода', error=e) @@ -1097,7 +1135,7 @@ class AdminNotificationService: ] ) - return await self._send_message('\n'.join(message_lines)) + return await self._send_message('\n'.join(message_lines), category=NotificationCategory.PROMO) except Exception as e: logger.error('Ошибка отправки уведомления о переходе по кампании', error=e) @@ -1187,14 +1225,30 @@ class AdminNotificationService: ] ) - return await self._send_message('\n'.join(message_lines)) + return await self._send_message('\n'.join(message_lines), category=NotificationCategory.PROMO) except Exception as e: logger.error('Ошибка отправки уведомления о смене промогруппы', error=e) return False + def _resolve_topic_id(self, category: NotificationCategory | None = None) -> int | None: + """Определяет topic_id для сообщения. + + Если указана category и для неё настроен топик — возвращает его. + Иначе — fallback на self.topic_id (общий топик). + """ + if category: + topic = self.category_topics.get(category) + if topic is not None: + return topic + return self.topic_id + async def _send_message( - self, text: str, reply_markup: types.InlineKeyboardMarkup | None = None, *, ticket_event: bool = False + self, + text: str, + reply_markup: types.InlineKeyboardMarkup | None = None, + *, + category: NotificationCategory | None = None, ) -> bool: if not self.chat_id: logger.warning('ADMIN_NOTIFICATIONS_CHAT_ID не настроен') @@ -1208,19 +1262,14 @@ class AdminNotificationService: 'disable_web_page_preview': True, } - # route to ticket-specific topic if provided - thread_id = None - if ticket_event and self.ticket_topic_id: - thread_id = self.ticket_topic_id - elif self.topic_id: - thread_id = self.topic_id + thread_id = self._resolve_topic_id(category) if thread_id: message_kwargs['message_thread_id'] = thread_id if reply_markup is not None: message_kwargs['reply_markup'] = reply_markup await self.bot.send_message(**message_kwargs) - logger.info('Уведомление отправлено в чат', chat_id=self.chat_id) + logger.info('Уведомление отправлено в чат', chat_id=self.chat_id, category=category) return True except TelegramForbiddenError: @@ -1241,11 +1290,17 @@ class AdminNotificationService: """Public check for whether admin notifications are configured and active.""" return self._is_enabled() - async def send_admin_notification(self, text: str, reply_markup: types.InlineKeyboardMarkup | None = None) -> bool: + async def send_admin_notification( + self, + text: str, + reply_markup: types.InlineKeyboardMarkup | None = None, + *, + category: NotificationCategory | None = None, + ) -> bool: """Send a generic notification to admin chat with optional inline keyboard.""" if not self._is_enabled(): return False - return await self._send_message(text, reply_markup=reply_markup) + return await self._send_message(text, reply_markup=reply_markup, category=category) async def send_guest_purchase_notification( self, @@ -1316,7 +1371,7 @@ class AdminNotificationService: message_lines.append(f'{format_local_datetime(datetime.now(UTC), "%d.%m.%Y %H:%M")}') - return await self._send_message('\n'.join(message_lines)) + return await self._send_message('\n'.join(message_lines), category=NotificationCategory.PURCHASES) except Exception as e: logger.error('Ошибка отправки уведомления о гостевой покупке', error=e) @@ -1330,7 +1385,7 @@ class AdminNotificationService: """ if not self._is_enabled(): return False - return await self._send_message(text) + return await self._send_message(text, category=NotificationCategory.INFRASTRUCTURE) def _get_payment_method_display(self, payment_method: str | None) -> str: if not payment_method: @@ -1516,7 +1571,7 @@ class AdminNotificationService: message = '\n'.join(message_parts) - return await self._send_message(message) + return await self._send_message(message, category=NotificationCategory.INFRASTRUCTURE) except Exception as e: logger.error('Ошибка отправки уведомления о техработах', error=e) @@ -1601,7 +1656,7 @@ class AdminNotificationService: message = '\n'.join(message_parts) - return await self._send_message(message) + return await self._send_message(message, category=NotificationCategory.INFRASTRUCTURE) except Exception as e: logger.error('Ошибка отправки уведомления о статусе панели Remnawave', error=e) @@ -1694,7 +1749,7 @@ class AdminNotificationService: ] ) - return await self._send_message('\n'.join(message_lines)) + return await self._send_message('\n'.join(message_lines), category=NotificationCategory.ADDONS) except Exception as e: logger.error('Ошибка отправки уведомления об изменении подписки', error=e) @@ -1778,7 +1833,7 @@ class AdminNotificationService: ] ) - return await self._send_message('\n'.join(message_lines)) + return await self._send_message('\n'.join(message_lines), category=NotificationCategory.PARTNERS) except Exception as e: logger.error('Ошибка отправки уведомления о заявке на партнёрку', error=e) @@ -1829,7 +1884,7 @@ class AdminNotificationService: ] ) - return await self._send_message('\n'.join(message_lines)) + return await self._send_message('\n'.join(message_lines), category=NotificationCategory.PARTNERS) except Exception as e: logger.error('Ошибка отправки уведомления о запросе на вывод', error=e) @@ -1873,7 +1928,7 @@ class AdminNotificationService: ) message = '\n'.join(message_lines) - return await self._send_message(message) + return await self._send_message(message, category=NotificationCategory.PARTNERS) except Exception as e: logger.error('Ошибка отправки уведомления о массовой блокировке', error=e) @@ -1910,7 +1965,7 @@ class AdminNotificationService: if media_file_id and media_type == 'photo': return await self._send_ticket_photo_notification(text, media_file_id, keyboard) - return await self._send_message(text, reply_markup=keyboard, ticket_event=True) + return await self._send_message(text, reply_markup=keyboard, category=NotificationCategory.TICKETS) async def _send_ticket_photo_notification( self, @@ -1925,7 +1980,7 @@ class AdminNotificationService: if not self.chat_id: return False - thread_id = self.ticket_topic_id or self.topic_id + thread_id = self._resolve_topic_id(category=NotificationCategory.TICKETS) try: if not caption_exceeds_telegram_limit(text): @@ -1943,7 +1998,7 @@ class AdminNotificationService: await self.bot.send_photo(**photo_kwargs) else: # Текст отдельно, фото следом в тот же топик - await self._send_message(text, reply_markup=keyboard, ticket_event=True) + await self._send_message(text, reply_markup=keyboard, category=NotificationCategory.TICKETS) photo_kwargs = { 'chat_id': self.chat_id, 'photo': photo_file_id, @@ -1956,7 +2011,7 @@ class AdminNotificationService: except Exception as e: logger.error('Ошибка отправки фото-уведомления тикета', error=e) # Fallback: отправляем хотя бы текст - return await self._send_message(text, reply_markup=keyboard, ticket_event=True) + return await self._send_message(text, reply_markup=keyboard, category=NotificationCategory.TICKETS) async def send_suspicious_traffic_notification(self, message: str, bot: Bot, topic_id: int | None = None) -> bool: """ diff --git a/app/services/backup_service.py b/app/services/backup_service.py index ae1926f5..a8fd77b0 100644 --- a/app/services/backup_service.py +++ b/app/services/backup_service.py @@ -1810,10 +1810,10 @@ class BackupService: notification_text += f'\n\n⏰ {datetime.now(UTC).strftime("%d.%m.%Y %H:%M:%S")}' try: - from app.services.admin_notification_service import AdminNotificationService + from app.services.admin_notification_service import AdminNotificationService, NotificationCategory admin_service = AdminNotificationService(self.bot) - await admin_service._send_message(notification_text) + await admin_service.send_admin_notification(notification_text, category=NotificationCategory.INFRASTRUCTURE) except Exception as e: logger.error('Ошибка отправки уведомления через AdminNotificationService', error=e) diff --git a/app/services/maintenance_service.py b/app/services/maintenance_service.py index 9d63bbff..90acbe2c 100644 --- a/app/services/maintenance_service.py +++ b/app/services/maintenance_service.py @@ -65,11 +65,11 @@ class MaintenanceService: return False try: - from app.services.admin_notification_service import AdminNotificationService + from app.services.admin_notification_service import AdminNotificationService, NotificationCategory notification_service = AdminNotificationService(self._bot) - if not notification_service._is_enabled(): + if not notification_service.is_enabled: logger.debug('Уведомления администраторов отключены') return False @@ -79,7 +79,9 @@ class MaintenanceService: timestamp = format_local_datetime(datetime.now(UTC), '%d.%m.%Y %H:%M:%S %Z') formatted_message = f'{emoji} ТЕХНИЧЕСКИЕ РАБОТЫ\n\n{message}\n\n⏰ {timestamp}' - return await notification_service._send_message(formatted_message) + return await notification_service.send_admin_notification( + formatted_message, category=NotificationCategory.INFRASTRUCTURE + ) except Exception as e: logger.error('Ошибка отправки уведомления через AdminNotificationService', error=e) diff --git a/app/services/referral_contest_service.py b/app/services/referral_contest_service.py index 84dae4f8..9ff377f7 100644 --- a/app/services/referral_contest_service.py +++ b/app/services/referral_contest_service.py @@ -305,7 +305,7 @@ class ReferralContestService: chat_id=chat_id, text='\n'.join(lines), disable_web_page_preview=True, - message_thread_id=settings.ADMIN_NOTIFICATIONS_TOPIC_ID, + message_thread_id=settings.ADMIN_NOTIFICATIONS_PROMO_TOPIC_ID or settings.ADMIN_NOTIFICATIONS_TOPIC_ID, ) except Exception as exc: logger.error('Не удалось отправить админскую сводку конкурса', exc=exc) diff --git a/app/services/startup_notification_service.py b/app/services/startup_notification_service.py index d71ffae7..57ec5414 100644 --- a/app/services/startup_notification_service.py +++ b/app/services/startup_notification_service.py @@ -65,7 +65,10 @@ class StartupNotificationService: def __init__(self, bot: Bot) -> None: self.bot = bot self.chat_id = getattr(settings, 'ADMIN_NOTIFICATIONS_CHAT_ID', None) - self.topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None) + # Стартовые/краш-уведомления → топик инфраструктуры, fallback на общий + self.topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_INFRASTRUCTURE_TOPIC_ID', None) or getattr( + settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None + ) self.enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) def _get_version(self) -> str: @@ -381,7 +384,10 @@ async def send_crash_notification(bot: Bot, error: Exception, traceback_str: str bool: True если уведомление отправлено успешно """ chat_id = getattr(settings, 'ADMIN_NOTIFICATIONS_CHAT_ID', None) - topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None) + # Краш → топик ошибок, fallback на общий + topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_ERRORS_TOPIC_ID', None) or getattr( + settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None + ) enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) if not enabled or not chat_id: diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index edc24e62..6a4d5634 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -260,6 +260,15 @@ class BotConfigurationService: 'ADMIN_NOTIFICATIONS_CHAT_ID': 'ADMIN_NOTIFICATIONS', 'ADMIN_NOTIFICATIONS_TOPIC_ID': 'ADMIN_NOTIFICATIONS', 'ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID': 'ADMIN_NOTIFICATIONS', + 'ADMIN_NOTIFICATIONS_PURCHASES_TOPIC_ID': 'ADMIN_NOTIFICATIONS', + 'ADMIN_NOTIFICATIONS_RENEWALS_TOPIC_ID': 'ADMIN_NOTIFICATIONS', + 'ADMIN_NOTIFICATIONS_TRIALS_TOPIC_ID': 'ADMIN_NOTIFICATIONS', + 'ADMIN_NOTIFICATIONS_BALANCE_TOPIC_ID': 'ADMIN_NOTIFICATIONS', + 'ADMIN_NOTIFICATIONS_ADDONS_TOPIC_ID': 'ADMIN_NOTIFICATIONS', + 'ADMIN_NOTIFICATIONS_INFRASTRUCTURE_TOPIC_ID': 'ADMIN_NOTIFICATIONS', + 'ADMIN_NOTIFICATIONS_ERRORS_TOPIC_ID': 'ADMIN_NOTIFICATIONS', + 'ADMIN_NOTIFICATIONS_PROMO_TOPIC_ID': 'ADMIN_NOTIFICATIONS', + 'ADMIN_NOTIFICATIONS_PARTNERS_TOPIC_ID': 'ADMIN_NOTIFICATIONS', 'ADMIN_REPORTS_ENABLED': 'ADMIN_REPORTS', 'ADMIN_REPORTS_CHAT_ID': 'ADMIN_REPORTS', 'ADMIN_REPORTS_TOPIC_ID': 'ADMIN_REPORTS',