Skip to content

Commit 230381f

Browse files
author
0xMett
committed
feat(moderation): ban by welcome reply, ban by @handle, and ban admin notifications
Ban by replying to bot welcome messages using welcome_message_map. Resolve @usernames via known_users table as fallback after admin lookup. Notify all group admins via DM when a ban is issued. Track users in known_users on all moderation handler interactions.
1 parent b71731b commit 230381f

1 file changed

Lines changed: 141 additions & 16 deletions

File tree

src/python_italy_bot/handlers/moderation.py

Lines changed: 141 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,22 @@
88
from telegram.ext import CommandHandler, ContextTypes, MessageHandler, filters
99

1010
from .. import strings
11+
from ..db.base import AsyncRepository
1112
from ..services.moderation import ModerationService
1213

1314
logger = 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+
1627
async 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

7081
async 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

118152
async 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

159197
async 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

228270
async 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

283329
async 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

482595
async 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

Comments
 (0)