fix: repair linked panel email during sync

This commit is contained in:
3252a8
2026-05-23 22:27:51 +03:00
parent 60d8c297f9
commit 31eb5c06ad
4 changed files with 291 additions and 9 deletions
+9 -1
View File
@@ -1058,11 +1058,19 @@ async def _sync_panel_identity_for_user(
payload["status"] = "ACTIVE"
try:
await subscription_service.panel_service.update_user_details_on_panel(
updated_panel_user = await subscription_service.panel_service.update_user_details_on_panel(
user.panel_user_uuid,
payload,
log_response=False,
)
if not updated_panel_user or (
isinstance(updated_panel_user, dict) and updated_panel_user.get("error")
):
logger.warning(
"Panel identity update returned no success payload for user %s",
user.user_id,
)
return False
return True
except Exception as exc:
logger.warning(
+134 -8
View File
@@ -66,6 +66,8 @@ def _panel_identity_matches_user(
panel_user: dict[str, Any],
user: User,
desired_description: str,
*,
missing_identity_fields_match: bool = True,
) -> bool:
if desired_description and not _description_matches(
panel_user.get("description"),
@@ -73,17 +75,65 @@ def _panel_identity_matches_user(
):
return False
if user.email and _normalize_panel_email(panel_user.get("email")) != user.email.strip().lower():
return False
if user.email:
if "email" not in panel_user:
return missing_identity_fields_match
panel_email = _normalize_panel_email(panel_user.get("email"))
if panel_email != user.email.strip().lower():
return False
if user.telegram_id and _coerce_panel_telegram_id(panel_user.get("telegramId")) != int(
user.telegram_id
):
return False
if user.telegram_id:
if "telegramId" not in panel_user:
return missing_identity_fields_match
panel_telegram_id = _coerce_panel_telegram_id(panel_user.get("telegramId"))
if panel_telegram_id != int(user.telegram_id):
return False
return True
def _panel_identity_needs_full_fetch(panel_user: dict[str, Any], user: User) -> bool:
if user.email:
if "email" not in panel_user:
return True
if not _normalize_panel_email(panel_user.get("email")):
return True
if user.telegram_id:
if "telegramId" not in panel_user:
return True
if _coerce_panel_telegram_id(panel_user.get("telegramId")) is None:
return True
return False
async def _panel_identity_view_for_comparison(
panel_service: PanelApiService,
panel_uuid: str,
panel_user: dict[str, Any],
user: User,
) -> tuple[dict[str, Any], bool]:
"""Return the most reliable panel user view available for identity comparison.
Remnawave list responses may omit identity fields. When that happens, fetch
the concrete user by UUID before deciding whether the panel really needs a
repair PATCH.
"""
if not _panel_identity_needs_full_fetch(panel_user, user):
return panel_user, True
try:
full_panel_user = await panel_service.get_user_by_uuid(panel_uuid)
except Exception:
logging.exception(
"Sync: failed to fetch full panel user %s for identity comparison",
panel_uuid,
)
return panel_user, True
if not full_panel_user:
return panel_user, True
return full_panel_user, False
def _panel_identity_update_payload(user: User, description_text: str) -> dict[str, Any]:
payload: dict[str, Any] = {"description": description_text}
if user.email:
@@ -802,6 +852,72 @@ async def _perform_sync_impl(
and str(linked_uuid)
in panel_uuids_by_telegram_id.get(telegram_id_from_panel, set())
)
linked_uuid_present_on_panel = bool(
linked_uuid and str(linked_uuid) in panel_users_by_uuid
)
panel_uuid_owner = users_by_panel_uuid.get(panel_uuid)
if (
panel_uuid_owner
and panel_uuid_owner.user_id != existing_user.user_id
and not linked_uuid_still_present
):
if linked_uuid_present_on_panel:
msg = (
f"Panel UUID {panel_uuid} for user {actual_user_id} is already "
f"linked to local user {panel_uuid_owner.user_id}, while current "
f"local panel UUID {linked_uuid} still exists on panel."
)
sync_errors.append(msg)
logging.warning("Sync: %s", msg)
continue
previous_panel_uuid = existing_user.panel_user_uuid
previous_owner_user_id = panel_uuid_owner.user_id
previous_owner_email = panel_uuid_owner.email
previous_owner_telegram_id = panel_uuid_owner.telegram_id
(
existing_user,
can_merge_panel_uuid_owner,
) = await _merge_local_duplicate_panel_user_if_needed(
session,
existing_user=existing_user,
duplicate_panel_uuid=panel_uuid,
)
if not can_merge_panel_uuid_owner:
logging.warning(
"Sync: panel UUID %s is already linked to local user %s; "
"skipping reassignment to user %s because local merge failed.",
panel_uuid,
previous_owner_user_id,
actual_user_id,
)
continue
existing_user.panel_user_uuid = panel_uuid
actual_user_id = existing_user.user_id
user_was_updated = True
users_uuid_updated += 1
if previous_panel_uuid:
users_by_panel_uuid.pop(str(previous_panel_uuid), None)
users_by_panel_uuid[panel_uuid] = existing_user
users_by_user_id.pop(int(previous_owner_user_id), None)
users_by_user_id[int(existing_user.user_id)] = existing_user
if previous_owner_telegram_id is not None:
users_by_telegram_id.pop(int(previous_owner_telegram_id), None)
if existing_user.telegram_id is not None:
users_by_telegram_id[int(existing_user.telegram_id)] = existing_user
if previous_owner_email:
users_by_email.pop(previous_owner_email.strip().lower(), None)
if existing_user.email:
users_by_email[existing_user.email.strip().lower()] = existing_user
logging.info(
"Sync: merged local user %s owning panel UUID %s into user %s "
"and reassigned stale local panel UUID %s.",
previous_owner_user_id,
panel_uuid,
actual_user_id,
previous_panel_uuid,
)
if linked_uuid_still_present:
is_duplicate_panel_identity = True
(
@@ -859,7 +975,7 @@ async def _perform_sync_impl(
panel_uuid,
)
continue
else:
elif existing_user.panel_user_uuid != panel_uuid:
existing_user.panel_user_uuid = panel_uuid
user_was_updated = True
users_uuid_updated += 1
@@ -911,10 +1027,20 @@ async def _perform_sync_impl(
)
# Update description only when it differs from the current one on panel
desired_description = description_text.strip()
if desired_description and not _panel_identity_matches_user(
(
panel_user_for_identity,
missing_identity_fields_match,
) = await _panel_identity_view_for_comparison(
panel_service,
panel_uuid,
panel_user_dict,
existing_user,
)
if desired_description and not _panel_identity_matches_user(
panel_user_for_identity,
existing_user,
desired_description,
missing_identity_fields_match=missing_identity_fields_match,
):
await panel_service.update_user_details_on_panel(
panel_uuid,
+43
View File
@@ -9,6 +9,7 @@ from bot.app.web.webapp import account as account_routes
from bot.app.web.webapp.auth import (
_link_telegram_to_user,
_sync_merged_panel_identity_for_user,
_sync_panel_identity_for_user,
)
@@ -30,6 +31,48 @@ class AccountLinkingPanelTests(unittest.IsolatedAsyncioTestCase):
async def __aexit__(self, exc_type, exc, tb):
return None
async def test_panel_identity_sync_reports_failed_update_response(self):
user = SimpleNamespace(
user_id=42,
panel_user_uuid="panel-42",
email="linked@example.com",
telegram_id=42,
username="alice",
first_name=None,
last_name=None,
)
panel_service = SimpleNamespace(update_user_details_on_panel=AsyncMock(return_value=None))
request = SimpleNamespace(
app={"subscription_service": SimpleNamespace(panel_service=panel_service)}
)
result = await _sync_panel_identity_for_user(request, user)
self.assertFalse(result)
panel_service.update_user_details_on_panel.assert_awaited_once()
async def test_panel_identity_sync_reports_successful_update_response(self):
user = SimpleNamespace(
user_id=42,
panel_user_uuid="panel-42",
email="linked@example.com",
telegram_id=42,
username="alice",
first_name=None,
last_name=None,
)
panel_service = SimpleNamespace(
update_user_details_on_panel=AsyncMock(return_value={"uuid": "panel-42"})
)
request = SimpleNamespace(
app={"subscription_service": SimpleNamespace(panel_service=panel_service)}
)
result = await _sync_panel_identity_for_user(request, user)
self.assertTrue(result)
panel_service.update_user_details_on_panel.assert_awaited_once()
async def test_merged_panel_identity_deletes_source_before_updating_target(self):
calls = []
+105
View File
@@ -7,6 +7,9 @@ from bot.handlers.admin.sync_admin import (
_absorb_duplicate_panel_identity,
_coerce_panel_telegram_id,
_description_matches,
_panel_identity_matches_user,
_panel_identity_needs_full_fetch,
_panel_identity_view_for_comparison,
_should_update_lifetime_used_traffic,
_subscription_update_delta,
)
@@ -36,6 +39,108 @@ def test_panel_telegram_id_is_coerced_to_int():
assert _coerce_panel_telegram_id("") is None
def test_panel_identity_match_treats_missing_list_email_as_unknown():
user = SimpleNamespace(
email="linked@example.com",
telegram_id=42,
)
panel_user = {
"description": "linked@example.com\nalice",
"telegramId": 42,
}
assert _panel_identity_matches_user(
panel_user,
user,
"linked@example.com\nalice",
)
def test_panel_identity_match_treats_missing_full_email_as_mismatch():
user = SimpleNamespace(
email="linked@example.com",
telegram_id=42,
)
panel_user = {
"description": "linked@example.com\nalice",
"telegramId": 42,
}
assert not _panel_identity_matches_user(
panel_user,
user,
"linked@example.com\nalice",
missing_identity_fields_match=False,
)
def test_panel_identity_match_rejects_different_returned_email():
user = SimpleNamespace(
email="linked@example.com",
telegram_id=42,
)
panel_user = {
"description": "linked@example.com\nalice",
"email": "other@example.com",
"telegramId": 42,
}
assert not _panel_identity_matches_user(
panel_user,
user,
"linked@example.com\nalice",
)
def test_panel_identity_needs_full_fetch_for_missing_or_blank_identity_fields():
user = SimpleNamespace(
email="linked@example.com",
telegram_id=42,
)
assert _panel_identity_needs_full_fetch({"telegramId": 42}, user)
assert _panel_identity_needs_full_fetch({"email": "", "telegramId": 42}, user)
assert _panel_identity_needs_full_fetch({"email": "linked@example.com"}, user)
assert not _panel_identity_needs_full_fetch(
{"email": "linked@example.com", "telegramId": "42"},
user,
)
def test_panel_identity_view_fetches_full_user_when_list_email_missing():
panel_service = SimpleNamespace(
get_user_by_uuid=AsyncMock(
return_value={
"uuid": "panel-1",
"description": "linked@example.com\nalice",
"email": "linked@example.com",
"telegramId": 42,
}
)
)
user = SimpleNamespace(
email="linked@example.com",
telegram_id=42,
)
panel_user, missing_fields_match = asyncio.run(
_panel_identity_view_for_comparison(
panel_service,
"panel-1",
{
"uuid": "panel-1",
"description": "linked@example.com\nalice",
"telegramId": 42,
},
user,
)
)
assert panel_user["email"] == "linked@example.com"
assert not missing_fields_match
panel_service.get_user_by_uuid.assert_awaited_once_with("panel-1")
def test_subscription_update_delta_skips_unchanged_fields():
end_date = datetime(2026, 5, 20, 12, 0, tzinfo=timezone.utc)
subscription = Subscription(