88from telegram .ext import CommandHandler , ContextTypes , MessageHandler , filters
99
1010from .. import strings
11+ from ..db .base import AsyncRepository
1112from ..services .moderation import ModerationService
1213
1314logger = logging .getLogger (__name__ )
1415
1516
17+ async def _track_user (repo : AsyncRepository , user : object ) -> None :
18+ """Track a Telegram user in the known_users table."""
19+ await repo .upsert_known_user (
20+ user_id = user .id , # type: ignore[attr-defined]
21+ username = getattr (user , "username" , None ),
22+ first_name = getattr (user , "first_name" , None ),
23+ last_name = getattr (user , "last_name" , None ),
24+ )
25+
26+
1627async def _is_admin (
1728 context : ContextTypes .DEFAULT_TYPE , chat_id : int , user_id : int
1829) -> bool :
@@ -68,8 +79,9 @@ async def _handle_force_group_registration(
6879
6980
7081async def _handle_ban (update : Update , context : ContextTypes .DEFAULT_TYPE ) -> None :
71- """Ban a user globally. Usage: /ban user_id [reason] or reply to message with /ban [reason]"""
82+ """Ban a user globally. Usage: /ban user_id|@username [reason] or reply with /ban [reason]. """
7283 moderation_service : ModerationService = context .bot_data ["moderation_service" ]
84+ repository : AsyncRepository = context .bot_data ["repository" ]
7385 message = update .message
7486 if message is None or message .from_user is None :
7587 return
@@ -78,6 +90,9 @@ async def _handle_ban(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non
7890 if chat is None or chat .type == "private" :
7991 return
8092
93+ # Track the invoking admin
94+ await _track_user (repository , message .from_user )
95+
8196 if not await _is_admin (context , chat .id , message .from_user .id ):
8297 await message .reply_text (strings .ONLY_ADMINS )
8398 return
@@ -86,13 +101,22 @@ async def _handle_ban(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non
86101
87102 user_id : int | None = None
88103 reason : str | None = None
89- if message .reply_to_message and message .reply_to_message .from_user :
90- user_id = message .reply_to_message .from_user .id
104+ if message .reply_to_message :
105+ reply = message .reply_to_message
106+ if reply .from_user and not reply .from_user .is_bot :
107+ # Replying to a regular user's message
108+ user_id = reply .from_user .id
109+ else :
110+ # Replying to a bot message — check welcome_message_map
111+ welcome_map : dict [tuple [int , int ], int ] = context .bot_data .get (
112+ "welcome_message_map" , {}
113+ )
114+ user_id = welcome_map .get ((chat .id , reply .message_id ))
91115 reason = " " .join (args ) if args else None
92116 elif args :
93117 target = args [0 ]
94118 reason = args [1 ] if len (args ) > 1 else None
95- user_id = await _resolve_user_id (context , chat .id , target )
119+ user_id = await _resolve_user_id (context , chat .id , target , moderation_service )
96120
97121 if user_id is None :
98122 await message .reply_text (strings .BAN_USAGE )
@@ -114,10 +138,21 @@ async def _handle_ban(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non
114138
115139 await message .reply_text (strings .ban_success (success_count , fail_count , reason ))
116140
141+ # Notify admins of the ban
142+ await _notify_admins_of_ban (
143+ context = context ,
144+ chat = chat ,
145+ admin = message .from_user ,
146+ banned_user_id = user_id ,
147+ success_count = success_count ,
148+ reason = reason ,
149+ )
150+
117151
118152async def _handle_unban (update : Update , context : ContextTypes .DEFAULT_TYPE ) -> None :
119- """Unban a user globally. Usage: /unban user_id or reply to message with /unban"""
153+ """Unban a user globally. Usage: /unban user_id or reply to message with /unban. """
120154 moderation_service : ModerationService = context .bot_data ["moderation_service" ]
155+ repository : AsyncRepository = context .bot_data ["repository" ]
121156 message = update .message
122157 if message is None or message .from_user is None :
123158 return
@@ -126,6 +161,9 @@ async def _handle_unban(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
126161 if chat is None or chat .type == "private" :
127162 return
128163
164+ # Track the invoking admin
165+ await _track_user (repository , message .from_user )
166+
129167 if not await _is_admin (context , chat .id , message .from_user .id ):
130168 await message .reply_text (strings .ONLY_ADMINS )
131169 return
@@ -135,7 +173,7 @@ async def _handle_unban(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
135173 if message .reply_to_message and message .reply_to_message .from_user :
136174 user_id = message .reply_to_message .from_user .id
137175 elif args :
138- user_id = await _resolve_user_id (context , chat .id , args [0 ])
176+ user_id = await _resolve_user_id (context , chat .id , args [0 ], moderation_service )
139177
140178 if user_id is None :
141179 await message .reply_text (strings .UNBAN_USAGE )
@@ -157,8 +195,9 @@ async def _handle_unban(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
157195
158196
159197async def _handle_mute (update : Update , context : ContextTypes .DEFAULT_TYPE ) -> None :
160- """Mute a user. Usage: /mute @username [duration_minutes] [reason]"""
198+ """Mute a user. Usage: /mute @username [duration_minutes] [reason]. """
161199 moderation_service : ModerationService = context .bot_data ["moderation_service" ]
200+ repository : AsyncRepository = context .bot_data ["repository" ]
162201 message = update .message
163202 if message is None or message .from_user is None :
164203 return
@@ -167,6 +206,9 @@ async def _handle_mute(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
167206 if chat is None or chat .type == "private" :
168207 return
169208
209+ # Track the invoking admin
210+ await _track_user (repository , message .from_user )
211+
170212 if not await _is_admin (context , chat .id , message .from_user .id ):
171213 await message .reply_text (strings .ONLY_ADMINS )
172214 return
@@ -191,7 +233,7 @@ async def _handle_mute(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
191233 reason = args [2 ] if len (args ) > 2 else None
192234 else :
193235 reason = args [1 ] if len (args ) > 1 else None
194- user_id = await _resolve_user_id (context , chat .id , target )
236+ user_id = await _resolve_user_id (context , chat .id , target , moderation_service )
195237
196238 if user_id is None :
197239 await message .reply_text (strings .MUTE_USAGE )
@@ -226,8 +268,9 @@ async def _handle_mute(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
226268
227269
228270async def _handle_unmute (update : Update , context : ContextTypes .DEFAULT_TYPE ) -> None :
229- """Unmute a user. Usage: /unmute @username"""
271+ """Unmute a user. Usage: /unmute @username. """
230272 moderation_service : ModerationService = context .bot_data ["moderation_service" ]
273+ repository : AsyncRepository = context .bot_data ["repository" ]
231274 message = update .message
232275 if message is None or message .from_user is None :
233276 return
@@ -236,6 +279,9 @@ async def _handle_unmute(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
236279 if chat is None or chat .type == "private" :
237280 return
238281
282+ # Track the invoking admin
283+ await _track_user (repository , message .from_user )
284+
239285 if not await _is_admin (context , chat .id , message .from_user .id ):
240286 await message .reply_text (strings .ONLY_ADMINS )
241287 return
@@ -245,7 +291,7 @@ async def _handle_unmute(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
245291 if message .reply_to_message and message .reply_to_message .from_user :
246292 user_id = message .reply_to_message .from_user .id
247293 elif args :
248- user_id = await _resolve_user_id (context , chat .id , args [0 ])
294+ user_id = await _resolve_user_id (context , chat .id , args [0 ], moderation_service )
249295
250296 if user_id is None :
251297 await message .reply_text (strings .UNMUTE_USAGE )
@@ -281,8 +327,9 @@ async def _handle_unmute(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
281327
282328
283329async def _handle_report (update : Update , context : ContextTypes .DEFAULT_TYPE ) -> None :
284- """Report a message or user. Usage: /report [reason] or reply to message with /report [reason]"""
330+ """Report a message or user. Usage: /report [reason] or reply to message with /report [reason]. """
285331 moderation_service : ModerationService = context .bot_data ["moderation_service" ]
332+ repository : AsyncRepository = context .bot_data ["repository" ]
286333 message = update .message
287334 if message is None or message .from_user is None :
288335 return
@@ -291,6 +338,9 @@ async def _handle_report(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
291338 if chat is None or chat .type == "private" :
292339 return
293340
341+ # Track the reporter
342+ await _track_user (repository , message .from_user )
343+
294344 args = message .text .split (maxsplit = 1 )[1 :] if message .text else []
295345 reason = args [0 ] if args else None
296346
@@ -337,6 +387,7 @@ async def _handle_admin_mention(
337387 update : Update , context : ContextTypes .DEFAULT_TYPE
338388) -> None :
339389 """Handle @admin mention: notify admins of intervention request (no reply needed)."""
390+ repository : AsyncRepository = context .bot_data ["repository" ]
340391 message = update .message
341392 if message is None or message .from_user is None :
342393 return
@@ -345,6 +396,9 @@ async def _handle_admin_mention(
345396 if chat is None or chat .type == "private" :
346397 return
347398
399+ # Track the user
400+ await _track_user (repository , message .from_user )
401+
348402 text = message .text or message .caption or ""
349403 reason = _extract_reason_after_admin (text )
350404
@@ -473,32 +527,103 @@ async def _notify_admins_of_report(
473527 text = report_text ,
474528 parse_mode = "HTML" ,
475529 )
530+ except Exception as e :
531+ logger .debug ("Could not send report to admin %s: %s" , admin .user .id , e )
532+
533+
534+ async def _notify_admins_of_ban (
535+ context : ContextTypes .DEFAULT_TYPE ,
536+ chat ,
537+ admin ,
538+ banned_user_id : int ,
539+ success_count : int ,
540+ reason : str | None ,
541+ ) -> None :
542+ """Send ban notification to all chat admins via private message."""
543+ try :
544+ chat_admins = await context .bot .get_chat_administrators (chat .id )
545+ except Exception as e :
546+ logger .warning ("Failed to get admins for ban notification: %s" , e )
547+ return
548+
549+ chat_title = chat .title or "Chat"
550+ admin_name = _get_user_display_name (admin )
551+
552+ # Try to get banned user display name from known_users
553+ banned_name = str (banned_user_id )
554+ repository : AsyncRepository | None = context .bot_data .get ("repository" )
555+ if repository is not None :
556+ known_user = await repository .get_known_user (banned_user_id )
557+ if known_user is not None :
558+ if known_user .first_name :
559+ banned_name = known_user .first_name
560+ if known_user .last_name :
561+ banned_name += f" { known_user .last_name } "
562+ elif known_user .username :
563+ banned_name = f"@{ known_user .username } "
564+
565+ notification = strings .ban_notification (
566+ chat_title = chat_title ,
567+ banned_name = banned_name ,
568+ banned_id = banned_user_id ,
569+ admin_name = admin_name ,
570+ admin_id = admin .id ,
571+ success_count = success_count ,
572+ reason = reason ,
573+ )
574+
575+ for member in chat_admins :
576+ if member .user .is_bot :
577+ continue
578+ # Don't notify the admin who performed the ban
579+ if member .user .id == admin .id :
580+ continue
581+ try :
582+ await context .bot .send_message (
583+ chat_id = member .user .id ,
584+ text = notification ,
585+ parse_mode = "HTML" ,
586+ )
476587 except Exception as e :
477588 logger .debug (
478- "Could not send report to admin %s: %s" , admin .user .id , e
589+ "Could not send ban notification to admin %s: %s" ,
590+ member .user .id ,
591+ e ,
479592 )
480593
481594
482595async def _resolve_user_id (
483596 context : ContextTypes .DEFAULT_TYPE ,
484597 chat_id : int ,
485598 target : str ,
599+ moderation_service : ModerationService | None = None ,
486600) -> int | None :
487- """Resolve @username or user_id to numeric user_id. @username only works for admins."""
601+ """Resolve @username or user_id to numeric user_id.
602+
603+ Resolution order for @username:
604+ 1. Check chat administrators (works for admins only).
605+ 2. Fall back to known_users table (any user the bot has seen).
606+ """
488607 target = target .strip ()
489608 if target .startswith ("@" ):
609+ username_lower = target .lstrip ("@" ).lower ()
610+ # Try admins first
490611 try :
491612 admins = await context .bot .get_chat_administrators (chat_id )
492- username_lower = target .lstrip ("@" ).lower ()
493613 for admin in admins :
494614 if (
495615 admin .user .username
496616 and admin .user .username .lower () == username_lower
497617 ):
498618 return admin .user .id
499- return None
500619 except Exception :
501- return None
620+ pass
621+ # Fall back to known_users table
622+ if moderation_service is not None :
623+ known = await moderation_service .get_known_user_by_username (username_lower )
624+ if known is not None :
625+ return known .user_id
626+ return None
502627 if re .match (r"^-?\d+$" , target ):
503628 return int (target )
504629 return None
0 commit comments