Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 33 additions & 3 deletions src/bot/handlers/bio_bait.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import re
import unicodedata
from time import monotonic
from urllib.parse import urlparse

from aiogram.types import Update

Expand Down Expand Up @@ -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.

Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
40 changes: 40 additions & 0 deletions tests/test_bio_bait.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
Loading