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"