1- """Handler for broadcasting announcements to all groups."""
1+ """Handler for broadcasting announcements to groups."""
22
33import logging
4+ import re
45
56from telegram import InlineKeyboardButton , InlineKeyboardMarkup , Update
67from telegram .ext import CommandHandler , ContextTypes
78
89from .. import strings
910from ..config import Settings
11+ from ..db .models import Chat
1012from ..services .captcha import BUTTON_URL_PATTERN
1113from ..services .moderation import ModerationService
1214
@@ -27,8 +29,7 @@ def _parse_button_urls(text: str) -> tuple[str, InlineKeyboardMarkup | None]:
2729 matches = list (BUTTON_URL_PATTERN .finditer (line ))
2830 if matches :
2931 row = [
30- InlineKeyboardButton (text = m .group (1 ), url = m .group (2 ))
31- for m in matches
32+ InlineKeyboardButton (text = m .group (1 ), url = m .group (2 )) for m in matches
3233 ]
3334 keyboard_rows .append (row )
3435 clean_line = BUTTON_URL_PATTERN .sub ("" , line ).strip ()
@@ -42,16 +43,76 @@ def _parse_button_urls(text: str) -> tuple[str, InlineKeyboardMarkup | None]:
4243 return clean_text , keyboard
4344
4445
46+ def _parse_target_and_message (raw_announcement : str ) -> tuple [str | None , str ]:
47+ """Split 'target | message' into (target, message).
48+
49+ If no pipe separator is found, returns (None, raw_announcement).
50+ Only the first pipe is used as delimiter.
51+ """
52+ if "|" in raw_announcement :
53+ target , _ , message = raw_announcement .partition ("|" )
54+ target = target .strip ()
55+ message = message .strip ()
56+ if target and message :
57+ return target , message
58+ return None , raw_announcement
59+
60+
61+ async def _resolve_target_chats (
62+ target : str ,
63+ moderation_service : ModerationService ,
64+ context : ContextTypes .DEFAULT_TYPE ,
65+ ) -> list [Chat ] | str :
66+ """Resolve a target string to a list of Chat objects.
67+
68+ Returns a list of Chat on success, or an error message string on failure.
69+ Resolution order:
70+ 1. Numeric ID -> direct lookup
71+ 2. @username -> Telegram API get_chat
72+ 3. Otherwise -> search registered chats by title
73+ """
74+ # 1. Numeric ID
75+ if re .match (r"^-?\d+$" , target ):
76+ chat_id = int (target )
77+ # Check if it's a registered chat
78+ all_chats = await moderation_service .get_all_chats_with_titles ()
79+ for chat in all_chats :
80+ if chat .chat_id == chat_id :
81+ return [chat ]
82+ # Not registered but still try to send (owner might know the ID)
83+ return [Chat (chat_id = chat_id , title = None )]
84+
85+ # 2. @username -> resolve via Telegram API
86+ if target .startswith ("@" ):
87+ try :
88+ resolved = await context .bot .get_chat (target )
89+ return [Chat (chat_id = resolved .id , title = resolved .title )]
90+ except Exception :
91+ return strings .ANNOUNCE_GROUP_NOT_FOUND .format (target = target )
92+
93+ # 3. Search by title
94+ matches = await moderation_service .find_chats_by_title (target )
95+ if not matches :
96+ return strings .ANNOUNCE_GROUP_NOT_FOUND .format (target = target )
97+ if len (matches ) > 1 :
98+ match_lines = "\n " .join (f" { c .chat_id } — { c .title } " for c in matches )
99+ return strings .ANNOUNCE_AMBIGUOUS_GROUPS .format (
100+ query = target , matches = match_lines
101+ )
102+ return matches
103+
104+
45105async def _handle_announce (
46106 update : Update ,
47107 context : ContextTypes .DEFAULT_TYPE ,
48108 moderation_service : ModerationService ,
49109 settings : Settings ,
50110) -> None :
51- """Broadcast an announcement to all registered groups .
111+ """Broadcast an announcement to all or a specific group .
52112
53113 Only works in DM and only for the bot owner.
54114 Supports HTML formatting and [text](buttonurl://url) button syntax.
115+ Use pipe syntax to target: /announce target | message
55116 """
56117 message = update .effective_message
57118 chat = update .effective_chat
@@ -79,47 +140,108 @@ async def _handle_announce(
79140 await message .reply_text (strings .ANNOUNCE_USAGE )
80141 return
81142
82- clean_text , keyboard = _parse_button_urls (announcement )
143+ target_str , body = _parse_target_and_message (announcement )
144+
145+ clean_text , keyboard = _parse_button_urls (body )
83146
84147 if not clean_text :
85148 await message .reply_text (strings .ANNOUNCE_EMPTY_MESSAGE )
86149 return
87150
88- chat_ids = await moderation_service .get_all_chats ()
89-
90- if not chat_ids :
91- await message .reply_text (strings .ANNOUNCE_NO_GROUPS )
92- return
93-
94- await message .reply_text (strings .ANNOUNCE_SENDING .format (count = len (chat_ids )))
151+ # Resolve target chat(s)
152+ if target_str is not None :
153+ result = await _resolve_target_chats (target_str , moderation_service , context )
154+ if isinstance (result , str ):
155+ await message .reply_text (result )
156+ return
157+ target_chats = result
158+ display_name = target_chats [0 ].title or str (target_chats [0 ].chat_id )
159+ await message .reply_text (
160+ strings .ANNOUNCE_SENDING_TARGETED .format (name = display_name )
161+ )
162+ chat_ids = [c .chat_id for c in target_chats ]
163+ else :
164+ chat_ids = await moderation_service .get_all_chats ()
165+ if not chat_ids :
166+ await message .reply_text (strings .ANNOUNCE_NO_GROUPS )
167+ return
168+ await message .reply_text (strings .ANNOUNCE_SENDING .format (count = len (chat_ids )))
95169
96170 success = 0
97171 failed = 0
98172
99- for chat_id in chat_ids :
173+ for cid in chat_ids :
100174 try :
101175 await context .bot .send_message (
102- chat_id = chat_id ,
176+ chat_id = cid ,
103177 text = clean_text ,
104178 parse_mode = "HTML" ,
105179 reply_markup = keyboard ,
106180 )
107181 success += 1
108182 except Exception as e :
109183 failed += 1
110- logger .warning ("Failed to send announcement to %s: %s" , chat_id , e )
184+ logger .warning ("Failed to send announcement to %s: %s" , cid , e )
111185
112186 await message .reply_text (strings .announce_result (success , failed ))
113187
114188
189+ async def _handle_groups (
190+ update : Update ,
191+ context : ContextTypes .DEFAULT_TYPE ,
192+ moderation_service : ModerationService ,
193+ settings : Settings ,
194+ ) -> None :
195+ """List all registered groups. Owner only, private chat only."""
196+ message = update .effective_message
197+ chat = update .effective_chat
198+ user = update .effective_user
199+
200+ if not message or not chat or not user :
201+ return
202+
203+ if chat .type != "private" :
204+ await message .reply_text (strings .ONLY_IN_PRIVATE )
205+ return
206+
207+ if settings .bot_owner_id is None :
208+ await message .reply_text (strings .ANNOUNCE_NO_OWNER_CONFIGURED )
209+ return
210+
211+ if user .id != settings .bot_owner_id :
212+ await message .reply_text (strings .ANNOUNCE_OWNER_ONLY )
213+ return
214+
215+ chats = await moderation_service .get_all_chats_with_titles ()
216+
217+ if not chats :
218+ await message .reply_text (strings .GROUPS_LIST_EMPTY )
219+ return
220+
221+ lines = [strings .GROUPS_LIST_HEADER .format (count = len (chats ))]
222+ for c in chats :
223+ title = c .title or "(senza nome)"
224+ lines .append (strings .GROUPS_LIST_ROW .format (chat_id = c .chat_id , title = title ))
225+
226+ await message .reply_text ("\n " .join (lines ))
227+
228+
115229def create_announce_handlers (
116230 moderation_service : ModerationService , settings : Settings
117231) -> list [CommandHandler ]:
118- """Create handlers for the announce command ."""
232+ """Create handlers for the announce and groups commands ."""
119233
120234 async def announce_wrapper (
121235 update : Update , context : ContextTypes .DEFAULT_TYPE
122236 ) -> None :
123237 await _handle_announce (update , context , moderation_service , settings )
124238
125- return [CommandHandler ("announce" , announce_wrapper )]
239+ async def groups_wrapper (
240+ update : Update , context : ContextTypes .DEFAULT_TYPE
241+ ) -> None :
242+ await _handle_groups (update , context , moderation_service , settings )
243+
244+ return [
245+ CommandHandler ("announce" , announce_wrapper ),
246+ CommandHandler ("groups" , groups_wrapper ),
247+ ]
0 commit comments