From 82cc33587cd10d9c12aa8495a4cee0dfb2578d7b Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Mon, 25 May 2026 00:06:29 +0300 Subject: [PATCH] fix: stop panel description churn --- backend/bot/app/web/webapp/_runtime.py | 6 ++- backend/bot/app/web/webapp/auth.py | 15 +++----- backend/bot/handlers/admin/sync_admin.py | 33 ++++++++--------- backend/bot/middlewares/profile_sync.py | 18 +++------ .../subscription_service_impl/_runtime.py | 1 + .../panel_identity.py | 15 +++----- backend/bot/utils/text_sanitizer.py | 37 +++++++++++++++++++ tests/test_account_linking_panel.py | 12 +++++- tests/test_admin_sync_performance.py | 31 +++++++++++++++- tests/test_panel_api_service_logging.py | 14 +++++++ tests/test_panel_identity_description.py | 17 ++++++++- tests/test_profile_sync_middleware.py | 5 ++- tests/test_text_sanitizer.py | 26 ++++++++++++- 13 files changed, 172 insertions(+), 58 deletions(-) diff --git a/backend/bot/app/web/webapp/_runtime.py b/backend/bot/app/web/webapp/_runtime.py index 463ddb3..743d78b 100644 --- a/backend/bot/app/web/webapp/_runtime.py +++ b/backend/bot/app/web/webapp/_runtime.py @@ -49,7 +49,11 @@ from bot.services.referral_service import ReferralService from bot.services.subscription_service import SubscriptionService from bot.utils.config_link import prepare_config_links from bot.utils.request_security import parse_ip_entries, request_client_ip -from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username +from bot.utils.text_sanitizer import ( + panel_description_from_profile, + sanitize_display_name, + sanitize_username, +) from config.settings import Settings from db.dal import payment_dal, security_dal, subscription_dal, support_dal, user_dal from db.dal.user_dal import UserMergeConflictError diff --git a/backend/bot/app/web/webapp/auth.py b/backend/bot/app/web/webapp/auth.py index 08cfa67..6b966c5 100644 --- a/backend/bot/app/web/webapp/auth.py +++ b/backend/bot/app/web/webapp/auth.py @@ -1013,12 +1013,11 @@ def _telegram_id_for_user(user: User) -> Optional[int]: def _panel_description_for_user(user: User) -> str: - lines = [ - user.username or "", - user.first_name or "", - user.last_name or "", - ] - return "\n".join(line for line in lines if line).strip() + return panel_description_from_profile( + user.username, + user.first_name, + user.last_name, + ) def _telegram_photo_url_value(telegram_user: Dict[str, Any]) -> Optional[str]: @@ -1041,9 +1040,7 @@ async def _sync_panel_identity_for_user( if not subscription_service or not subscription_service.panel_service: return False - payload: Dict[str, Any] = { - "description": _panel_description_for_user(user), - } + payload: Dict[str, Any] = {} telegram_id = _telegram_id_for_user(user) if telegram_id: payload["telegramId"] = telegram_id diff --git a/backend/bot/handlers/admin/sync_admin.py b/backend/bot/handlers/admin/sync_admin.py index 1bbfb40..0baad9e 100644 --- a/backend/bot/handlers/admin/sync_admin.py +++ b/backend/bot/handlers/admin/sync_admin.py @@ -12,6 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from bot.middlewares.i18n import JsonI18n from bot.services.notification_service import NotificationService from bot.services.panel_api_service import PanelApiService +from bot.utils.text_sanitizer import panel_description_from_profile from config.settings import Settings from db.dal import panel_sync_dal, subscription_dal, user_dal from db.models import Subscription, User @@ -354,8 +355,8 @@ async def _panel_identity_view_for_comparison( 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} +def _panel_identity_fields_update_payload(user: User) -> dict[str, Any]: + payload: dict[str, Any] = {} if user.email: payload["email"] = user.email if user.telegram_id: @@ -364,12 +365,11 @@ def _panel_identity_update_payload(user: User, description_text: str) -> dict[st def _panel_description_for_user(user: User) -> str: - lines = [ - user.username or "", - user.first_name or "", - user.last_name or "", - ] - return "\n".join(line for line in lines if line).strip() + return panel_description_from_profile( + user.username, + user.first_name, + user.last_name, + ) def _datetime_matches(current: Optional[datetime], desired: datetime) -> bool: @@ -683,7 +683,7 @@ def _panel_identity_payload_with_expiry( *, expire_at: datetime, ) -> dict[str, Any]: - payload = _panel_identity_update_payload(user, _panel_description_for_user(user)) + payload = _panel_identity_fields_update_payload(user) payload["expireAt"] = expire_at.isoformat(timespec="milliseconds").replace("+00:00", "Z") if expire_at > datetime.now(timezone.utc): payload["status"] = "ACTIVE" @@ -1272,11 +1272,11 @@ async def _perform_sync_impl( user_was_updated = True _append_unique(user_update_reasons, "lifetime_traffic_synced") - # Ensure panel description contains Telegram fields + # Keep structural identity fields in panel and clean legacy email from + # description. Plain description text is intentionally not canonical. try: if panel_uuid and existing_user and not is_duplicate_panel_identity: description_text = _panel_description_for_user(existing_user) - # Update description only when it differs from the current one on panel desired_description = description_text.strip() ( panel_user_for_identity, @@ -1296,19 +1296,16 @@ async def _perform_sync_impl( identity_matches = _panel_identity_matches_user( panel_user_for_identity, existing_user, - desired_description, + "", missing_identity_fields_match=missing_identity_fields_match, ) + panel_payload = _panel_identity_fields_update_payload(existing_user) if description_has_email: - description_text = _description_without_email( + panel_payload["description"] = _description_without_email( current_description, existing_user.email, ) if description_has_email or not identity_matches: - panel_payload = _panel_identity_update_payload( - existing_user, - description_text, - ) panel_changes = _panel_update_changes( panel_user_for_identity, panel_payload, @@ -1339,7 +1336,7 @@ async def _perform_sync_impl( ) except Exception as e_desc: logging.warning( - f"Sync: Failed to update description for panel user {panel_uuid} (tg {actual_user_id}): {e_desc}" # noqa: E501 + f"Sync: Failed to update panel identity for panel user {panel_uuid} (tg {actual_user_id}): {e_desc}" # noqa: E501 ) # Sync subscription data diff --git a/backend/bot/middlewares/profile_sync.py b/backend/bot/middlewares/profile_sync.py index c06786d..bbb1b0e 100644 --- a/backend/bot/middlewares/profile_sync.py +++ b/backend/bot/middlewares/profile_sync.py @@ -8,7 +8,7 @@ from aiogram.types import User as TgUser from sqlalchemy.ext.asyncio import AsyncSession from bot.infra.redis import cache_get_json, cache_set_json, redis_key -from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username, username_for_display +from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username from config.settings import Settings from db.dal import user_dal @@ -55,21 +55,13 @@ class ProfileSyncMiddleware(BaseMiddleware): f"ProfileSyncMiddleware: Updated user {tg_user.id} profile fields: {list(update_payload.keys())}" # noqa: E501 ) - # Also update description on panel if linked + # Keep panel identity fields fresh, but do not rewrite + # description from profile changes. Remnawave may return + # description with lossy encoding in list views. try: panel_service = data.get("panel_service") if panel_service and db_user.panel_user_uuid: - description_text = "\n".join( - [ - username_for_display(tg_user.username, with_at=False) - if sanitized_username is not None - else "", - sanitized_first_name or "", - sanitized_last_name or "", - ] - ).strip() panel_payload = { - "description": description_text, "telegramId": tg_user.id, } if db_user.email: @@ -80,7 +72,7 @@ class ProfileSyncMiddleware(BaseMiddleware): ) except Exception as e_upd_desc: logging.warning( - f"ProfileSyncMiddleware: Failed to update panel description for user {tg_user.id}: {e_upd_desc}" # noqa: E501 + f"ProfileSyncMiddleware: Failed to update panel identity for user {tg_user.id}: {e_upd_desc}" # noqa: E501 ) except Exception as e: logging.error( diff --git a/backend/bot/services/subscription_service_impl/_runtime.py b/backend/bot/services/subscription_service_impl/_runtime.py index 33ebab9..32b26c4 100644 --- a/backend/bot/services/subscription_service_impl/_runtime.py +++ b/backend/bot/services/subscription_service_impl/_runtime.py @@ -25,5 +25,6 @@ from db.models import Subscription, User from bot.services.email_auth_service import EmailAuthService from bot.services.email_templates import render_payment_success from bot.services.panel_api_service import PanelApiService +from bot.utils.text_sanitizer import panel_description_from_profile __all__ = [name for name in globals() if not name.startswith("__")] diff --git a/backend/bot/services/subscription_service_impl/panel_identity.py b/backend/bot/services/subscription_service_impl/panel_identity.py index 90a9a0f..77ec151 100644 --- a/backend/bot/services/subscription_service_impl/panel_identity.py +++ b/backend/bot/services/subscription_service_impl/panel_identity.py @@ -57,17 +57,14 @@ class PanelIdentityMixin: return f"em_{referral_code}" def _panel_description_for_user(self, db_user: User) -> str: - lines = [ - db_user.username or "", - db_user.first_name or "", - db_user.last_name or "", - ] - return "\n".join(line for line in lines if line).strip() + return panel_description_from_profile( + db_user.username, + db_user.first_name, + db_user.last_name, + ) def _panel_identity_payload_for_user(self, db_user: User) -> Dict[str, Any]: - payload: Dict[str, Any] = { - "description": self._panel_description_for_user(db_user), - } + payload: Dict[str, Any] = {} telegram_id = self._telegram_id_for_panel(db_user) if telegram_id: payload["telegramId"] = telegram_id diff --git a/backend/bot/utils/text_sanitizer.py b/backend/bot/utils/text_sanitizer.py index 18d63b4..1fef643 100644 --- a/backend/bot/utils/text_sanitizer.py +++ b/backend/bot/utils/text_sanitizer.py @@ -146,6 +146,43 @@ _NORMALIZED_BANNED_TOKENS = { _USERNAME_PLACEHOLDER = "клиент" +def looks_like_broken_panel_text(value: Optional[str]) -> bool: + if value is None: + return False + + text = unicodedata.normalize("NFKC", str(value)).strip() + if not text: + return False + if "\ufffd" in text: + return True + + meaningful = [ch for ch in text if not ch.isspace()] + if len(meaningful) < 2: + return False + + question_count = sum(1 for ch in meaningful if ch == "?") + if question_count < 2: + return False + + has_content = any( + ch != "?" and not unicodedata.category(ch).startswith("P") for ch in meaningful + ) + return not has_content and question_count / len(meaningful) >= 0.5 + + +def panel_description_from_profile( + username: Optional[str], + first_name: Optional[str], + last_name: Optional[str], +) -> str: + lines = [] + for value in (username, first_name, last_name): + line = (value or "").strip() + if line and not looks_like_broken_panel_text(line): + lines.append(line) + return "\n".join(lines).strip() + + def _normalize_for_detection(value: str) -> str: if not value: return "" diff --git a/tests/test_account_linking_panel.py b/tests/test_account_linking_panel.py index 8d1c136..90cfd28 100644 --- a/tests/test_account_linking_panel.py +++ b/tests/test_account_linking_panel.py @@ -74,7 +74,7 @@ class AccountLinkingPanelTests(unittest.IsolatedAsyncioTestCase): self.assertTrue(result) panel_service.update_user_details_on_panel.assert_awaited_once() _, payload = panel_service.update_user_details_on_panel.await_args.args[:2] - self.assertEqual(payload["description"], "alice") + self.assertNotIn("description", payload) self.assertEqual(payload["email"], "linked@example.com") def test_panel_description_for_user_excludes_email(self): @@ -87,6 +87,16 @@ class AccountLinkingPanelTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(_panel_description_for_user(user), "alice\nAlice") + def test_panel_description_for_user_filters_broken_lines(self): + user = SimpleNamespace( + email="linked@example.com", + username="alice??", + first_name="????", + last_name="Smith", + ) + + self.assertEqual(_panel_description_for_user(user), "alice??\nSmith") + async def test_merged_panel_identity_deletes_source_before_updating_target(self): calls = [] diff --git a/tests/test_admin_sync_performance.py b/tests/test_admin_sync_performance.py index 9f684ca..fc7559d 100644 --- a/tests/test_admin_sync_performance.py +++ b/tests/test_admin_sync_performance.py @@ -12,6 +12,7 @@ from bot.handlers.admin.sync_admin import ( _format_panel_update_changes, _identity_panel_update_reasons, _panel_description_for_user, + _panel_identity_fields_update_payload, _panel_identity_matches_user, _panel_identity_needs_full_fetch, _panel_identity_needs_legacy_description_cleanup, @@ -58,6 +59,17 @@ def test_panel_description_for_user_excludes_email(): assert _panel_description_for_user(user) == "alice\nAlice\nSmith" +def test_panel_description_for_user_filters_broken_lines(): + user = SimpleNamespace( + email="linked@example.com", + username="alice??", + first_name="????", + last_name="Smith", + ) + + assert _panel_description_for_user(user) == "alice??\nSmith" + + def test_panel_update_change_summary_is_compact_and_field_based(): changes = _panel_update_changes( { @@ -133,7 +145,7 @@ def test_panel_identity_match_accepts_list_description_without_email(): ) -def test_panel_identity_payload_with_expiry_keeps_email_out_of_description(): +def test_panel_identity_payload_with_expiry_excludes_description_updates(): expire_at = datetime(2026, 6, 1, 12, 0, tzinfo=timezone.utc) user = SimpleNamespace( email="linked@example.com", @@ -145,11 +157,26 @@ def test_panel_identity_payload_with_expiry_keeps_email_out_of_description(): payload = _panel_identity_payload_with_expiry(user, expire_at=expire_at) - assert payload["description"] == "alice\nAlice" + assert "description" not in payload assert payload["email"] == "linked@example.com" assert payload["telegramId"] == 42 +def test_panel_identity_fields_update_payload_excludes_description(): + user = SimpleNamespace( + email="linked@example.com", + telegram_id=42, + username="alice", + first_name="Alice", + last_name=None, + ) + + assert _panel_identity_fields_update_payload(user) == { + "email": "linked@example.com", + "telegramId": 42, + } + + def test_panel_identity_detects_legacy_full_description_cleanup_need(): user = SimpleNamespace( email="linked@example.com", diff --git a/tests/test_panel_api_service_logging.py b/tests/test_panel_api_service_logging.py index 3bba5d4..ee4decc 100644 --- a/tests/test_panel_api_service_logging.py +++ b/tests/test_panel_api_service_logging.py @@ -47,6 +47,20 @@ class PanelApiServiceLoggingTests(unittest.IsolatedAsyncioTestCase): self.assertTrue(service._request.await_args.kwargs["log_full_response"]) + async def test_create_panel_user_omits_empty_description(self): + service = self._make_service() + service._request = AsyncMock(return_value={"response": {"uuid": "user-uuid"}}) + + await service.create_panel_user( + username_on_panel="tg_42", + telegram_id=42, + description="", + ) + + payload = service._request.await_args.kwargs["json"] + self.assertNotIn("description", payload) + self.assertEqual(payload["telegramId"], 42) + async def test_get_user_by_uuid_uses_short_ttl_cache_and_update_invalidates(self): service = self._make_service() service._request = AsyncMock(return_value={"response": {"uuid": "user-uuid"}}) diff --git a/tests/test_panel_identity_description.py b/tests/test_panel_identity_description.py index bda1e10..3e38d18 100644 --- a/tests/test_panel_identity_description.py +++ b/tests/test_panel_identity_description.py @@ -3,7 +3,7 @@ from types import SimpleNamespace from bot.services.subscription_service_impl.panel_identity import PanelIdentityMixin -def test_subscription_panel_description_excludes_email(): +def test_subscription_panel_identity_payload_excludes_description_updates(): user = SimpleNamespace( email="linked@example.com", username="alice", @@ -15,6 +15,19 @@ def test_subscription_panel_description_excludes_email(): payload = PanelIdentityMixin()._panel_identity_payload_for_user(user) - assert payload["description"] == "alice\nAlice\nSmith" + assert "description" not in payload assert payload["email"] == "linked@example.com" assert payload["telegramId"] == 42 + + +def test_subscription_panel_description_filters_broken_lines_for_creation(): + user = SimpleNamespace( + email="linked@example.com", + username="alice??", + first_name="????", + last_name="Smith", + telegram_id=42, + user_id=42, + ) + + assert PanelIdentityMixin()._panel_description_for_user(user) == "alice??\nSmith" diff --git a/tests/test_profile_sync_middleware.py b/tests/test_profile_sync_middleware.py index b930ccd..75bcf3e 100644 --- a/tests/test_profile_sync_middleware.py +++ b/tests/test_profile_sync_middleware.py @@ -69,7 +69,7 @@ class ProfileSyncMiddlewareCacheTests(unittest.IsolatedAsyncioTestCase): get_user.assert_awaited_once() self.assertEqual(handler.await_count, 2) - async def test_profile_sync_keeps_email_out_of_panel_description(self): + async def test_profile_sync_does_not_rewrite_panel_description(self): middleware = ProfileSyncMiddleware() handler = AsyncMock(return_value="ok") event = SimpleNamespace() @@ -124,8 +124,9 @@ class ProfileSyncMiddlewareCacheTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(result, "ok") panel_service.update_user_details_on_panel.assert_awaited_once() _, payload = panel_service.update_user_details_on_panel.await_args.args[:2] - self.assertEqual(payload["description"], "alice\nAlice\nSmith") + self.assertNotIn("description", payload) self.assertEqual(payload["email"], "linked@example.com") + self.assertEqual(payload["telegramId"], 42) if __name__ == "__main__": diff --git a/tests/test_text_sanitizer.py b/tests/test_text_sanitizer.py index c53f7ef..59f5b1b 100644 --- a/tests/test_text_sanitizer.py +++ b/tests/test_text_sanitizer.py @@ -3,7 +3,13 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "backend")) -from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username, username_for_display +from bot.utils.text_sanitizer import ( + looks_like_broken_panel_text, + panel_description_from_profile, + sanitize_display_name, + sanitize_username, + username_for_display, +) def test_sanitize_username_preserves_underscore_suffixes(): @@ -22,3 +28,21 @@ def test_sanitize_username_rejects_free_form_values_instead_of_truncating(): def test_display_name_filters_still_apply_to_free_form_names(): assert sanitize_display_name("Name service") == "Name" + + +def test_panel_broken_text_detection_is_conservative_about_question_marks(): + assert not looks_like_broken_panel_text("?") + assert not looks_like_broken_panel_text("alice??") + assert not looks_like_broken_panel_text("??? 123") + assert not looks_like_broken_panel_text("\U0001f0cf") + + +def test_panel_broken_text_detection_filters_replacement_garbage(): + assert looks_like_broken_panel_text("????") + assert looks_like_broken_panel_text("???!") + assert looks_like_broken_panel_text("\ufffd\ufffd") + + +def test_panel_description_filters_only_broken_lines(): + assert panel_description_from_profile("alice??", "????", "Smith") == "alice??\nSmith" + assert panel_description_from_profile(None, "????", "\ufffd\ufffd") == ""