diff --git a/backend/bot/app/web/admin_api_impl/common.py b/backend/bot/app/web/admin_api_impl/common.py index 2ef0e05..624515d 100644 --- a/backend/bot/app/web/admin_api_impl/common.py +++ b/backend/bot/app/web/admin_api_impl/common.py @@ -234,6 +234,7 @@ def _serialize_log(entry: MessageLog) -> Dict[str, Any]: "user_id": int(entry.user_id) if entry.user_id else None, "telegram_username": entry.telegram_username, "telegram_first_name": entry.telegram_first_name, + "email": getattr(getattr(entry, "author_user", None), "email", None), "event_type": entry.event_type, "content": entry.content, "is_admin_event": bool(entry.is_admin_event), diff --git a/backend/bot/app/web/webapp/billing.py b/backend/bot/app/web/webapp/billing.py index 142807b..17fdf36 100644 --- a/backend/bot/app/web/webapp/billing.py +++ b/backend/bot/app/web/webapp/billing.py @@ -311,7 +311,12 @@ async def activate_trial_route(request: web.Request) -> web.Response: notification_service = NotificationService( request.app["bot"], settings, i18n_instance ) - await notification_service.notify_trial_activation(user_id, end_date) + await notification_service.notify_trial_activation( + user_id, + end_date, + username=db_user.username, + email=getattr(db_user, "email", None), + ) except Exception: logger.exception("Failed to send WebApp trial activation notification") diff --git a/backend/bot/handlers/admin/logs_admin.py b/backend/bot/handlers/admin/logs_admin.py index 769a90c..3a1f196 100644 --- a/backend/bot/handlers/admin/logs_admin.py +++ b/backend/bot/handlers/admin/logs_admin.py @@ -8,6 +8,7 @@ from typing import Any, Dict, List, Optional from aiogram import F, Router, types from aiogram.fsm.context import FSMContext +from aiogram.utils.text_decorations import html_decoration as hd from sqlalchemy.ext.asyncio import AsyncSession from bot.keyboards.inline.admin_keyboards import ( @@ -25,6 +26,44 @@ USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$") EMAIL_REGEX = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") +def _user_email(user: Optional[User]) -> str: + return str(getattr(user, "email", None) or "").strip() + + +def _format_user_with_email( + *, + first_name: Optional[str] = None, + username: Optional[str] = None, + email: Optional[str] = None, + fallback: str = "", +) -> str: + parts = [] + if first_name: + parts.append(first_name) + if username: + parts.append(f"(@{username})") + + display = " ".join(parts).strip() or str(fallback or "").strip() + clean_email = str(email or "").strip() + if clean_email: + display = ( + f"{display} · {clean_email}" if display and display != clean_email else clean_email + ) + return hd.quote(display) + + +def _format_log_entry_user(log_entry: MessageLog, translate) -> str: + fallback = ( + translate("system_or_unknown_user") if not log_entry.user_id else f"ID: {log_entry.user_id}" + ) + return _format_user_with_email( + first_name=log_entry.telegram_first_name, + username=log_entry.telegram_username, + email=_user_email(getattr(log_entry, "author_user", None)), + fallback=fallback, + ) + + async def display_logs_menu( callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession ): @@ -94,19 +133,7 @@ async def _display_formatted_logs( log_entries_text = [] for log_entry_model in logs: - user_display_parts = [] - if log_entry_model.telegram_first_name: - user_display_parts.append(log_entry_model.telegram_first_name) - if log_entry_model.telegram_username: - user_display_parts.append(f"(@{log_entry_model.telegram_username})") - - user_display = " ".join(user_display_parts).strip() - if not user_display: - user_display = ( - _("system_or_unknown_user") - if not log_entry_model.user_id - else f"ID: {log_entry_model.user_id}" - ) + user_display = _format_log_entry_user(log_entry_model, _) user_id_display = ( str(log_entry_model.user_id) if log_entry_model.user_id is not None else "N/A" @@ -270,10 +297,11 @@ async def process_user_id_for_logs_handler( return target_user_id = user_model_for_logs.user_id - user_display_name = user_model_for_logs.first_name or ( - f"@{user_model_for_logs.username}" - if user_model_for_logs.username - else (user_model_for_logs.email or f"ID {target_user_id}") + user_display_name = _format_user_with_email( + first_name=user_model_for_logs.first_name, + username=user_model_for_logs.username, + email=user_model_for_logs.email, + fallback=f"ID {target_user_id}", ) logs_models = await message_log_dal.get_user_message_logs( @@ -319,10 +347,11 @@ async def view_user_logs_paginated_handler( await callback.answer() return - user_display_name = user_model_for_logs.first_name or ( - f"@{user_model_for_logs.username}" - if user_model_for_logs.username - else (user_model_for_logs.email or f"ID {target_user_id}") + user_display_name = _format_user_with_email( + first_name=user_model_for_logs.first_name, + username=user_model_for_logs.username, + email=user_model_for_logs.email, + fallback=f"ID {target_user_id}", ) logs_models = await message_log_dal.get_user_message_logs( @@ -392,6 +421,7 @@ async def export_logs_csv_handler( _("admin_csv_header_user_id"), _("admin_csv_header_telegram_username"), _("admin_csv_header_telegram_first_name"), + _("admin_csv_header_email"), _("admin_csv_header_event_type"), _("admin_csv_header_content"), _("admin_csv_header_is_admin_event"), @@ -417,6 +447,7 @@ async def export_logs_csv_handler( log.user_id or "", log.telegram_username or "", log.telegram_first_name or "", + _user_email(getattr(log, "author_user", None)), log.event_type or "", content_clean, "Yes" if log.is_admin_event else "No", diff --git a/backend/bot/handlers/user/promo_user.py b/backend/bot/handlers/user/promo_user.py index 973e1eb..930e047 100644 --- a/backend/bot/handlers/user/promo_user.py +++ b/backend/bot/handlers/user/promo_user.py @@ -21,6 +21,7 @@ from bot.utils.install_links import ( ensure_user_install_guide_links, ) from config.settings import Settings +from db.dal import user_dal from .start import send_main_menu @@ -137,10 +138,12 @@ async def process_promo_code_input( from bot.services.notification_service import NotificationService notification_service = NotificationService(bot, settings, i18n) + db_user = await user_dal.get_user_by_id(session, user.id) await notification_service.notify_suspicious_promo_attempt( user_id=user.id, username=user.username, first_name=user.first_name, + email=getattr(db_user, "email", None) if db_user else None, suspicious_input=code_input, ) except Exception as e: diff --git a/backend/bot/handlers/user/trial_handler.py b/backend/bot/handlers/user/trial_handler.py index 843675f..dd99177 100644 --- a/backend/bot/handlers/user/trial_handler.py +++ b/backend/bot/handlers/user/trial_handler.py @@ -19,6 +19,7 @@ from bot.utils.install_links import ( ensure_user_install_guide_links, ) from config.settings import Settings +from db.dal import user_dal from .start import send_main_menu @@ -119,7 +120,13 @@ async def request_trial_confirmation_handler( # Send notification to admin about new trial notification_service = NotificationService(callback.bot, settings, i18n) - await notification_service.notify_trial_activation(user_id, end_date_obj) + db_user = await user_dal.get_user_by_id(session, user_id) + await notification_service.notify_trial_activation( + user_id, + end_date_obj, + username=db_user.username if db_user else callback.from_user.username, + email=getattr(db_user, "email", None) if db_user else None, + ) # Mark ad attribution trial if exists try: from db.dal import ad_dal as _ad_dal @@ -316,7 +323,13 @@ async def confirm_activate_trial_handler( if activation_result and activation_result.get("activated") and end_date_obj: notification_service = NotificationService(callback.bot, settings, i18n) - await notification_service.notify_trial_activation(user_id, end_date_obj) + db_user = await user_dal.get_user_by_id(session, user_id) + await notification_service.notify_trial_activation( + user_id, + end_date_obj, + username=db_user.username if db_user else callback.from_user.username, + email=getattr(db_user, "email", None) if db_user else None, + ) try: from db.dal import ad_dal as _ad_dal diff --git a/backend/bot/payment_providers/shared/success.py b/backend/bot/payment_providers/shared/success.py index b00ecd3..1ca7672 100644 --- a/backend/bot/payment_providers/shared/success.py +++ b/backend/bot/payment_providers/shared/success.py @@ -190,6 +190,7 @@ async def notify_admins_payment_received( traffic_is_premium: bool, tariff_key: Optional[str], log_prefix: str = "payment_providers", + email: Optional[str] = None, ) -> None: """Push the standard ``notify_payment_received`` to the admin log channel.""" try: @@ -202,6 +203,7 @@ async def notify_admins_payment_received( traffic_gb=traffic_gb_for_admin, payment_provider=payment_provider, username=username, + email=email, traffic_is_premium=traffic_is_premium, tariff_key=tariff_key, ) @@ -398,6 +400,7 @@ async def finalize_successful_payment( traffic_gb_for_admin=traffic_gb_for_activation, payment_provider=req.provider_notification, username=db_user.username if db_user else None, + email=getattr(db_user, "email", None) if db_user else None, traffic_is_premium=base == "premium_topup", tariff_key=tariff_key, log_prefix=req.log_prefix, diff --git a/backend/bot/payment_providers/yookassa.py b/backend/bot/payment_providers/yookassa.py index 33b0414..390b2fb 100644 --- a/backend/bot/payment_providers/yookassa.py +++ b/backend/bot/payment_providers/yookassa.py @@ -874,6 +874,7 @@ async def process_successful_payment( ), payment_provider="yookassa", username=user_for_notify.username if user_for_notify else None, + email=getattr(user_for_notify, "email", None) if user_for_notify else None, traffic_is_premium=sale_mode_base == "premium_topup", tariff_key=tariff_for_log, log_prefix="YooKassa webhook", diff --git a/backend/bot/services/notification_service.py b/backend/bot/services/notification_service.py index c713978..9db3b8f 100644 --- a/backend/bot/services/notification_service.py +++ b/backend/bot/services/notification_service.py @@ -56,11 +56,16 @@ class NotificationService: user_id: int, username: Optional[str] = None, first_name: Optional[str] = None, + email: Optional[str] = None, ) -> str: base_display = display_name_or_fallback(first_name, f"ID {user_id}") if username: base_display = f"{base_display} ({username_for_display(username)})" - return base_display + safe_display = hd.quote(base_display) + clean_email = str(email or "").strip() + if clean_email: + safe_display = f"{safe_display} · {hd.quote(clean_email)}" + return safe_display @staticmethod def _build_profile_keyboard( @@ -254,8 +259,13 @@ class NotificationService: part for part in [user.first_name, getattr(user, "last_name", None)] if part ) if user.username: - return f"{name or user.username} (@{user.username})" - return name or getattr(user, "email", None) or f"ID {user.user_id}" + display = f"{name or user.username} (@{user.username})" + else: + display = name or f"ID {user.user_id}" + email = str(getattr(user, "email", None) or "").strip() + if email: + return f"{display} · {email}" if display and display != email else email + return display @staticmethod def _support_snapshot_rows(snapshot: Optional[dict]) -> list[tuple[str, str]]: @@ -540,6 +550,7 @@ class NotificationService: user_id: int, username: Optional[str] = None, first_name: Optional[str] = None, + email: Optional[str] = None, referred_by_id: Optional[int] = None, ): """Send notification about new user registration""" @@ -553,6 +564,7 @@ class NotificationService: user_id=user_id, username=username, first_name=first_name, + email=email, ) referral_text = "" @@ -640,6 +652,7 @@ class NotificationService: user_id=telegram_id or user_id, username=username, first_name=first_name, + email=email, ) message = _( @@ -676,6 +689,7 @@ class NotificationService: user_id=telegram_id, username=username, first_name=first_name, + email=email, ) message = _( @@ -715,6 +729,7 @@ class NotificationService: user_id=display_user_id, username=username, first_name=first_name, + email=email, ) message = _( @@ -761,6 +776,7 @@ class NotificationService: months: int, payment_provider: str, username: Optional[str] = None, + email: Optional[str] = None, traffic_gb: Optional[float] = None, *, traffic_is_premium: bool = False, @@ -776,6 +792,7 @@ class NotificationService: user_display = self._format_user_display( user_id=user_id, username=username, + email=email, ) try: @@ -827,7 +844,12 @@ class NotificationService: await self._send_to_log_channel(message, reply_markup=profile_keyboard) async def notify_promo_activation( - self, user_id: int, promo_code: str, bonus_days: int, username: Optional[str] = None + self, + user_id: int, + promo_code: str, + bonus_days: int, + username: Optional[str] = None, + email: Optional[str] = None, ): """Send notification about promo code activation""" if not self.settings.LOG_PROMO_ACTIVATIONS: @@ -839,6 +861,7 @@ class NotificationService: user_display = self._format_user_display( user_id=user_id, username=username, + email=email, ) message = _( @@ -854,7 +877,11 @@ class NotificationService: await self._send_to_log_channel(message, reply_markup=profile_keyboard) async def notify_trial_activation( - self, user_id: int, end_date: datetime, username: Optional[str] = None + self, + user_id: int, + end_date: datetime, + username: Optional[str] = None, + email: Optional[str] = None, ): """Send notification about trial activation""" if not self.settings.LOG_TRIAL_ACTIVATIONS: @@ -866,6 +893,7 @@ class NotificationService: user_display = self._format_user_display( user_id=user_id, username=username, + email=email, ) message = _( @@ -918,6 +946,7 @@ class NotificationService: suspicious_input: str, username: Optional[str] = None, first_name: Optional[str] = None, + email: Optional[str] = None, ): """Send notification about a suspicious promo code attempt.""" if not self.settings.LOG_SUSPICIOUS_ACTIVITY: @@ -930,11 +959,12 @@ class NotificationService: user_id=user_id, username=username, first_name=first_name, + email=email, ) message = _( "log_suspicious_promo", - user_display=hd.quote(user_display), + user_display=user_display, user_id=user_id, suspicious_input=hd.quote(suspicious_input), timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S %Z"), diff --git a/backend/bot/services/promo_code_service.py b/backend/bot/services/promo_code_service.py index 3f5c3a2..b486595 100644 --- a/backend/bot/services/promo_code_service.py +++ b/backend/bot/services/promo_code_service.py @@ -112,6 +112,7 @@ class PromoCodeService: promo_code=code_input_upper, bonus_days=bonus_days, username=user.username if user else None, + email=getattr(user, "email", None) if user else None, ) except Exception as e: logging.error(f"Failed to send promo activation notification: {e}") diff --git a/backend/db/dal/message_log_dal.py b/backend/db/dal/message_log_dal.py index e475224..5bf20db 100644 --- a/backend/db/dal/message_log_dal.py +++ b/backend/db/dal/message_log_dal.py @@ -4,6 +4,7 @@ from typing import List, Optional from sqlalchemy import func, or_ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select +from sqlalchemy.orm import selectinload from ..models import MessageLog @@ -22,7 +23,13 @@ async def create_message_log(session: AsyncSession, log_data: dict) -> Optional[ async def get_all_message_logs(session: AsyncSession, limit: int, offset: int) -> List[MessageLog]: - stmt = select(MessageLog).order_by(MessageLog.timestamp.desc()).limit(limit).offset(offset) + stmt = ( + select(MessageLog) + .options(selectinload(MessageLog.author_user), selectinload(MessageLog.target_user)) + .order_by(MessageLog.timestamp.desc()) + .limit(limit) + .offset(offset) + ) result = await session.execute(stmt) return result.scalars().all() @@ -38,6 +45,7 @@ async def get_user_message_logs( ) -> List[MessageLog]: stmt = ( select(MessageLog) + .options(selectinload(MessageLog.author_user), selectinload(MessageLog.target_user)) .where( or_( MessageLog.user_id == user_id_to_search, diff --git a/locales/en.json b/locales/en.json index ebd17f7..b5a828e 100644 --- a/locales/en.json +++ b/locales/en.json @@ -396,6 +396,7 @@ "admin_csv_header_user_id": "User ID", "admin_csv_header_telegram_username": "Username", "admin_csv_header_telegram_first_name": "First Name", + "admin_csv_header_email": "Email", "admin_csv_header_event_type": "Event Type", "admin_csv_header_content": "Content", "admin_csv_header_is_admin_event": "Admin Event", diff --git a/locales/ru.json b/locales/ru.json index d5925f9..0f889cc 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -396,6 +396,7 @@ "admin_csv_header_user_id": "ID Пользователя", "admin_csv_header_telegram_username": "Username", "admin_csv_header_telegram_first_name": "Имя", + "admin_csv_header_email": "Email", "admin_csv_header_event_type": "Тип события", "admin_csv_header_content": "Содержание", "admin_csv_header_is_admin_event": "Админ событие",