Skip to content

Commit b09e7ac

Browse files
CopilotMattiaFailla
andcommitted
feat(handlers): implement user tracking middleware for all handler interactions
Co-authored-by: MattiaFailla <11872425+MattiaFailla@users.noreply.github.com>
1 parent eea0cd9 commit b09e7ac

4 files changed

Lines changed: 49 additions & 54 deletions

File tree

src/python_italy_bot/handlers/moderation.py

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,6 @@
1414
logger = logging.getLogger(__name__)
1515

1616

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-
2717
async def _is_admin(
2818
context: ContextTypes.DEFAULT_TYPE, chat_id: int, user_id: int
2919
) -> bool:
@@ -81,7 +71,6 @@ async def _handle_force_group_registration(
8171
async def _handle_ban(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
8272
"""Ban a user globally. Usage: /ban user_id|@username [reason] or reply with /ban [reason]."""
8373
moderation_service: ModerationService = context.bot_data["moderation_service"]
84-
repository: AsyncRepository = context.bot_data["repository"]
8574
message = update.message
8675
if message is None or message.from_user is None:
8776
return
@@ -90,9 +79,6 @@ async def _handle_ban(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non
9079
if chat is None or chat.type == "private":
9180
return
9281

93-
# Track the invoking admin
94-
await _track_user(repository, message.from_user)
95-
9682
if not await _is_admin(context, chat.id, message.from_user.id):
9783
await message.reply_text(strings.ONLY_ADMINS)
9884
return
@@ -152,7 +138,6 @@ async def _handle_ban(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non
152138
async def _handle_unban(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
153139
"""Unban a user globally. Usage: /unban user_id or reply to message with /unban."""
154140
moderation_service: ModerationService = context.bot_data["moderation_service"]
155-
repository: AsyncRepository = context.bot_data["repository"]
156141
message = update.message
157142
if message is None or message.from_user is None:
158143
return
@@ -161,9 +146,6 @@ async def _handle_unban(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
161146
if chat is None or chat.type == "private":
162147
return
163148

164-
# Track the invoking admin
165-
await _track_user(repository, message.from_user)
166-
167149
if not await _is_admin(context, chat.id, message.from_user.id):
168150
await message.reply_text(strings.ONLY_ADMINS)
169151
return
@@ -197,7 +179,6 @@ async def _handle_unban(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
197179
async def _handle_mute(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
198180
"""Mute a user. Usage: /mute @username [duration_minutes] [reason]."""
199181
moderation_service: ModerationService = context.bot_data["moderation_service"]
200-
repository: AsyncRepository = context.bot_data["repository"]
201182
message = update.message
202183
if message is None or message.from_user is None:
203184
return
@@ -206,9 +187,6 @@ async def _handle_mute(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
206187
if chat is None or chat.type == "private":
207188
return
208189

209-
# Track the invoking admin
210-
await _track_user(repository, message.from_user)
211-
212190
if not await _is_admin(context, chat.id, message.from_user.id):
213191
await message.reply_text(strings.ONLY_ADMINS)
214192
return
@@ -270,7 +248,6 @@ async def _handle_mute(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
270248
async def _handle_unmute(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
271249
"""Unmute a user. Usage: /unmute @username."""
272250
moderation_service: ModerationService = context.bot_data["moderation_service"]
273-
repository: AsyncRepository = context.bot_data["repository"]
274251
message = update.message
275252
if message is None or message.from_user is None:
276253
return
@@ -279,9 +256,6 @@ async def _handle_unmute(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
279256
if chat is None or chat.type == "private":
280257
return
281258

282-
# Track the invoking admin
283-
await _track_user(repository, message.from_user)
284-
285259
if not await _is_admin(context, chat.id, message.from_user.id):
286260
await message.reply_text(strings.ONLY_ADMINS)
287261
return
@@ -329,7 +303,6 @@ async def _handle_unmute(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
329303
async def _handle_report(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
330304
"""Report a message or user. Usage: /report [reason] or reply to message with /report [reason]."""
331305
moderation_service: ModerationService = context.bot_data["moderation_service"]
332-
repository: AsyncRepository = context.bot_data["repository"]
333306
message = update.message
334307
if message is None or message.from_user is None:
335308
return
@@ -338,9 +311,6 @@ async def _handle_report(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
338311
if chat is None or chat.type == "private":
339312
return
340313

341-
# Track the reporter
342-
await _track_user(repository, message.from_user)
343-
344314
args = message.text.split(maxsplit=1)[1:] if message.text else []
345315
reason = args[0] if args else None
346316

@@ -387,7 +357,6 @@ async def _handle_admin_mention(
387357
update: Update, context: ContextTypes.DEFAULT_TYPE
388358
) -> None:
389359
"""Handle @admin mention: notify admins of intervention request (no reply needed)."""
390-
repository: AsyncRepository = context.bot_data["repository"]
391360
message = update.message
392361
if message is None or message.from_user is None:
393362
return
@@ -396,9 +365,6 @@ async def _handle_admin_mention(
396365
if chat is None or chat.type == "private":
397366
return
398367

399-
# Track the user
400-
await _track_user(repository, message.from_user)
401-
402368
text = message.text or message.caption or ""
403369
reason = _extract_reason_after_admin(text)
404370

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""Shared utilities for all handlers."""
2+
3+
import logging
4+
5+
from telegram import Update
6+
from telegram.ext import ContextTypes, TypeHandler
7+
8+
from ..db.base import AsyncRepository
9+
10+
logger = logging.getLogger(__name__)
11+
12+
13+
async def track_user(repo: AsyncRepository, user: object) -> None:
14+
"""Track a Telegram user in the known_users table."""
15+
await repo.upsert_known_user(
16+
user_id=user.id, # type: ignore[attr-defined]
17+
username=getattr(user, "username", None),
18+
first_name=getattr(user, "first_name", None),
19+
last_name=getattr(user, "last_name", None),
20+
)
21+
22+
23+
async def _handle_track_user(
24+
update: Update, context: ContextTypes.DEFAULT_TYPE
25+
) -> None:
26+
"""Middleware: track the effective user for every update."""
27+
user = update.effective_user
28+
if user is None:
29+
return
30+
repository: AsyncRepository | None = context.bot_data.get("repository")
31+
if repository is None:
32+
return
33+
try:
34+
await track_user(repository, user)
35+
except Exception as e:
36+
logger.warning("Failed to track user %s: %s", user.id, e, exc_info=True)
37+
38+
39+
def create_user_tracking_handler() -> TypeHandler:
40+
"""Create a TypeHandler that tracks users from every incoming update."""
41+
return TypeHandler(Update, _handle_track_user)

src/python_italy_bot/handlers/welcome.py

Lines changed: 4 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,22 +16,13 @@
1616
from ..db.base import AsyncRepository
1717
from ..services.captcha import CaptchaService
1818
from ..services.moderation import ModerationService
19+
from .utils import track_user
1920

2021
logger = logging.getLogger(__name__)
2122

2223
DEFAULT_WELCOME_DELAY_MINUTES = 5
2324

2425

25-
async def _track_user(repo: AsyncRepository, user: object) -> None:
26-
"""Track a Telegram user in the known_users table."""
27-
await repo.upsert_known_user(
28-
user_id=user.id, # type: ignore[attr-defined]
29-
username=getattr(user, "username", None),
30-
first_name=getattr(user, "first_name", None),
31-
last_name=getattr(user, "last_name", None),
32-
)
33-
34-
3526
def create_welcome_handlers(captcha_service: CaptchaService) -> list:
3627
"""Create welcome and captcha handlers."""
3728
return [
@@ -93,8 +84,9 @@ async def _handle_new_member(
9384

9485
await moderation_service.register_chat(chat.id)
9586

96-
# Track the user
97-
await _track_user(repository, user)
87+
# Track the new member explicitly: middleware tracks effective_user (the admin
88+
# when someone is added by an admin), but we need to track the joined member.
89+
await track_user(repository, user)
9890

9991
if user.is_bot:
10092
return
@@ -176,7 +168,6 @@ async def _handle_start(
176168
) -> None:
177169
"""Handle /start command, including deep link for verification."""
178170
captcha_service: CaptchaService = context.bot_data["captcha_service"]
179-
repository: AsyncRepository = context.bot_data["repository"]
180171
message = update.message
181172
if message is None:
182173
return
@@ -189,9 +180,6 @@ async def _handle_start(
189180
if user is None:
190181
return
191182

192-
# Track the user
193-
await _track_user(repository, user)
194-
195183
args = context.args
196184
if args and args[0] == "verify":
197185
rules_url = captcha_service.get_rules_url()
@@ -254,7 +242,6 @@ async def _handle_private_message(
254242
) -> None:
255243
"""Handle private messages: check for secret command and verify user globally."""
256244
captcha_service: CaptchaService = context.bot_data["captcha_service"]
257-
repository: AsyncRepository = context.bot_data["repository"]
258245
message = update.message
259246
if message is None or message.text is None:
260247
return
@@ -263,9 +250,6 @@ async def _handle_private_message(
263250
if user is None:
264251
return
265252

266-
# Track the user
267-
await _track_user(repository, user)
268-
269253
if not captcha_service.is_secret_command(message.text):
270254
await message.reply_text(strings.VERIFY_UNKNOWN_COMMAND)
271255
return

src/python_italy_bot/main.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from .handlers.ping import create_ping_handlers
1313
from .handlers.settings import create_settings_handlers
1414
# from .handlers.spam import create_spam_handler
15+
from .handlers.utils import create_user_tracking_handler
1516
from .handlers.welcome import create_welcome_handlers
1617
from .services.captcha import CaptchaService
1718
from .services.moderation import ModerationService
@@ -43,6 +44,9 @@ async def _post_init(application) -> None:
4344
application.bot_data["moderation_service"] = moderation_service
4445
# application.bot_data["spam_detector"] = spam_detector
4546

47+
# Register user-tracking middleware before all other handlers (group -1)
48+
application.add_handler(create_user_tracking_handler(), group=-1)
49+
4650
for handler in create_id_handlers():
4751
application.add_handler(handler)
4852
for handler in create_settings_handlers():

0 commit comments

Comments
 (0)