Skip to content

Commit 5981766

Browse files
committed
fix: consistent user mention formatting with @username preference
- Update get_user_mention() to accept User | Chat objects - Add optional username param to get_user_mention_by_id() - Normalize usernames with lstrip('@') to prevent @@Prefix - Use get_user_mention(chat) in verify.py and check.py handlers for public-facing messages (clearance, warn callbacks) This ensures verification success messages show '@username' instead of just the first name when username is available. Tests: - Add 10 new tests for Chat objects and username normalization - Add explicit test for clearance message format - Update README with current stats: 404 tests, 99% coverage
1 parent 8ab9477 commit 5981766

7 files changed

Lines changed: 171 additions & 20 deletions

File tree

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,9 +150,9 @@ uv run pytest -v
150150
### Test Coverage
151151

152152
The project maintains comprehensive test coverage:
153-
- **Coverage**: 100% (1,204 statements)
154-
- **Tests**: 345 total
155-
- **Pass Rate**: 100% (345/345 passed)
153+
- **Coverage**: 99% (1,216 statements)
154+
- **Tests**: 404 total
155+
- **Pass Rate**: 100% (404/404 passed)
156156
- **All modules**: 100% coverage including JobQueue scheduler integration, captcha verification, and anti-spam enforcement
157157
- Services: `bot_info.py` (100%), `scheduler.py` (100%), `user_checker.py` (100%), `telegram_utils.py` (100%), `captcha_recovery.py` (100%)
158158
- Handlers: `anti_spam.py` (100%), `captcha.py` (100%), `check.py` (100%), `dm.py` (100%), `message.py` (100%), `topic_guard.py` (100%), `verify.py` (100%)

src/bot/handlers/check.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@
2323
MISSING_ITEMS_SEPARATOR,
2424
)
2525
from bot.database.service import get_database
26-
from bot.services.telegram_utils import extract_forwarded_user, get_user_mention_by_id
26+
from bot.services.telegram_utils import (
27+
extract_forwarded_user,
28+
get_user_mention,
29+
get_user_mention_by_id,
30+
)
2731
from bot.services.user_checker import check_user_profile
2832

2933
logger = logging.getLogger(__name__)
@@ -248,7 +252,7 @@ async def handle_warn_callback(
248252
try:
249253
# Get user info for mention
250254
chat = await context.bot.get_chat(target_user_id)
251-
user_mention = get_user_mention_by_id(target_user_id, chat.full_name or f"User {target_user_id}")
255+
user_mention = get_user_mention(chat)
252256

253257
# Send warning to group
254258
warn_message = ADMIN_WARN_USER_MESSAGE.format(

src/bot/handlers/verify.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from bot.config import Settings, get_settings
1616
from bot.constants import VERIFICATION_CLEARANCE_MESSAGE
1717
from bot.database.service import DatabaseService, get_database
18-
from bot.services.telegram_utils import get_user_mention_by_id, unrestrict_user
18+
from bot.services.telegram_utils import get_user_mention, unrestrict_user
1919

2020
logger = logging.getLogger(__name__)
2121

@@ -62,7 +62,7 @@ async def verify_user(
6262
if deleted_count > 0:
6363
# Get user info for proper mention
6464
user_info = await bot.get_chat(target_user_id)
65-
user_mention = get_user_mention_by_id(target_user_id, user_info.full_name)
65+
user_mention = get_user_mention(user_info)
6666

6767
# Send clearance message to warning topic
6868
clearance_message = VERIFICATION_CLEARANCE_MESSAGE.format(

src/bot/services/telegram_utils.py

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,47 +7,54 @@
77

88
import logging
99

10-
from telegram import Bot, Message, User
10+
from telegram import Bot, Chat, Message, User
1111
from telegram.constants import ChatMemberStatus
1212
from telegram.error import BadRequest, Forbidden
1313
from telegram.helpers import mention_markdown
1414

1515
logger = logging.getLogger(__name__)
1616

1717

18-
def get_user_mention(user: User) -> str:
18+
def get_user_mention(user: User | Chat) -> str:
1919
"""
20-
Get a formatted mention string for a user.
20+
Get a formatted mention string for a user or chat.
2121
22-
Returns `@username` if the user has a username, otherwise returns
23-
a markdown mention using the user's full name and ID.
22+
Returns `@username` if the user/chat has a username, otherwise returns
23+
a markdown mention using the full name and ID.
2424
2525
Args:
26-
user: Telegram User object.
26+
user: Telegram User or Chat object.
2727
2828
Returns:
2929
str: Formatted user mention (either @username or markdown mention).
3030
"""
3131
return (
32-
f"@{user.username}"
32+
f"@{user.username.lstrip('@')}"
3333
if user.username
3434
else mention_markdown(user.id, user.full_name, version=1)
3535
)
3636

3737

38-
def get_user_mention_by_id(user_id: int, user_full_name: str) -> str:
38+
def get_user_mention_by_id(
39+
user_id: int,
40+
user_full_name: str,
41+
username: str | None = None,
42+
) -> str:
3943
"""
40-
Get a formatted markdown mention for a user by ID and name.
44+
Get a formatted mention for a user by ID and name.
4145
4246
Used when only user ID and full name are available (not a full User object).
4347
4448
Args:
4549
user_id: Telegram user ID.
4650
user_full_name: User's full name.
51+
username: Optional username to prefer @username format.
4752
4853
Returns:
49-
str: Markdown mention string.
54+
str: Formatted mention string.
5055
"""
56+
if username:
57+
return f"@{username.lstrip('@')}"
5158
return mention_markdown(user_id, user_full_name, version=1)
5259

5360

tests/test_check.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -438,14 +438,15 @@ async def test_warn_callback_success_missing_photo_only(
438438

439439
mock_chat = MagicMock()
440440
mock_chat.full_name = "Test User"
441+
mock_chat.username = "testuser"
441442
mock_context.bot.get_chat.return_value = mock_chat
442443

443444
with patch("bot.handlers.check.get_settings", return_value=mock_settings):
444445
await handle_warn_callback(update, mock_context)
445446

446447
send_call_args = mock_context.bot.send_message.call_args
447448
assert "foto profil publik" in send_call_args.kwargs["text"]
448-
assert "username" not in send_call_args.kwargs["text"]
449+
assert "@testuser" in send_call_args.kwargs["text"]
449450

450451
async def test_warn_callback_invalid_data(self, mock_context):
451452
"""Invalid callback data shows error."""

tests/test_telegram_utils.py

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from unittest.mock import AsyncMock, MagicMock, patch
22

33
import pytest
4-
from telegram import User
4+
from telegram import Chat, User
55
from telegram.error import BadRequest, Forbidden
66

77
from bot.services.telegram_utils import (
@@ -97,6 +97,67 @@ def test_get_user_mention_long_full_name(self, mock_mention_markdown):
9797
mock_mention_markdown.assert_called_once_with(777888, "A" * 100, version=1)
9898
assert result == f"[{'A' * 100}](tg://user?id=777888)"
9999

100+
def test_get_user_mention_with_prefixed_username(self):
101+
"""Test that username with @ prefix is normalized."""
102+
user = MagicMock(spec=User)
103+
user.username = "@already_prefixed"
104+
user.id = 123456
105+
user.full_name = "Test User"
106+
107+
result = get_user_mention(user)
108+
109+
assert result == "@already_prefixed"
110+
111+
def test_get_user_mention_chat_with_username(self):
112+
"""Test getting mention for Chat object with username."""
113+
chat = MagicMock(spec=Chat)
114+
chat.username = "john_doe"
115+
chat.id = 123456
116+
chat.full_name = "John Doe"
117+
118+
result = get_user_mention(chat)
119+
120+
assert result == "@john_doe"
121+
122+
def test_get_user_mention_chat_with_prefixed_username(self):
123+
"""Test that Chat with @ prefixed username is normalized."""
124+
chat = MagicMock(spec=Chat)
125+
chat.username = "@prefixed_chat"
126+
chat.id = 123456
127+
chat.full_name = "Prefixed Chat"
128+
129+
result = get_user_mention(chat)
130+
131+
assert result == "@prefixed_chat"
132+
133+
@patch("bot.services.telegram_utils.mention_markdown")
134+
def test_get_user_mention_chat_without_username(self, mock_mention_markdown):
135+
"""Test getting mention for Chat object without username."""
136+
chat = MagicMock(spec=Chat)
137+
chat.username = None
138+
chat.id = 123456
139+
chat.full_name = "Jane Smith"
140+
mock_mention_markdown.return_value = "[Jane Smith](tg://user?id=123456)"
141+
142+
result = get_user_mention(chat)
143+
144+
mock_mention_markdown.assert_called_once_with(123456, "Jane Smith", version=1)
145+
assert result == "[Jane Smith](tg://user?id=123456)"
146+
147+
@patch("bot.services.telegram_utils.mention_markdown")
148+
def test_get_user_mention_chat_empty_username(self, mock_mention_markdown):
149+
"""Test getting mention for Chat object with empty string username."""
150+
chat = MagicMock(spec=Chat)
151+
chat.username = ""
152+
chat.id = 987654
153+
chat.full_name = "Jane Smith"
154+
mock_mention_markdown.return_value = "[Jane Smith](tg://user?id=987654)"
155+
156+
result = get_user_mention(chat)
157+
158+
mock_mention_markdown.assert_called_once_with(987654, "Jane Smith", version=1)
159+
assert result == "[Jane Smith](tg://user?id=987654)"
160+
100161

101162
class TestGetUserMentionById:
102163
@patch("bot.services.telegram_utils.mention_markdown")
@@ -160,6 +221,44 @@ def test_get_user_mention_by_id_single_character_name(self, mock_mention_markdow
160221
mock_mention_markdown.assert_called_once_with(777888, "A", version=1)
161222
assert result == "[A](tg://user?id=777888)"
162223

224+
def test_get_user_mention_by_id_with_username(self):
225+
"""Test mention by ID with username provided returns @username."""
226+
result = get_user_mention_by_id(123456, "John Doe", username="johndoe")
227+
228+
assert result == "@johndoe"
229+
230+
def test_get_user_mention_by_id_with_username_special_chars(self):
231+
"""Test mention by ID with username containing underscores."""
232+
result = get_user_mention_by_id(123456, "John Doe", username="john_doe_123")
233+
234+
assert result == "@john_doe_123"
235+
236+
def test_get_user_mention_by_id_with_prefixed_username(self):
237+
"""Test that username with @ prefix is normalized."""
238+
result = get_user_mention_by_id(123456, "Test User", username="@prefixed")
239+
240+
assert result == "@prefixed"
241+
242+
@patch("bot.services.telegram_utils.mention_markdown")
243+
def test_get_user_mention_by_id_with_none_username(self, mock_mention_markdown):
244+
"""Test mention by ID with explicit None username."""
245+
mock_mention_markdown.return_value = "[John Doe](tg://user?id=123456)"
246+
247+
result = get_user_mention_by_id(123456, "John Doe", username=None)
248+
249+
mock_mention_markdown.assert_called_once_with(123456, "John Doe", version=1)
250+
assert result == "[John Doe](tg://user?id=123456)"
251+
252+
@patch("bot.services.telegram_utils.mention_markdown")
253+
def test_get_user_mention_by_id_with_empty_username(self, mock_mention_markdown):
254+
"""Test mention by ID with empty string username falls back to markdown."""
255+
mock_mention_markdown.return_value = "[John Doe](tg://user?id=123456)"
256+
257+
result = get_user_mention_by_id(123456, "John Doe", username="")
258+
259+
mock_mention_markdown.assert_called_once_with(123456, "John Doe", version=1)
260+
assert result == "[John Doe](tg://user?id=123456)"
261+
163262

164263
class TestUnrestrictUser:
165264
async def test_unrestrict_user_basic(self, mock_bot):

tests/test_verify_handler.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ def mock_context():
5656
mock_permissions.can_pin_messages = False
5757
mock_chat.permissions = mock_permissions
5858
mock_chat.full_name = "Test User"
59+
mock_chat.username = "testuser"
5960
context.bot.get_chat.return_value = mock_chat
6061

6162
context.bot_data = {"admin_ids": [12345]}
@@ -285,6 +286,45 @@ class MockSettings:
285286
new_warning = db.get_or_create_user_warning(target_user_id, MockSettings.group_id)
286287
assert new_warning.message_count == 1 # Fresh start
287288

289+
async def test_verify_sends_clearance_message_when_warnings_deleted(
290+
self, mock_update, mock_context, temp_db, monkeypatch
291+
):
292+
"""Test that clearance message is sent to warning topic when user with warnings is verified."""
293+
class MockSettings:
294+
group_id = -1001234567890
295+
warning_topic_id = 12345
296+
telegram_bot_token = "fake_token"
297+
298+
monkeypatch.setattr("bot.handlers.verify.get_settings", lambda: MockSettings())
299+
300+
target_user_id = 77777777
301+
db = get_database()
302+
303+
# Seed a warning for the target user
304+
db.get_or_create_user_warning(target_user_id, MockSettings.group_id)
305+
db.increment_message_count(target_user_id, MockSettings.group_id)
306+
307+
# Mock get_chat to return a user with username
308+
mock_user_chat = MagicMock()
309+
mock_user_chat.username = "verified_user"
310+
mock_user_chat.full_name = "Verified User"
311+
312+
# First call is for group permissions, second call is for user info
313+
mock_group_chat = MagicMock()
314+
mock_group_chat.permissions = MagicMock()
315+
mock_context.bot.get_chat.side_effect = [mock_group_chat, mock_user_chat]
316+
317+
mock_context.args = [str(target_user_id)]
318+
await handle_verify_command(mock_update, mock_context)
319+
320+
# Verify send_message was called with correct clearance message
321+
mock_context.bot.send_message.assert_called_once()
322+
call_kwargs = mock_context.bot.send_message.call_args.kwargs
323+
assert call_kwargs["chat_id"] == MockSettings.group_id
324+
assert call_kwargs["message_thread_id"] == MockSettings.warning_topic_id
325+
assert "@verified_user" in call_kwargs["text"]
326+
assert call_kwargs["parse_mode"] == "Markdown"
327+
288328
async def test_verify_handles_non_restricted_user_gracefully(
289329
self, mock_update, mock_context, temp_db, monkeypatch
290330
):
@@ -348,7 +388,7 @@ class MockSettings:
348388
assert call_args.kwargs["message_thread_id"] == MockSettings.warning_topic_id
349389
assert call_args.kwargs["parse_mode"] == "Markdown"
350390
# Check the message contains user mention
351-
assert "Test User" in call_args.kwargs["text"] or str(target_user_id) in call_args.kwargs["text"]
391+
assert "@testuser" in call_args.kwargs["text"]
352392

353393
async def test_verify_without_warnings_no_notification(
354394
self, mock_update, mock_context, temp_db, monkeypatch

0 commit comments

Comments
 (0)