Skip to content

Commit 5f492c9

Browse files
committed
fix: /warn ID/username priority over reply + forum topic anchor skip
Fix two bugs in the /warn command: 1. ID mode warned the replied-to user instead of the specified user ID. In forum-topic groups, Telegram auto-sets reply_to_message to the topic anchor message, so reply mode always won over ID mode even when a user ID was explicitly provided. Now args are checked first: numeric → ID mode, @username → username mode, then reply fallback. 2. Forum topic anchor messages (forum_topic_created set) are now detected via _is_real_reply() and skipped, so they are never mistaken for a real user reply. Also adds @username support as a third invocation mode: /warn @username [reason] Since the Bot API cannot resolve usernames to user IDs, this mode mentions @username directly without membership/bot/self checks. Updated usage message to mention @username option.
1 parent 7ca5358 commit 5f492c9

3 files changed

Lines changed: 189 additions & 44 deletions

File tree

src/bot/constants.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -234,8 +234,8 @@ def format_hours_display(hours: int) -> str:
234234

235235
# Generic /warn command templates (admin-issued, in-group)
236236
WARN_COMMAND_USAGE = (
237-
"❌ Penggunaan: balas pesan member dengan /warn [alasan] "
238-
"atau gunakan /warn USER_ID [alasan]"
237+
"❌ Penggunaan: balas pesan member dengan /warn [alasan], "
238+
"/warn USER_ID [alasan], atau /warn @username [alasan]"
239239
)
240240
WARN_COMMAND_NOT_FOUND = "❌ Tidak dapat menemukan user dengan ID {user_id}."
241241
WARN_COMMAND_NOT_MEMBER = "❌ User {user_id} bukan member grup ini."

src/bot/handlers/warn.py

Lines changed: 62 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@
22
Admin /warn command handler for the PythonID bot.
33
44
Lets an admin make the bot send a generic warning to a group member.
5-
Two invocation modes:
5+
Three invocation modes:
66
7-
1. Reply mode: admin replies to the member's message with ``/warn [reason]``
8-
2. ID mode: admin sends ``/warn USER_ID [reason]`` in the group
7+
1. Reply mode: admin replies to the member's message with ``/warn [reason]``
8+
2. ID mode: admin sends ``/warn USER_ID [reason]`` in the group
9+
3. Username mode: admin sends ``/warn @username [reason]`` in the group
10+
11+
In forum-topic groups, Telegram auto-sets ``reply_to_message`` to the topic
12+
anchor message. To avoid mistaking the anchor for a real reply, we check
13+
``forum_topic_created`` and skip it.
914
1015
The warning is sent to the moderation topic when ``moderation_topic_id`` is
1116
configured (per-group), otherwise to the main group chat.
@@ -44,18 +49,29 @@ async def _delete_command_message(update: Update) -> None:
4449
)
4550

4651

52+
def _is_real_reply(message: object) -> bool:
53+
"""Check if reply_to_message is a real user reply, not a forum topic anchor."""
54+
reply = getattr(message, "reply_to_message", None)
55+
if reply is None:
56+
return False
57+
if getattr(reply, "forum_topic_created", None) is not None:
58+
return False
59+
if getattr(reply, "from_user", None) is None:
60+
return False
61+
return True
62+
63+
4764
async def handle_warn_command(
4865
update: Update, context: ContextTypes.DEFAULT_TYPE
4966
) -> None:
5067
"""
5168
Handle /warn command in a monitored group.
5269
53-
Admin replies to a member's message with ``/warn [reason]``, or
54-
provides a user ID: ``/warn USER_ID [reason]``.
55-
56-
Sends a warning message mentioning the target member. The admin's
57-
command message is deleted before any network lookups to protect
58-
their identity.
70+
Resolution priority:
71+
1. ``args[0]`` is numeric → ID mode (get_chat_member, membership check)
72+
2. ``args[0]`` starts with @ → username mode (mention only, no membership check)
73+
3. Real reply_to_message → reply mode
74+
4. None of the above → usage error
5975
"""
6076
if not update.message or not update.message.from_user:
6177
return
@@ -67,32 +83,23 @@ async def handle_warn_command(
6783
if group_config is None:
6884
return
6985

70-
# Per-group admin check (not global union)
7186
if not is_user_admin_in_group(context, group_config.group_id, admin.id):
7287
return
7388

74-
# Delete command message early to protect admin identity on all paths
7589
await _delete_command_message(update)
7690

77-
# Resolve target user and reason
78-
reply_user = (
79-
message.reply_to_message.from_user
80-
if message.reply_to_message
81-
else None
82-
)
91+
args = context.args or []
92+
has_real_reply = _is_real_reply(message)
8393

84-
if reply_user is not None:
85-
target_user = reply_user
86-
reason = " ".join(context.args) if context.args else ""
87-
elif context.args:
88-
try:
89-
target_user_id = int(context.args[0])
90-
except ValueError:
91-
try:
92-
await message.reply_text(WARN_COMMAND_USAGE, do_quote=False)
93-
except Exception:
94-
logger.error("Failed to send usage message", exc_info=True)
95-
return
94+
# --- Determine target and reason ---
95+
target_user: object | None = None
96+
target_username: str | None = None
97+
reason = ""
98+
99+
if args and args[0].lstrip("-").isdigit():
100+
# --- ID mode ---
101+
target_user_id = int(args[0])
102+
reason = " ".join(args[1:]) if len(args) > 1 else ""
96103
try:
97104
member = await context.bot.get_chat_member(
98105
chat_id=group_config.group_id,
@@ -123,24 +130,37 @@ async def handle_warn_command(
123130
target_user = member.user
124131
if target_user is None:
125132
return
126-
reason = " ".join(context.args[1:]) if len(context.args) > 1 else ""
133+
elif args and args[0].startswith("@"):
134+
# --- Username mode ---
135+
target_username = args[0].lstrip("@")
136+
reason = " ".join(args[1:]) if len(args) > 1 else ""
137+
elif has_real_reply:
138+
# --- Reply mode ---
139+
target_user = message.reply_to_message.from_user # type: ignore[union-attr]
140+
reason = " ".join(args) if args else ""
127141
else:
128142
try:
129143
await message.reply_text(WARN_COMMAND_USAGE, do_quote=False)
130144
except Exception:
131145
logger.error("Failed to send usage message", exc_info=True)
132146
return
133147

134-
if target_user.is_bot:
135-
return
136-
137-
if target_user.id == admin.id:
148+
# --- Build warning text ---
149+
if target_user is not None:
150+
if target_user.is_bot:
151+
return
152+
if target_user.id == admin.id:
153+
return
154+
user_mention = get_user_mention_by_id(
155+
target_user.id,
156+
target_user.full_name,
157+
getattr(target_user, "username", None),
158+
)
159+
elif target_username is not None:
160+
user_mention = f"@{escape_markdown(target_username, version=1)}"
161+
else:
138162
return
139163

140-
user_mention = get_user_mention_by_id(
141-
target_user.id, target_user.full_name, getattr(target_user, "username", None)
142-
)
143-
144164
if reason:
145165
warn_text = WARN_COMMAND_WITH_REASON.format(
146166
user_mention=user_mention,
@@ -160,11 +180,13 @@ async def handle_warn_command(
160180
await context.bot.send_message(**send_kwargs)
161181
except Exception:
162182
logger.error(
163-
f"Failed to send warning to group {group_config.group_id} for user {target_user.id}",
183+
f"Failed to send warning to group {group_config.group_id}",
164184
exc_info=True,
165185
)
166186
return
167187

168188
logger.info(
169-
f"Admin {admin.id} warned user {target_user.id} in group {group_config.group_id}"
189+
f"Admin {admin.id} warned "
190+
f"{'@' + target_username if target_username else target_user.id} " # type: ignore[union-attr]
191+
f"in group {group_config.group_id}"
170192
)

tests/test_warn.py

Lines changed: 125 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ def mock_update():
3737
update.message.chat_id = -1001234567890
3838
update.message.message_id = 999
3939
update.message.reply_to_message = None
40+
update.message.forum_topic_created = None
4041
update.effective_chat = MagicMock()
4142
update.effective_chat.id = -1001234567890
4243
update.effective_chat.type = "supergroup"
@@ -69,6 +70,7 @@ def _make_target_user(user_id=67890, full_name="Bad Member", username="badmember
6970
def _make_reply_message(target_user):
7071
msg = MagicMock()
7172
msg.from_user = target_user
73+
msg.forum_topic_created = None
7274
return msg
7375

7476

@@ -322,11 +324,12 @@ async def test_delete_failure_does_not_break(
322324
async def test_reply_without_from_user_falls_to_id_mode(
323325
self, mock_update, mock_context, mock_registry
324326
):
325-
"""Reply to a channel message (no from_user) falls through to ID mode."""
327+
"""Reply to a channel message (no from_user) with ID arg uses ID mode."""
326328
target = _make_target_user()
327329
mock_context.bot.get_chat_member.return_value = _make_chat_member(target)
328330
mock_update.message.reply_to_message = MagicMock()
329331
mock_update.message.reply_to_message.from_user = None
332+
mock_update.message.reply_to_message.forum_topic_created = None
330333
mock_context.args = ["67890", "spam"]
331334

332335
with patch("bot.handlers.warn.get_group_config_for_update", return_value=mock_registry.get(-1001234567890)):
@@ -338,7 +341,7 @@ async def test_reply_without_from_user_falls_to_id_mode(
338341
mock_context.bot.send_message.assert_called_once()
339342
call_kwargs = mock_context.bot.send_message.call_args.kwargs
340343
assert "spam" in call_kwargs["text"]
341-
assert "67890" not in call_kwargs["text"] or "badmember" in call_kwargs["text"]
344+
assert "badmember" in call_kwargs["text"]
342345

343346
async def test_reason_with_markdown_is_escaped(
344347
self, mock_update, mock_context, mock_registry
@@ -464,3 +467,123 @@ async def mock_get_member(**kwargs):
464467
await handle_warn_command(mock_update, mock_context)
465468

466469
assert call_order[0] == "delete"
470+
471+
async def test_id_mode_takes_priority_over_reply(
472+
self, mock_update, mock_context, mock_registry
473+
):
474+
"""Bug fix: /warn USER_ID reason as a reply warns the ID target, not the replied-to user."""
475+
reply_target = _make_target_user(user_id=11111, full_name="Replied User", username="replieduser")
476+
id_target = _make_target_user(user_id=67890, full_name="ID Target", username="idtarget")
477+
mock_update.message.reply_to_message = _make_reply_message(reply_target)
478+
mock_context.bot.get_chat_member.return_value = _make_chat_member(id_target)
479+
mock_context.args = ["67890", "spamming"]
480+
481+
with patch("bot.handlers.warn.get_group_config_for_update", return_value=mock_registry.get(-1001234567890)):
482+
await handle_warn_command(mock_update, mock_context)
483+
484+
mock_context.bot.get_chat_member.assert_called_once_with(
485+
chat_id=-1001234567890, user_id=67890
486+
)
487+
mock_context.bot.send_message.assert_called_once()
488+
call_kwargs = mock_context.bot.send_message.call_args.kwargs
489+
assert "idtarget" in call_kwargs["text"]
490+
assert "replieduser" not in call_kwargs["text"]
491+
assert "spamming" in call_kwargs["text"]
492+
493+
async def test_forum_topic_anchor_not_treated_as_reply(
494+
self, mock_update, mock_context, mock_registry
495+
):
496+
"""Bug fix: forum topic anchor (auto reply_to_message) is not treated as a real reply."""
497+
target = _make_target_user()
498+
mock_context.bot.get_chat_member.return_value = _make_chat_member(target)
499+
# Simulate a forum topic anchor: reply_to_message exists with forum_topic_created set
500+
mock_update.message.reply_to_message = MagicMock()
501+
mock_update.message.reply_to_message.from_user = _make_target_user(user_id=11111, full_name="Topic Creator")
502+
mock_update.message.reply_to_message.forum_topic_created = MagicMock()
503+
mock_context.args = ["67890", "reason"]
504+
505+
with patch("bot.handlers.warn.get_group_config_for_update", return_value=mock_registry.get(-1001234567890)):
506+
await handle_warn_command(mock_update, mock_context)
507+
508+
mock_context.bot.get_chat_member.assert_called_once_with(
509+
chat_id=-1001234567890, user_id=67890
510+
)
511+
mock_context.bot.send_message.assert_called_once()
512+
call_kwargs = mock_context.bot.send_message.call_args.kwargs
513+
assert "badmember" in call_kwargs["text"]
514+
assert "11111" not in call_kwargs["text"]
515+
516+
async def test_forum_topic_anchor_no_args_shows_usage(
517+
self, mock_update, mock_context, mock_registry
518+
):
519+
"""Forum topic anchor with no args shows usage (not treated as reply)."""
520+
mock_update.message.reply_to_message = MagicMock()
521+
mock_update.message.reply_to_message.from_user = _make_target_user(user_id=11111)
522+
mock_update.message.reply_to_message.forum_topic_created = MagicMock()
523+
mock_context.args = []
524+
525+
with patch("bot.handlers.warn.get_group_config_for_update", return_value=mock_registry.get(-1001234567890)):
526+
await handle_warn_command(mock_update, mock_context)
527+
528+
mock_update.message.reply_text.assert_called_once()
529+
assert "Penggunaan" in mock_update.message.reply_text.call_args.args[0]
530+
mock_context.bot.send_message.assert_not_called()
531+
532+
async def test_username_mode_with_reason(
533+
self, mock_update, mock_context, mock_registry
534+
):
535+
"""Admin uses /warn @username <reason>."""
536+
mock_context.args = ["@badmember", "stop", "spamming"]
537+
538+
with patch("bot.handlers.warn.get_group_config_for_update", return_value=mock_registry.get(-1001234567890)):
539+
await handle_warn_command(mock_update, mock_context)
540+
541+
mock_context.bot.get_chat_member.assert_not_called()
542+
mock_context.bot.send_message.assert_called_once()
543+
call_kwargs = mock_context.bot.send_message.call_args.kwargs
544+
assert "@badmember" in call_kwargs["text"]
545+
assert "stop spamming" in call_kwargs["text"]
546+
547+
async def test_username_mode_without_reason(
548+
self, mock_update, mock_context, mock_registry
549+
):
550+
"""Admin uses /warn @username with no reason."""
551+
mock_context.args = ["@badmember"]
552+
553+
with patch("bot.handlers.warn.get_group_config_for_update", return_value=mock_registry.get(-1001234567890)):
554+
await handle_warn_command(mock_update, mock_context)
555+
556+
mock_context.bot.get_chat_member.assert_not_called()
557+
mock_context.bot.send_message.assert_called_once()
558+
call_kwargs = mock_context.bot.send_message.call_args.kwargs
559+
assert "@badmember" in call_kwargs["text"]
560+
assert "patuhi aturan grup" in call_kwargs["text"]
561+
562+
async def test_username_mode_takes_priority_over_reply(
563+
self, mock_update, mock_context, mock_registry
564+
):
565+
"""@username takes priority over a real reply."""
566+
reply_target = _make_target_user(user_id=11111, full_name="Replied User", username="replieduser")
567+
mock_update.message.reply_to_message = _make_reply_message(reply_target)
568+
mock_context.args = ["@badmember", "reason"]
569+
570+
with patch("bot.handlers.warn.get_group_config_for_update", return_value=mock_registry.get(-1001234567890)):
571+
await handle_warn_command(mock_update, mock_context)
572+
573+
mock_context.bot.get_chat_member.assert_not_called()
574+
mock_context.bot.send_message.assert_called_once()
575+
call_kwargs = mock_context.bot.send_message.call_args.kwargs
576+
assert "@badmember" in call_kwargs["text"]
577+
assert "replieduser" not in call_kwargs["text"]
578+
assert "reason" in call_kwargs["text"]
579+
580+
async def test_username_mode_skips_bot_and_self_checks(
581+
self, mock_update, mock_context, mock_registry
582+
):
583+
"""Username mode cannot check is_bot/self since we don't have the User object."""
584+
mock_context.args = ["@somebot"]
585+
586+
with patch("bot.handlers.warn.get_group_config_for_update", return_value=mock_registry.get(-1001234567890)):
587+
await handle_warn_command(mock_update, mock_context)
588+
589+
mock_context.bot.send_message.assert_called_once()

0 commit comments

Comments
 (0)