fix: stop panel description churn

This commit is contained in:
3252a8
2026-05-25 00:06:29 +03:00
parent 121f3c6ddf
commit 82cc33587c
13 changed files with 172 additions and 58 deletions
+5 -1
View File
@@ -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
+6 -9
View File
@@ -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
+15 -18
View File
@@ -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
+5 -13
View File
@@ -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(
@@ -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("__")]
@@ -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
+37
View File
@@ -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 ""