Skip to content

Commit d4e41db

Browse files
committed
feat(moderation): implement global ban and chat registration features
- Added methods for registering active chats and managing global bans in the moderation service. - Enhanced the in-memory and PostgreSQL repositories to support chat tracking and global ban functionality. - Introduced command handlers for forcefully registering chats and handling global bans in moderation. - Updated welcome message handling to automatically register chats and check for globally banned users.
1 parent f2258f3 commit d4e41db

6 files changed

Lines changed: 238 additions & 31 deletions

File tree

src/python_italy_bot/db/base.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,36 @@ def mark_globally_verified(self, user_id: int) -> None:
106106
"""Mark user as globally verified."""
107107
...
108108

109+
@abstractmethod
110+
def register_chat(self, chat_id: int) -> None:
111+
"""Track a chat where the bot is active."""
112+
...
113+
114+
@abstractmethod
115+
def get_all_chats(self) -> list[int]:
116+
"""Get all tracked chat IDs."""
117+
...
118+
119+
@abstractmethod
120+
def add_global_ban(
121+
self,
122+
user_id: int,
123+
admin_id: int,
124+
reason: str | None = None,
125+
) -> None:
126+
"""Add a global ban for a user."""
127+
...
128+
129+
@abstractmethod
130+
def remove_global_ban(self, user_id: int) -> bool:
131+
"""Remove a global ban. Returns True if existed."""
132+
...
133+
134+
@abstractmethod
135+
def is_globally_banned(self, user_id: int) -> bool:
136+
"""Check if user is globally banned."""
137+
...
138+
109139

110140
class AsyncRepository(ABC):
111141
"""Abstract interface for data persistence (async)."""
@@ -210,6 +240,36 @@ async def mark_globally_verified(self, user_id: int) -> None:
210240
"""Mark user as globally verified."""
211241
...
212242

243+
@abstractmethod
244+
async def register_chat(self, chat_id: int) -> None:
245+
"""Track a chat where the bot is active."""
246+
...
247+
248+
@abstractmethod
249+
async def get_all_chats(self) -> list[int]:
250+
"""Get all tracked chat IDs."""
251+
...
252+
253+
@abstractmethod
254+
async def add_global_ban(
255+
self,
256+
user_id: int,
257+
admin_id: int,
258+
reason: str | None = None,
259+
) -> None:
260+
"""Add a global ban for a user."""
261+
...
262+
263+
@abstractmethod
264+
async def remove_global_ban(self, user_id: int) -> bool:
265+
"""Remove a global ban. Returns True if existed."""
266+
...
267+
268+
@abstractmethod
269+
async def is_globally_banned(self, user_id: int) -> bool:
270+
"""Check if user is globally banned."""
271+
...
272+
213273
async def close(self) -> None:
214274
"""Close any resources (override if needed)."""
215275
pass

src/python_italy_bot/db/in_memory.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ def __init__(self) -> None:
1717
self._reports: list[Report] = []
1818
self._welcome_messages: dict[int, str] = {}
1919
self._globally_verified: set[int] = set()
20+
self._bot_chats: set[int] = set()
21+
self._global_bans: dict[int, tuple[int, str | None]] = {}
2022

2123
async def add_pending_verification(self, user_id: int, chat_id: int) -> None:
2224
self._pending.add((user_id, chat_id))
@@ -134,3 +136,26 @@ async def is_globally_verified(self, user_id: int) -> bool:
134136

135137
async def mark_globally_verified(self, user_id: int) -> None:
136138
self._globally_verified.add(user_id)
139+
140+
async def register_chat(self, chat_id: int) -> None:
141+
self._bot_chats.add(chat_id)
142+
143+
async def get_all_chats(self) -> list[int]:
144+
return list(self._bot_chats)
145+
146+
async def add_global_ban(
147+
self,
148+
user_id: int,
149+
admin_id: int,
150+
reason: str | None = None,
151+
) -> None:
152+
self._global_bans[user_id] = (admin_id, reason)
153+
154+
async def remove_global_ban(self, user_id: int) -> bool:
155+
if user_id in self._global_bans:
156+
del self._global_bans[user_id]
157+
return True
158+
return False
159+
160+
async def is_globally_banned(self, user_id: int) -> bool:
161+
return user_id in self._global_bans

src/python_italy_bot/db/postgres.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,5 +207,59 @@ async def mark_globally_verified(self, user_id: int) -> None:
207207
(user_id,),
208208
)
209209

210+
async def register_chat(self, chat_id: int) -> None:
211+
async with self._pool.connection() as conn:
212+
await conn.execute(
213+
"""
214+
INSERT INTO bot_chats (chat_id)
215+
VALUES (%s)
216+
ON CONFLICT (chat_id) DO NOTHING
217+
""",
218+
(chat_id,),
219+
)
220+
221+
async def get_all_chats(self) -> list[int]:
222+
async with self._pool.connection() as conn:
223+
async with conn.cursor() as cur:
224+
await cur.execute("SELECT chat_id FROM bot_chats")
225+
rows = await cur.fetchall()
226+
return [row[0] for row in rows]
227+
228+
async def add_global_ban(
229+
self,
230+
user_id: int,
231+
admin_id: int,
232+
reason: str | None = None,
233+
) -> None:
234+
async with self._pool.connection() as conn:
235+
await conn.execute(
236+
"""
237+
INSERT INTO global_bans (user_id, admin_id, reason)
238+
VALUES (%s, %s, %s)
239+
ON CONFLICT (user_id)
240+
DO UPDATE SET admin_id = EXCLUDED.admin_id,
241+
reason = EXCLUDED.reason,
242+
created_at = NOW()
243+
""",
244+
(user_id, admin_id, reason),
245+
)
246+
247+
async def remove_global_ban(self, user_id: int) -> bool:
248+
async with self._pool.connection() as conn:
249+
result = await conn.execute(
250+
"DELETE FROM global_bans WHERE user_id = %s",
251+
(user_id,),
252+
)
253+
return result.rowcount > 0
254+
255+
async def is_globally_banned(self, user_id: int) -> bool:
256+
async with self._pool.connection() as conn:
257+
async with conn.cursor() as cur:
258+
await cur.execute(
259+
"SELECT 1 FROM global_bans WHERE user_id = %s",
260+
(user_id,),
261+
)
262+
return await cur.fetchone() is not None
263+
210264
async def close(self) -> None:
211265
await self._pool.close()

src/python_italy_bot/handlers/moderation.py

Lines changed: 65 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,36 @@ def create_moderation_handlers(moderation_service: ModerationService) -> list:
3434
CommandHandler("mute", _handle_mute),
3535
CommandHandler("unmute", _handle_unmute),
3636
CommandHandler("report", _handle_report),
37+
CommandHandler("forcegroupregistration", _handle_force_group_registration),
3738
]
3839

3940

41+
async def _handle_force_group_registration(
42+
update: Update, context: ContextTypes.DEFAULT_TYPE
43+
) -> None:
44+
"""Force registration of current chat in bot_chats table. Admin only."""
45+
moderation_service: ModerationService = context.bot_data["moderation_service"]
46+
message = update.message
47+
if message is None or message.from_user is None:
48+
return
49+
50+
chat = update.effective_chat
51+
if chat is None or chat.type == "private":
52+
await message.reply_text("Questo comando funziona solo nei gruppi.")
53+
return
54+
55+
if not await _is_admin(context, chat.id, message.from_user.id):
56+
await message.reply_text(
57+
"Solo gli amministratori possono usare questo comando."
58+
)
59+
return
60+
61+
await moderation_service.register_chat(chat.id)
62+
await message.reply_text(f"Gruppo registrato. Chat ID: {chat.id}")
63+
64+
4065
async def _handle_ban(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
41-
"""Ban a user. Usage: /ban @username or /ban user_id [reason] or reply to message with /ban [reason]"""
66+
"""Ban a user globally. Usage: /ban user_id [reason] or reply to message with /ban [reason]"""
4267
moderation_service: ModerationService = context.bot_data["moderation_service"]
4368
message = update.message
4469
if message is None or message.from_user is None:
@@ -60,30 +85,41 @@ async def _handle_ban(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non
6085
reason: str | None = None
6186
if message.reply_to_message and message.reply_to_message.from_user:
6287
user_id = message.reply_to_message.from_user.id
63-
reason = args[0] if args else None
88+
reason = " ".join(args) if args else None
6489
elif args:
6590
target = args[0]
6691
reason = args[1] if len(args) > 1 else None
6792
user_id = await _resolve_user_id(context, chat.id, target)
6893

6994
if user_id is None:
7095
await message.reply_text(
71-
"Uso: /ban @username, /ban user_id [motivo], o rispondi al messaggio con /ban [motivo]. "
72-
"Per @username funziona solo con amministratori."
96+
"Uso: /ban user_id [motivo], o rispondi al messaggio con /ban [motivo]."
7397
)
7498
return
7599

76-
try:
77-
await context.bot.ban_chat_member(chat.id, user_id)
78-
await moderation_service.add_ban(user_id, chat.id, message.from_user.id, reason)
79-
await message.reply_text(f"Utente bannato. Motivo: {reason or 'Nessuno'}")
80-
except Exception as e:
81-
logger.warning("Ban failed: %s", e)
82-
await message.reply_text("Impossibile bannare l'utente.")
100+
chat_ids = await moderation_service.add_global_ban(
101+
user_id, message.from_user.id, reason
102+
)
103+
104+
success_count = 0
105+
fail_count = 0
106+
for cid in chat_ids:
107+
try:
108+
await context.bot.ban_chat_member(cid, user_id)
109+
success_count += 1
110+
except Exception as e:
111+
logger.debug("Ban in chat %s failed: %s", cid, e)
112+
fail_count += 1
113+
114+
msg = f"Utente bannato globalmente in {success_count} gruppi."
115+
if fail_count > 0:
116+
msg += f" ({fail_count} falliti)"
117+
msg += f"\nMotivo: {reason or 'Nessuno'}"
118+
await message.reply_text(msg)
83119

84120

85121
async def _handle_unban(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
86-
"""Unban a user. Usage: /unban @username or /unban user_id"""
122+
"""Unban a user globally. Usage: /unban user_id or reply to message with /unban"""
87123
moderation_service: ModerationService = context.bot_data["moderation_service"]
88124
message = update.message
89125
if message is None or message.from_user is None:
@@ -108,19 +144,26 @@ async def _handle_unban(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
108144

109145
if user_id is None:
110146
await message.reply_text(
111-
"Uso: /unban @username, /unban user_id, o rispondi al messaggio"
147+
"Uso: /unban user_id, o rispondi al messaggio con /unban"
112148
)
113-
if user_id is None:
114-
await message.reply_text("Utente non trovato.")
115149
return
116150

117-
try:
118-
await context.bot.unban_chat_member(chat.id, user_id)
119-
await moderation_service.remove_ban(user_id, chat.id)
120-
await message.reply_text("Utente sbannato.")
121-
except Exception as e:
122-
logger.warning("Unban failed: %s", e)
123-
await message.reply_text("Impossibile sbannare l'utente.")
151+
chat_ids = await moderation_service.remove_global_ban(user_id)
152+
153+
success_count = 0
154+
fail_count = 0
155+
for cid in chat_ids:
156+
try:
157+
await context.bot.unban_chat_member(cid, user_id)
158+
success_count += 1
159+
except Exception as e:
160+
logger.debug("Unban in chat %s failed: %s", cid, e)
161+
fail_count += 1
162+
163+
msg = f"Utente sbannato globalmente da {success_count} gruppi."
164+
if fail_count > 0:
165+
msg += f" ({fail_count} falliti)"
166+
await message.reply_text(msg)
124167

125168

126169
async def _handle_mute(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:

src/python_italy_bot/handlers/welcome.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
)
1414

1515
from ..services.captcha import CaptchaService
16+
from ..services.moderation import ModerationService
1617

1718
logger = logging.getLogger(__name__)
1819

@@ -38,6 +39,7 @@ async def _handle_new_member(
3839
) -> None:
3940
"""Handle new chat members: restrict and send welcome with captcha instructions."""
4041
captcha_service: CaptchaService = context.bot_data["captcha_service"]
42+
moderation_service: ModerationService = context.bot_data["moderation_service"]
4143
result = update.chat_member
4244
if result is None:
4345
return
@@ -59,9 +61,19 @@ async def _handle_new_member(
5961
if user is None or chat is None:
6062
return
6163

64+
await moderation_service.register_chat(chat.id)
65+
6266
if user.is_bot:
6367
return
6468

69+
if await moderation_service.is_globally_banned(user.id):
70+
try:
71+
await context.bot.ban_chat_member(chat.id, user.id)
72+
logger.info("Kicked globally banned user %s from chat %s", user.id, chat.id)
73+
except Exception as e:
74+
logger.warning("Failed to kick globally banned user %s: %s", user.id, e)
75+
return
76+
6577
if await captcha_service.is_globally_verified(user.id):
6678
return
6779

src/python_italy_bot/services/moderation.py

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,21 +39,34 @@ async def is_muted(self, user_id: int, chat_id: int) -> bool:
3939
muted_users = await self._repo.get_muted_users(chat_id)
4040
return user_id in muted_users
4141

42-
async def add_ban(
42+
async def add_global_ban(
4343
self,
4444
user_id: int,
45-
chat_id: int,
4645
admin_id: int,
4746
reason: str | None = None,
48-
) -> None:
49-
"""Record a ban."""
50-
await self._repo.add_ban(
51-
user_id=user_id, chat_id=chat_id, admin_id=admin_id, reason=reason
47+
) -> list[int]:
48+
"""Record a global ban. Returns list of chat IDs to ban in."""
49+
await self._repo.add_global_ban(
50+
user_id=user_id, admin_id=admin_id, reason=reason
5251
)
52+
return await self._repo.get_all_chats()
53+
54+
async def remove_global_ban(self, user_id: int) -> list[int]:
55+
"""Remove a global ban. Returns list of chat IDs to unban in."""
56+
await self._repo.remove_global_ban(user_id)
57+
return await self._repo.get_all_chats()
58+
59+
async def is_globally_banned(self, user_id: int) -> bool:
60+
"""Check if user is globally banned."""
61+
return await self._repo.is_globally_banned(user_id)
62+
63+
async def register_chat(self, chat_id: int) -> None:
64+
"""Register a chat where the bot is active."""
65+
await self._repo.register_chat(chat_id)
5366

54-
async def remove_ban(self, user_id: int, chat_id: int) -> bool:
55-
"""Remove a ban record."""
56-
return await self._repo.remove_ban(user_id, chat_id)
67+
async def get_all_chats(self) -> list[int]:
68+
"""Get all tracked chat IDs."""
69+
return await self._repo.get_all_chats()
5770

5871
async def add_mute(
5972
self,

0 commit comments

Comments
 (0)