From 19aa2ec9c969a8851457e15d2649c8821c722dc9 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Fri, 22 May 2026 22:02:28 +0300 Subject: [PATCH] fix: log account merge notifications --- backend/bot/app/web/webapp/account.py | 26 ++++++++++- backend/bot/app/web/webapp/auth.py | 45 ++++++++++++++++++ backend/bot/services/notification_service.py | 45 ++++++++++++++++++ locales/en.json | 1 + locales/ru.json | 1 + tests/test_account_linking_panel.py | 17 ++++++- tests/test_support_notifications.py | 48 ++++++++++++++++++++ 7 files changed, 181 insertions(+), 2 deletions(-) diff --git a/backend/bot/app/web/webapp/account.py b/backend/bot/app/web/webapp/account.py index aa92be2..d6ed3ce 100644 --- a/backend/bot/app/web/webapp/account.py +++ b/backend/bot/app/web/webapp/account.py @@ -2,7 +2,11 @@ from ._runtime import * # noqa: F403,F405 from bot.app.web.webapp.cache_helpers import webapp_cached_user_payload -from .auth import _hash_email_password, _sync_merged_panel_identity_for_user +from .auth import ( + _hash_email_password, + _notify_account_merged, + _sync_merged_panel_identity_for_user, +) from .common import _invalidate_webapp_user_caches @@ -164,6 +168,16 @@ async def account_email_verify_route(request: web.Request) -> web.Response: return _json_error(500, "link_failed", "Link failed") await _invalidate_webapp_user_caches(settings, user_id, final_user_id, include_devices=True) + if merge_notice: + await _notify_account_merged( + request, + settings, + merge_notice=merge_notice, + email=final_email, + telegram_id=final_telegram_id, + username=final_username, + first_name=final_first_name, + ) if should_notify_email_linked: try: from bot.services.notification_service import NotificationService @@ -372,6 +386,16 @@ async def account_telegram_link_route(request: web.Request) -> web.Response: return _json_error(500, "link_failed", "Link failed") await _invalidate_webapp_user_caches(settings, user_id, final_user_id, include_devices=True) + if merge_notice: + await _notify_account_merged( + request, + settings, + merge_notice=merge_notice, + email=final_email, + telegram_id=final_telegram_id, + username=final_username, + first_name=final_first_name, + ) if should_notify_telegram_linked and final_telegram_id: try: from bot.services.notification_service import NotificationService diff --git a/backend/bot/app/web/webapp/auth.py b/backend/bot/app/web/webapp/auth.py index 469057f..87d64a7 100644 --- a/backend/bot/app/web/webapp/auth.py +++ b/backend/bot/app/web/webapp/auth.py @@ -426,6 +426,15 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response: final_panel_uuid=link_final_panel_uuid, expire_at=merge_end_date, ) + await _notify_account_merged( + request, + settings, + merge_notice=link_merge_notice, + email=linked_user_for_panel.email, + telegram_id=_telegram_id_for_user(linked_user_for_panel), + username=linked_user_for_panel.username, + first_name=linked_user_for_panel.first_name, + ) token = create_webapp_session_token(settings, int(final_user_id)) response = web.HTTPFound(_telegram_oauth_redirect_url(redirect_path, status="success")) @@ -1148,6 +1157,42 @@ async def _build_account_merge_notice( } +async def _notify_account_merged( + request: web.Request, + settings: Settings, + *, + merge_notice: Optional[Dict[str, Any]], + email: Optional[str], + telegram_id: Optional[int], + username: Optional[str], + first_name: Optional[str], +) -> None: + if not merge_notice: + return + try: + from bot.services.notification_service import NotificationService + + bot: Bot = request.app["bot"] + notification_service = NotificationService( + bot, + settings, + request.app.get("i18n"), + ) + await notification_service.notify_account_merged( + primary_user_id=int(merge_notice.get("primary_user_id") or 0), + removed_user_id=int(merge_notice.get("removed_user_id") or 0), + email=email, + telegram_id=telegram_id, + username=username, + first_name=first_name, + final_end_date_text=str(merge_notice.get("final_end_date_text") or ""), + primary_panel_user_uuid=merge_notice.get("primary_panel_user_uuid"), + removed_panel_user_uuid=merge_notice.get("removed_panel_user_uuid"), + ) + except Exception: + logger.exception("Failed to send account merged notification") + + def _apply_telegram_profile_to_user( user: User, telegram_user: Dict[str, Any], diff --git a/backend/bot/services/notification_service.py b/backend/bot/services/notification_service.py index 9a01a40..c713978 100644 --- a/backend/bot/services/notification_service.py +++ b/backend/bot/services/notification_service.py @@ -690,6 +690,51 @@ class NotificationService: profile_keyboard = self._build_profile_keyboard(_, telegram_id) await self._send_to_log_channel(message, reply_markup=profile_keyboard) + async def notify_account_merged( + self, + *, + primary_user_id: int, + removed_user_id: int, + email: Optional[str], + telegram_id: Optional[int], + username: Optional[str] = None, + first_name: Optional[str] = None, + final_end_date_text: Optional[str] = None, + primary_panel_user_uuid: Optional[str] = None, + removed_panel_user_uuid: Optional[str] = None, + ): + """Send notification when duplicate email/Telegram accounts are merged.""" + if not self.settings.LOG_NEW_USERS: + return + + admin_lang = self.settings.DEFAULT_LANGUAGE + _ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k + + display_user_id = int(telegram_id or primary_user_id) + user_display = self._format_user_display( + user_id=display_user_id, + username=username, + first_name=first_name, + ) + + message = _( + "log_account_merged", + primary_user_id=primary_user_id, + removed_user_id=removed_user_id, + telegram_id=telegram_id or "", + user_display=user_display, + email=hd.quote(email or ""), + final_end_date=hd.quote(final_end_date_text or ""), + primary_panel_user_uuid=hd.quote(primary_panel_user_uuid or ""), + removed_panel_user_uuid=hd.quote(removed_panel_user_uuid or ""), + timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + ) + + profile_keyboard = ( + self._build_profile_keyboard(_, int(telegram_id)) if telegram_id else None + ) + await self._send_to_log_channel(message, reply_markup=profile_keyboard) + def _format_traffic_gb_admin(self, traffic_gb: float) -> str: value = float(traffic_gb) if value.is_integer(): diff --git a/locales/en.json b/locales/en.json index 3832487..992c748 100644 --- a/locales/en.json +++ b/locales/en.json @@ -363,6 +363,7 @@ "log_new_email_user_registration": "📧 New User (email)\n\n🆔 ID: {user_id}\n📧 Email: {email}{referral_text}\n📅 Time: {timestamp}", "log_account_email_linked": "📧 Email linked\n\n🆔 User ID: {user_id}\n📨 Telegram ID: {telegram_id}\n👤 User: {user_display}\n📧 Email: {email}\n🕐 Time: {timestamp}", "log_account_telegram_linked": "📨 Telegram linked\n\n🆔 User ID: {user_id}\n📨 Telegram ID: {telegram_id}\n👤 User: {user_display}\n📧 Email: {email}\n🕐 Time: {timestamp}", + "log_account_merged": "🔗 Accounts merged\n\n🆔 Kept user ID: {primary_user_id}\n🗑 Removed user ID: {removed_user_id}\n📨 Telegram ID: {telegram_id}\n👤 User: {user_display}\n📧 Email: {email}\n⏰ New end date: {final_end_date}\n📋 Kept panel UUID: {primary_panel_user_uuid}\n📋 Removed panel UUID: {removed_panel_user_uuid}\n🕐 Time: {timestamp}", "log_payment_received": "{provider_emoji} Payment Received\n\n👤 User: {user_display}\n💰 Amount: {amount} {currency}\n📅 Period: {months} mo.\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}", "log_payment_received_traffic": "{provider_emoji} Payment Received (traffic top-up)\n\n👤 User: {user_display}\n💰 Amount: {amount} {currency}\n🗂 {traffic_summary}\n{tariff_line}🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}", "log_payment_traffic_purchase_line": "{gb} GB · {kind}", diff --git a/locales/ru.json b/locales/ru.json index d77da6f..9411693 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -363,6 +363,7 @@ "log_new_email_user_registration": "📧 Новый пользователь (email)\n\n🆔 ID: {user_id}\n📧 Email: {email}{referral_text}\n📅 Время: {timestamp}", "log_account_email_linked": "📧 Привязана почта\n\n🆔 ID пользователя: {user_id}\n📨 Telegram ID: {telegram_id}\n👤 Пользователь: {user_display}\n📧 Email: {email}\n🕐 Время: {timestamp}", "log_account_telegram_linked": "📨 Привязан Telegram\n\n🆔 ID пользователя: {user_id}\n📨 Telegram ID: {telegram_id}\n👤 Пользователь: {user_display}\n📧 Email: {email}\n🕐 Время: {timestamp}", + "log_account_merged": "🔗 Аккаунты объединены\n\n🆔 Оставлен ID: {primary_user_id}\n🗑 Удалён ID: {removed_user_id}\n📨 Telegram ID: {telegram_id}\n👤 Пользователь: {user_display}\n📧 Email: {email}\n⏰ Новая дата окончания: {final_end_date}\n📋 UUID оставленного в панели: {primary_panel_user_uuid}\n📋 UUID удалённого в панели: {removed_panel_user_uuid}\n🕐 Время: {timestamp}", "log_payment_received": "{provider_emoji} Получен платеж\n\n👤 Пользователь: {user_display}\n💰 Сумма: {amount} {currency}\n📅 Период: {months} мес.\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}", "log_payment_received_traffic": "{provider_emoji} Получен платеж (докупка трафика)\n\n👤 Пользователь: {user_display}\n💰 Сумма: {amount} {currency}\n🗂 {traffic_summary}\n{tariff_line}🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}", "log_payment_traffic_purchase_line": "{gb} ГБ · {kind}", diff --git a/tests/test_account_linking_panel.py b/tests/test_account_linking_panel.py index ff8549a..46139fa 100644 --- a/tests/test_account_linking_panel.py +++ b/tests/test_account_linking_panel.py @@ -269,6 +269,10 @@ class AccountLinkingPanelTests(unittest.IsolatedAsyncioTestCase): "last_name": "", "language_code": "ru", } + notification_service = SimpleNamespace( + notify_account_telegram_linked=AsyncMock(), + notify_account_merged=AsyncMock(), + ) with ( patch.object(account_routes, "_require_user_id", return_value=-100), @@ -299,7 +303,7 @@ class AccountLinkingPanelTests(unittest.IsolatedAsyncioTestCase): ), patch( "bot.services.notification_service.NotificationService", - return_value=SimpleNamespace(notify_account_telegram_linked=AsyncMock()), + return_value=notification_service, ), ): response = await account_routes.account_telegram_link_route(request) @@ -325,4 +329,15 @@ class AccountLinkingPanelTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(update_uuid, "panel-telegram") self.assertEqual(update_payload["email"], "linked@example.com") self.assertEqual(update_payload["telegramId"], 42) + notification_service.notify_account_merged.assert_awaited_once_with( + primary_user_id=42, + removed_user_id=-100, + email="linked@example.com", + telegram_id=42, + username="alice", + first_name="Alice", + final_end_date_text="", + primary_panel_user_uuid="panel-telegram", + removed_panel_user_uuid="panel-email", + ) self.assertIn("rw_webapp_session", response.cookies) diff --git a/tests/test_support_notifications.py b/tests/test_support_notifications.py index 35d94eb..d73e4c1 100644 --- a/tests/test_support_notifications.py +++ b/tests/test_support_notifications.py @@ -364,3 +364,51 @@ def test_support_user_reply_can_send_email_without_telegram_channels(): assert channels == [] assert emails[0]["ticket_id"] == 7 + + +def test_account_merge_notification_goes_to_log_channel(): + messages = [] + + class I18n: + def gettext(self, _language, key, **kwargs): + if key == "log_open_profile_link": + return "Open profile" + assert key == "log_account_merged" + return ( + f"merged primary={kwargs['primary_user_id']} " + f"removed={kwargs['removed_user_id']} " + f"email={kwargs['email']} end={kwargs['final_end_date']}" + ) + + service = NotificationService( + bot=SimpleNamespace(), + settings=_settings(LOG_CHAT_ID=-100123, DEFAULT_LANGUAGE="en"), + i18n=I18n(), + ) + + async def send_to_log_channel(message, thread_id=None, reply_markup=None): + messages.append((message, thread_id, reply_markup)) + + service._send_to_log_channel = send_to_log_channel + + asyncio.run( + service.notify_account_merged( + primary_user_id=42, + removed_user_id=-100, + email="paid@example.com", + telegram_id=100200300, + username="alice", + first_name="Alice", + final_end_date_text="2026-06-21 10:00", + primary_panel_user_uuid="panel-telegram", + removed_panel_user_uuid="panel-email", + ) + ) + + assert len(messages) == 1 + message, thread_id, reply_markup = messages[0] + assert "primary=42" in message + assert "removed=-100" in message + assert "paid@example.com" in message + assert thread_id is None + assert reply_markup.inline_keyboard[0][0].url == "tg://user?id=100200300"