Skip to content

Commit 1016056

Browse files
author
0xMett
committed
feat(announce): support targeted announce and add /groups command
Rewrite announce handler to support pipe syntax for targeting a specific group: /announce <target> | <message>. Target resolution tries numeric ID, @username via Telegram API, then title search. Ambiguous title matches are reported back to the owner. Add /groups command (owner-only) to list all registered groups with their IDs and titles.
1 parent 9a1e867 commit 1016056

1 file changed

Lines changed: 139 additions & 17 deletions

File tree

src/python_italy_bot/handlers/announce.py

Lines changed: 139 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1-
"""Handler for broadcasting announcements to all groups."""
1+
"""Handler for broadcasting announcements to groups."""
22

33
import logging
4+
import re
45

56
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
67
from telegram.ext import CommandHandler, ContextTypes
78

89
from .. import strings
910
from ..config import Settings
11+
from ..db.models import Chat
1012
from ..services.captcha import BUTTON_URL_PATTERN
1113
from ..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+
45105
async 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+
115229
def 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

Comments
 (0)