From 68467cc0ac37fe9e7191bb9d856f2ab7b6309969 Mon Sep 17 00:00:00 2001 From: Tayo Date: Mon, 19 Oct 2026 20:20:09 +0700 Subject: [PATCH] Fix: whitelist own username in bio link detection has_suspicious_bio_links now accepts the bio owner's username and treats t.me/ links and @ mentions as self-references instead of promo links. Fixes false positives for users whose bio links to their own profile. --- src/bot/handlers/bio_bait.py | 36 +++++++++++++++++++++++++++++--- tests/test_bio_bait.py | 40 ++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/src/bot/handlers/bio_bait.py b/src/bot/handlers/bio_bait.py index a14becd..b05c4ca 100644 --- a/src/bot/handlers/bio_bait.py +++ b/src/bot/handlers/bio_bait.py @@ -23,6 +23,7 @@ import re import unicodedata from time import monotonic +from urllib.parse import urlparse from aiogram.types import Update @@ -201,7 +202,27 @@ def is_bio_bait_spam(text: str) -> bool: return False return any(pattern.search(normalized) for pattern in BIO_BAIT_PATTERNS) -def has_suspicious_bio_links(bio: str) -> bool: +def _telegram_link_first_segment(url: str) -> str | None: + """ + Extract the first path segment of a t.me/telegram.me URL, lowercased. + + Args: + url: Matched Telegram URL, with or without scheme. + + Returns: + The first path segment (e.g. "somechannel" for t.me/somechannel), + or None when the URL has no usable path. + """ + candidate = url if url.startswith(("http://", "https://")) else "https://" + url + try: + path = urlparse(candidate).path + except Exception: + return None + parts = [p for p in path.strip("/").split("/") if p] + return parts[0].lower() if parts else None + + +def has_suspicious_bio_links(bio: str, username: str | None = None) -> bool: """ Check whether a user's bio text contains suspicious Telegram promo refs. @@ -211,8 +232,12 @@ def has_suspicious_bio_links(bio: str) -> bool: - Two or more non-whitelisted bare @mentions. - A single non-whitelisted @mention combined with a promo hint word. + Links and @mentions that point at the bio owner's own username are + treated as self-references, not promo links (dynamic self-whitelist). + Args: bio: Raw bio string from the user's profile. + username: The bio owner's Telegram username (without @), if known. Returns: bool: True if the bio is considered spammy. @@ -222,17 +247,22 @@ def has_suspicious_bio_links(bio: str) -> bool: normalized = unicodedata.normalize("NFKC", bio) lowered = normalized.lower() + own_username = username.lower() if username else None if TELEGRAM_INVITE_LINK_RE.search(normalized): return True for match in TELEGRAM_LINK_RE.finditer(normalized): - if not is_url_whitelisted(match.group(1)): + url = match.group(1) + if own_username and _telegram_link_first_segment(url) == own_username: + continue + if not is_url_whitelisted(url): return True mention_count = sum( 1 for m in TELEGRAM_USERNAME_RE.finditer(normalized) if m.group(1).lower() not in WHITELISTED_TELEGRAM_PATHS + and m.group(1).lower() != own_username ) if mention_count >= 2: return True @@ -438,7 +468,7 @@ async def handle_bio_bait_spam( detection_reason = "message_bait" else: user_bio = await get_cached_user_bio(context, user.id) - if user_bio and has_suspicious_bio_links(user_bio): + if user_bio and has_suspicious_bio_links(user_bio, username=user.username): detection_reason = "bio_links" if detection_reason is None: diff --git a/tests/test_bio_bait.py b/tests/test_bio_bait.py index ef36284..ccc0b48 100644 --- a/tests/test_bio_bait.py +++ b/tests/test_bio_bait.py @@ -183,6 +183,46 @@ def test_strong_promo_hints_still_work(self): assert has_suspicious_bio_links("promo @scam_channel") is True assert has_suspicious_bio_links("join @scam_channel") is True + # --- Dynamic self-whitelist: links/@mentions pointing at the bio + # owner's own username are self-references, not promo links. --- + + def test_own_tme_link_not_suspicious(self): + assert has_suspicious_bio_links("t.me/myusername", username="myusername") is False + + def test_own_tme_link_with_scheme_not_suspicious(self): + assert has_suspicious_bio_links("https://t.me/myusername", username="myusername") is False + + def test_own_telegram_me_link_not_suspicious(self): + assert has_suspicious_bio_links("telegram.me/myusername", username="myusername") is False + + def test_own_bare_mention_not_suspicious(self): + assert has_suspicious_bio_links("@myusername", username="myusername") is False + + def test_own_username_case_insensitive(self): + assert has_suspicious_bio_links("t.me/MyUserName", username="myusername") is False + assert has_suspicious_bio_links("@MYUSERNAME", username="myusername") is False + + def test_own_link_plus_other_channel_still_suspicious(self): + bio = "t.me/myusername and t.me/somechannel" + assert has_suspicious_bio_links(bio, username="myusername") is True + + def test_own_mention_excluded_from_count(self): + # Only one foreign mention left -> not enough without a promo hint. + assert has_suspicious_bio_links("@myusername @somechannel", username="myusername") is False + # Two foreign mentions -> still suspicious. + assert has_suspicious_bio_links("@myusername @chan_one @chan_two", username="myusername") is True + + def test_own_mention_with_promo_hint_not_suspicious(self): + assert has_suspicious_bio_links("VIP @myusername", username="myusername") is False + + def test_invite_link_still_suspicious_with_username(self): + # Private invite links can never be a self-reference. + assert has_suspicious_bio_links("t.me/+exampleinvitehash", username="myusername") is True + + def test_none_username_keeps_old_behavior(self): + assert has_suspicious_bio_links("Join t.me/somerandomscamchannel", username=None) is True + assert has_suspicious_bio_links("@somerandomname", username=None) is False + class TestUserBioCache: """Tests for get_cached_user_bio / clear_cached_user_bio."""