diff --git a/bot/handlers/admin/user_management.py b/bot/handlers/admin/user_management.py index 4089055..548bfd9 100644 --- a/bot/handlers/admin/user_management.py +++ b/bot/handlers/admin/user_management.py @@ -19,6 +19,11 @@ from bot.services.referral_service import ReferralService from bot.middlewares.i18n import JsonI18n from bot.utils import get_message_content, send_direct_message from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton +from bot.utils.text_sanitizer import ( + sanitize_display_name, + sanitize_username, + username_for_display, +) router = Router(name="admin_user_management_router") USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$") @@ -118,8 +123,16 @@ async def format_user_card(user: User, session: AsyncSession, # User details na_value = _("admin_user_na_value", default="N/A") - user_name = user.first_name or na_value - username_display = f"@{user.username}" if user.username else na_value + safe_first_name = sanitize_display_name(user.first_name) if user.first_name else None + user_name = safe_first_name or na_value + if user.username: + sanitized_username = sanitize_username(user.username) + if sanitized_username: + username_display = f"@{sanitized_username}" + else: + username_display = username_for_display(user.username, with_at=False) + else: + username_display = na_value registration_date = user.registration_date.strftime('%Y-%m-%d %H:%M') if user.registration_date else na_value card_parts.append(f"{_('admin_user_id_label', default='🆔 ID:')} {hcode(str(user.user_id))}") @@ -950,4 +963,4 @@ async def process_unban_user_handler(message: types.Message, state: FSMContext, default="❌ Ошибка разблокировки пользователя" )) - await state.clear() \ No newline at end of file + await state.clear() diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index 2efcfbc..e6bc654 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -22,6 +22,7 @@ from bot.middlewares.i18n import JsonI18n from config.settings import Settings from bot.services.notification_service import NotificationService from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard +from bot.utils.text_sanitizer import sanitize_display_name, username_for_display payment_processing_lock = asyncio.Lock() @@ -241,10 +242,12 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, if db_user and db_user.referred_by_id: inviter = await user_dal.get_user_by_id( session, db_user.referred_by_id) - if inviter and inviter.first_name: - inviter_name_display = inviter.first_name - elif inviter and inviter.username: - inviter_name_display = f"@{inviter.username}" + if inviter: + safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None + if safe_name: + inviter_name_display = safe_name + elif inviter.username: + inviter_name_display = username_for_display(inviter.username, with_at=False) details_message = _( "payment_successful_with_referral_bonus_full", diff --git a/bot/handlers/user/start.py b/bot/handlers/user/start.py index 294b055..52f902b 100644 --- a/bot/handlers/user/start.py +++ b/bot/handlers/user/start.py @@ -17,6 +17,7 @@ from bot.services.referral_service import ReferralService from bot.services.promo_code_service import PromoCodeService from config.settings import Settings from bot.middlewares.i18n import JsonI18n +from bot.utils.text_sanitizer import sanitize_username, sanitize_display_name router = Router(name="user_start_router") @@ -152,13 +153,17 @@ async def start_command_handler(message: types.Message, ad_start_param = ad_param_match.group(1) logging.info(f"User {user_id} started with ad start param: {ad_start_param}") + sanitized_username = sanitize_username(user.username) + sanitized_first_name = sanitize_display_name(user.first_name) + sanitized_last_name = sanitize_display_name(user.last_name) + db_user = await user_dal.get_user_by_id(session, user_id) if not db_user: user_data_to_create = { "user_id": user_id, - "username": user.username, - "first_name": user.first_name, - "last_name": user.last_name, + "username": sanitized_username, + "first_name": sanitized_first_name, + "last_name": sanitized_last_name, "language_code": current_lang, "referred_by_id": referred_by_user_id, "registration_date": datetime.now(timezone.utc) @@ -177,8 +182,8 @@ async def start_command_handler(message: types.Message, notification_service = NotificationService(message.bot, settings, i18n) await notification_service.notify_new_user_registration( user_id=user_id, - username=user.username, - first_name=user.first_name, + username=sanitized_username, + first_name=sanitized_first_name, referred_by_id=referred_by_user_id ) except Exception as e: @@ -203,12 +208,12 @@ async def start_command_handler(message: types.Message, is_active_now = False if not is_active_now: update_payload["referred_by_id"] = referred_by_user_id - if user.username != db_user.username: - update_payload["username"] = user.username - if user.first_name != db_user.first_name: - update_payload["first_name"] = user.first_name - if user.last_name != db_user.last_name: - update_payload["last_name"] = user.last_name + if sanitized_username != db_user.username: + update_payload["username"] = sanitized_username + if sanitized_first_name != db_user.first_name: + update_payload["first_name"] = sanitized_first_name + if sanitized_last_name != db_user.last_name: + update_payload["last_name"] = sanitized_last_name if update_payload: try: diff --git a/bot/middlewares/profile_sync.py b/bot/middlewares/profile_sync.py index 24d075d..6fe3f3d 100644 --- a/bot/middlewares/profile_sync.py +++ b/bot/middlewares/profile_sync.py @@ -6,6 +6,7 @@ from aiogram.types import Update, User as TgUser from sqlalchemy.ext.asyncio import AsyncSession from db.dal import user_dal +from bot.utils.text_sanitizer import sanitize_username, sanitize_display_name, username_for_display class ProfileSyncMiddleware(BaseMiddleware): @@ -24,12 +25,16 @@ class ProfileSyncMiddleware(BaseMiddleware): db_user = await user_dal.get_user_by_id(session, tg_user.id) if db_user: update_payload: Dict[str, Any] = {} - if db_user.username != tg_user.username: - update_payload["username"] = tg_user.username - if db_user.first_name != tg_user.first_name: - update_payload["first_name"] = tg_user.first_name - if db_user.last_name != tg_user.last_name: - update_payload["last_name"] = tg_user.last_name + sanitized_username = sanitize_username(tg_user.username) + sanitized_first_name = sanitize_display_name(tg_user.first_name) + sanitized_last_name = sanitize_display_name(tg_user.last_name) + + if db_user.username != sanitized_username: + update_payload["username"] = sanitized_username + if db_user.first_name != sanitized_first_name: + update_payload["first_name"] = sanitized_first_name + if db_user.last_name != sanitized_last_name: + update_payload["last_name"] = sanitized_last_name if update_payload: await user_dal.update_user(session, tg_user.id, update_payload) @@ -42,10 +47,10 @@ class ProfileSyncMiddleware(BaseMiddleware): panel_service = data.get("panel_service") if panel_service and db_user.panel_user_uuid: description_text = "\n".join([ - tg_user.username or "", - tg_user.first_name or "", - tg_user.last_name or "", - ]) + 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() await panel_service.update_user_details_on_panel( db_user.panel_user_uuid, {"description": description_text}, @@ -62,4 +67,3 @@ class ProfileSyncMiddleware(BaseMiddleware): return await handler(event, data) - diff --git a/bot/services/crypto_pay_service.py b/bot/services/crypto_pay_service.py index 41261b3..7cf8baf 100644 --- a/bot/services/crypto_pay_service.py +++ b/bot/services/crypto_pay_service.py @@ -16,6 +16,7 @@ from bot.services.referral_service import ReferralService from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard from bot.services.notification_service import NotificationService from db.dal import payment_dal, user_dal +from bot.utils.text_sanitizer import sanitize_display_name, username_for_display class CryptoPayService: @@ -189,10 +190,12 @@ class CryptoPayService: inviter_name_display = _("friend_placeholder") if db_user and db_user.referred_by_id: inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id) - if inviter and inviter.first_name: - inviter_name_display = inviter.first_name - elif inviter and inviter.username: - inviter_name_display = f"@{inviter.username}" + if inviter: + safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None + if safe_name: + inviter_name_display = safe_name + elif inviter.username: + inviter_name_display = username_for_display(inviter.username, with_at=False) text = _("payment_successful_with_referral_bonus_full", months=months, base_end_date=activation["end_date"].strftime('%Y-%m-%d'), diff --git a/bot/services/notification_service.py b/bot/services/notification_service.py index f14cfb3..9a774b8 100644 --- a/bot/services/notification_service.py +++ b/bot/services/notification_service.py @@ -10,6 +10,10 @@ from config.settings import Settings from sqlalchemy.orm import sessionmaker from bot.middlewares.i18n import JsonI18n from bot.utils.message_queue import get_queue_manager +from bot.utils.text_sanitizer import ( + display_name_or_fallback, + username_for_display, +) class NotificationService: @@ -19,6 +23,17 @@ class NotificationService: self.bot = bot self.settings = settings self.i18n = i18n + + @staticmethod + def _format_user_display( + user_id: int, + username: Optional[str] = None, + first_name: Optional[str] = None, + ) -> str: + base_display = display_name_or_fallback(first_name, f"ID {user_id}") + if username: + base_display = f"{base_display} ({username_for_display(username)})" + return base_display async def _send_to_log_channel(self, message: str, thread_id: Optional[int] = None): """Send message to configured log channel/group using message queue""" @@ -101,9 +116,11 @@ class NotificationService: admin_lang = self.settings.DEFAULT_LANGUAGE _ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k - user_display = first_name or f"ID {user_id}" - if username: - user_display += f" (@{username})" + user_display = self._format_user_display( + user_id=user_id, + username=username, + first_name=first_name, + ) referral_text = "" if referred_by_id: @@ -134,9 +151,10 @@ class NotificationService: admin_lang = self.settings.DEFAULT_LANGUAGE _ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k - user_display = f"ID {user_id}" - if username: - user_display += f" (@{username})" + user_display = self._format_user_display( + user_id=user_id, + username=username, + ) provider_emoji = { "yookassa": "💳", @@ -174,9 +192,10 @@ class NotificationService: admin_lang = self.settings.DEFAULT_LANGUAGE _ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k - user_display = f"ID {user_id}" - if username: - user_display += f" (@{username})" + user_display = self._format_user_display( + user_id=user_id, + username=username, + ) message = _( "log_promo_activation", @@ -203,9 +222,10 @@ class NotificationService: admin_lang = self.settings.DEFAULT_LANGUAGE _ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k - user_display = f"ID {user_id}" - if username: - user_display += f" (@{username})" + user_display = self._format_user_display( + user_id=user_id, + username=username, + ) message = _( "log_trial_activation", @@ -268,9 +288,11 @@ class NotificationService: _ = lambda k, **kw: self.i18n.gettext( admin_lang, k, **kw) if self.i18n else k - user_display = first_name or f"ID {user_id}" - if username: - user_display += f" (@{username})" + user_display = self._format_user_display( + user_id=user_id, + username=username, + first_name=first_name, + ) message = _( "log_suspicious_promo", @@ -295,4 +317,4 @@ class NotificationService: if to_admins: await self._send_to_admins(message) -# Removed legacy helper functions that duplicated NotificationService API \ No newline at end of file +# Removed legacy helper functions that duplicated NotificationService API diff --git a/bot/services/stars_service.py b/bot/services/stars_service.py index b48b4b5..17173a2 100644 --- a/bot/services/stars_service.py +++ b/bot/services/stars_service.py @@ -12,6 +12,7 @@ from .referral_service import ReferralService from bot.middlewares.i18n import JsonI18n from .notification_service import NotificationService from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard +from bot.utils.text_sanitizer import sanitize_display_name, username_for_display class StarsService: @@ -124,10 +125,12 @@ class StarsService: db_user = await user_dal.get_user_by_id(session, message.from_user.id) if db_user and db_user.referred_by_id: inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id) - if inviter and inviter.first_name: - inviter_name_display = inviter.first_name - elif inviter and inviter.username: - inviter_name_display = f"@{inviter.username}" + if inviter: + safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None + if safe_name: + inviter_name_display = safe_name + elif inviter.username: + inviter_name_display = username_for_display(inviter.username, with_at=False) success_msg = _( "payment_successful_with_referral_bonus_full", months=months, @@ -173,4 +176,3 @@ class StarsService: ) except Exception as e: logging.error(f"Failed to send stars payment notification: {e}") - diff --git a/bot/services/tribute_service.py b/bot/services/tribute_service.py index 3057d3c..de12e55 100644 --- a/bot/services/tribute_service.py +++ b/bot/services/tribute_service.py @@ -16,6 +16,7 @@ from bot.services.referral_service import ReferralService from .notification_service import NotificationService from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard from db.dal import payment_dal, user_dal, subscription_dal +from bot.utils.text_sanitizer import sanitize_display_name, username_for_display def convert_period_to_months(period: Optional[str]) -> int: @@ -184,10 +185,12 @@ class TributeService: inviter_name_display = _('friend_placeholder') if db_user and db_user.referred_by_id: inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id) - if inviter and inviter.first_name: - inviter_name_display = inviter.first_name - elif inviter and inviter.username: - inviter_name_display = f"@{inviter.username}" + if inviter: + safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None + if safe_name: + inviter_name_display = safe_name + elif inviter.username: + inviter_name_display = username_for_display(inviter.username, with_at=False) success_msg = _( "payment_successful_with_referral_bonus_full", months=months, diff --git a/bot/utils/text_sanitizer.py b/bot/utils/text_sanitizer.py new file mode 100644 index 0000000..1657bd9 --- /dev/null +++ b/bot/utils/text_sanitizer.py @@ -0,0 +1,226 @@ +import re +import unicodedata +from typing import Optional + +_OBFUSCATION_CHARS = " .\\-/\\\\•﹒٫_․·∙‧ꞏ‒–—﹘﹣⁻−" + +_URL_PATTERNS = [ + re.compile(r"(?i)https?://\S+"), + re.compile(r"(?i)www\.\S+"), + re.compile(r"(?i)tg://\S+"), + re.compile(r"(?i)telegram\.me\S*"), + re.compile(r"(?i)t\.me/\+\S*"), + re.compile(r"(?i)joinchat\S*"), +] + +_OBFUSCATED_DOMAIN_PATTERNS = [ + re.compile( + r"(?i)[tт][\s{}\u2022]*[\.{}\u2022]*[\s{}\u2022]*[mм][eе]".format( + re.escape(_OBFUSCATION_CHARS), + re.escape(_OBFUSCATION_CHARS), + re.escape(_OBFUSCATION_CHARS), + ) + ), + re.compile( + r"(?i)[tт][{}\s]*[eе][{}\s]*[lłl1i|][{}\s]*[eе]" + r"[{}\s]*[gɢgqг][{}\s]*[rр][{}\s]*[aа]" + r"[{}\s]*(?:[mм]|rn)".format( + re.escape(_OBFUSCATION_CHARS), + re.escape(_OBFUSCATION_CHARS), + re.escape(_OBFUSCATION_CHARS), + re.escape(_OBFUSCATION_CHARS), + re.escape(_OBFUSCATION_CHARS), + re.escape(_OBFUSCATION_CHARS), + re.escape(_OBFUSCATION_CHARS), + ) + ), + re.compile(r"(?i)t\.me\S*"), +] + +_ENGLISH_SERVICE_PATTERNS = [ + re.compile(r"(?i)telegram"), + re.compile(r"(?i)teleqram"), + re.compile(r"(?i)teiegram"), + re.compile(r"(?i)teieqram"), + re.compile(r"(?i)telegrarn"), + re.compile(r"(?i)service"), + re.compile(r"(?i)notif(?:ication)?"), + re.compile(r"(?i)system"), + re.compile(r"(?i)security"), + re.compile(r"(?i)safety"), + re.compile(r"(?i)support"), + re.compile(r"(?i)moderation"), + re.compile(r"(?i)review"), + re.compile(r"(?i)compliance"), + re.compile(r"(?i)abuse"), + re.compile(r"(?i)spam"), + re.compile(r"(?i)report"), +] + +_RUSSIAN_SERVICE_PATTERNS = [ + re.compile(r"(?i)телеграм\w*"), + re.compile(r"(?i)служебн\w*"), + re.compile(r"(?i)уведомлен\w*"), + re.compile(r"(?i)поддержк\w*"), + re.compile(r"(?i)безопасн\w*"), + re.compile(r"(?i)модерац\w*"), + re.compile(r"(?i)жалоб\w*"), + re.compile(r"(?i)абуз\w*"), +] + +_PRE_LOWER_TRANSLATION = str.maketrans( + { + "I": "l", + "İ": "l", + "Q": "g", + "@": " ", + } +) + +_POST_LOWER_TRANSLATION = str.maketrans( + { + "а": "a", + "б": "b", + "в": "v", + "г": "g", + "д": "d", + "е": "e", + "ё": "e", + "ж": "zh", + "з": "z", + "и": "i", + "і": "i", + "й": "i", + "к": "k", + "л": "l", + "м": "m", + "н": "n", + "о": "o", + "п": "p", + "р": "r", + "с": "s", + "т": "t", + "у": "u", + "ф": "f", + "х": "h", + "ц": "c", + "ч": "ch", + "ш": "sh", + "щ": "sh", + "ъ": "", + "ы": "y", + "ь": "", + "э": "e", + "ю": "yu", + "я": "ya", + "_": "_", + } +) + +_NORMALIZED_BANNED_TOKENS = { + "tme", + "telegram", + "teleqram", + "teiegram", + "teieqram", + "telegrarn", + "joinchat", + "http", + "https", + "www", + "tg", + "service", + "notification", + "system", + "security", + "safety", + "support", + "moderation", + "review", + "compliance", + "abuse", + "spam", + "report", +} + +_USERNAME_PLACEHOLDER = "клиент" + + +def _normalize_for_detection(value: str) -> str: + if not value: + return "" + + normalized = unicodedata.normalize("NFKD", value) + normalized = normalized.translate(_PRE_LOWER_TRANSLATION) + normalized = normalized.lower() + normalized = "".join( + ch for ch in normalized if unicodedata.category(ch) != "Mn" + ) + normalized = normalized.translate(_POST_LOWER_TRANSLATION) + normalized = normalized.replace("rn", "m") + + pattern = rf"[{re.escape(_OBFUSCATION_CHARS)}\s]+" + normalized = re.sub(pattern, "", normalized) + normalized = re.sub(r"[^a-z0-9]+", "", normalized) + return normalized + + +def _remove_patterns(value: str) -> str: + updated = value + for pattern in ( + _URL_PATTERNS + + _OBFUSCATED_DOMAIN_PATTERNS + + _ENGLISH_SERVICE_PATTERNS + + _RUSSIAN_SERVICE_PATTERNS + ): + updated = pattern.sub(" ", updated) + return updated + + +def _finalize(value: str) -> Optional[str]: + compacted = re.sub(r"\s+", " ", value) + compacted = compacted.strip(" \t\r\n-_.,/\\") + compacted = compacted.strip() + if not compacted: + return None + + normalized = _normalize_for_detection(compacted) + if any(token in normalized for token in _NORMALIZED_BANNED_TOKENS): + return None + return compacted + + +def sanitize_display_name(value: Optional[str]) -> Optional[str]: + if value is None: + return None + clean = value.replace("@", " ") + clean = _remove_patterns(clean) + return _finalize(clean) + + +def sanitize_username(value: Optional[str]) -> Optional[str]: + if value is None: + return None + clean = value.strip() + clean = clean.lstrip("@") + clean = _remove_patterns(clean) + return _finalize(clean) + + +def username_for_display(username: Optional[str], with_at: bool = False) -> str: + sanitized = sanitize_username(username) + if not sanitized: + return _USERNAME_PLACEHOLDER + return f"@{sanitized}" if with_at else sanitized + + +def display_name_or_fallback( + first_name: Optional[str], + fallback: Optional[str] = None, +) -> str: + sanitized = sanitize_display_name(first_name) + if sanitized: + return sanitized + if fallback is not None: + return fallback + return _USERNAME_PLACEHOLDER