feat: раздельные топики для админских уведомлений

Добавлены 9 новых env-переменных для маршрутизации уведомлений по отдельным топикам:
- PURCHASES, RENEWALS, TRIALS, BALANCE, ADDONS
- INFRASTRUCTURE, ERRORS, PROMO, PARTNERS

Обратная совместимость: если топик для категории не задан — fallback на ADMIN_NOTIFICATIONS_TOPIC_ID.
This commit is contained in:
Fringg
2026-03-18 23:16:54 +03:00
parent b80eeea089
commit e15b18fb41
9 changed files with 130 additions and 42 deletions
+11
View File
@@ -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 # Задержка между отправкой чеков (секунды)
+4 -2
View File
@@ -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)
+4 -1
View File
@@ -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:
+86 -31
View File
@@ -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'⏰ <i>{format_local_datetime(datetime.now(UTC), "%d.%m.%Y %H:%M:%S")}</i>')
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:
⚙️ <i>Система автоматических обновлений • {format_local_datetime(datetime.now(UTC), '%d.%m.%Y %H:%M:%S')}</i>"""
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:
⏰ <i>{format_local_datetime(datetime.now(UTC), '%d.%m.%Y %H:%M:%S')}</i>"""
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'<i>{format_local_datetime(datetime.now(UTC), "%d.%m.%Y %H:%M")}</i>')
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:
"""
+2 -2
View File
@@ -1810,10 +1810,10 @@ class BackupService:
notification_text += f'\n\n⏰ <i>{datetime.now(UTC).strftime("%d.%m.%Y %H:%M:%S")}</i>'
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)
+5 -3
View File
@@ -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} <b>ТЕХНИЧЕСКИЕ РАБОТЫ</b>\n\n{message}\n\n⏰ <i>{timestamp}</i>'
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)
+1 -1
View File
@@ -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)
+8 -2
View File
@@ -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:
+9
View File
@@ -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',