feat(bot): add interactive start menu and rich reply keyboard navigation
This commit is contained in:
+196
-112
@@ -12,6 +12,14 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
SESSION_DIR = os.getenv("SESSION_DIR", "/app/sessions" if os.path.exists("/app") else "/projects/telegram-bots/copykar/sessions")
|
||||
|
||||
def get_main_menu_keyboard():
|
||||
return [
|
||||
[Button.text("🔑 Request Login Code", resize=True), Button.text("📊 Fleet Statistics", resize=True)],
|
||||
[Button.text("📡 Monitored Sources", resize=True), Button.text("🎯 Target Channels", resize=True)],
|
||||
[Button.text("➕ Add Source Guide", resize=True), Button.text("➕ Add Target Guide", resize=True)],
|
||||
[Button.text("❓ Help & Documentation", resize=True)]
|
||||
]
|
||||
|
||||
class AdminBotService:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -51,7 +59,7 @@ class AdminBotService:
|
||||
|
||||
for admin_id in self.admin_user_ids:
|
||||
try:
|
||||
await self.client.send_message(admin_id, text, parse_mode="html")
|
||||
await self.client.send_message(admin_id, text, parse_mode="html", buttons=get_main_menu_keyboard())
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not send DM to admin {admin_id}: {e}")
|
||||
|
||||
@@ -62,40 +70,200 @@ class AdminBotService:
|
||||
self._register_handlers()
|
||||
|
||||
def _register_handlers(self):
|
||||
# --- /start and Main Menu ---
|
||||
@self.client.on(events.NewMessage(pattern=r"(?i)^(/start|/menu|menu)$"))
|
||||
async def cmd_start(event: events.NewMessage.Event):
|
||||
if not self.is_admin(event.sender_id):
|
||||
await event.reply(f"⛔ Unauthorized user ID: <code>{event.sender_id}</code>. Please add this ID to <code>ADMIN_USER_IDS</code> in .env.", parse_mode="html")
|
||||
return
|
||||
|
||||
userbot_status = "🔴 Not Authorized"
|
||||
if self.collector and self.collector.client.is_connected() and await self.collector.client.is_user_authorized():
|
||||
me = await self.collector.client.get_me()
|
||||
userbot_status = f"🟢 Online ({me.first_name})"
|
||||
|
||||
welcome_text = (
|
||||
"👋 <b>Welcome to Copykar Admin Console!</b>\n\n"
|
||||
f"• 🤖 <b>Userbot Status:</b> {userbot_status}\n"
|
||||
f"• 📋 <b>Review Channel:</b> <code>{self.review_channel_id}</code>\n\n"
|
||||
"Use the interactive menu buttons below to manage the fleet:"
|
||||
)
|
||||
await event.reply(welcome_text, parse_mode="html", buttons=get_main_menu_keyboard())
|
||||
|
||||
# --- Interactive Userbot Authentication Commands ---
|
||||
@self.client.on(events.NewMessage(pattern=r"/code\s+(\S+)"))
|
||||
async def cmd_code(event: events.NewMessage.Event):
|
||||
if not self.is_admin(event.sender_id):
|
||||
return
|
||||
if not self.collector:
|
||||
await event.reply("Collector service not linked.")
|
||||
return
|
||||
code = event.pattern_match.group(1)
|
||||
status_msg = await event.reply("⏳ Verifying code with Telegram...")
|
||||
result = await self.collector.submit_code(code)
|
||||
await status_msg.edit(result, parse_mode="html")
|
||||
|
||||
@self.client.on(events.NewMessage(pattern=r"/password\s+(.+)"))
|
||||
async def cmd_password(event: events.NewMessage.Event):
|
||||
if not self.is_admin(event.sender_id):
|
||||
return
|
||||
if not self.collector:
|
||||
await event.reply("Collector service not linked.")
|
||||
return
|
||||
pwd = event.pattern_match.group(1)
|
||||
status_msg = await event.reply("⏳ Verifying 2FA password...")
|
||||
result = await self.collector.submit_password(pwd)
|
||||
await status_msg.edit(result, parse_mode="html")
|
||||
|
||||
@self.client.on(events.NewMessage(pattern="/request_code"))
|
||||
@self.client.on(events.NewMessage(pattern=r"(?i)^(/request_code|🔑 Request Login Code)$"))
|
||||
async def cmd_request_code(event: events.NewMessage.Event):
|
||||
if not self.is_admin(event.sender_id):
|
||||
return
|
||||
if not self.collector:
|
||||
await event.reply("Collector service not linked.")
|
||||
return
|
||||
await event.reply("Requesting new login code...")
|
||||
await self.collector.start(notify_fn=self.notify_admins)
|
||||
msg = await event.reply("⏳ Contacting Telegram to request login code...")
|
||||
try:
|
||||
sent = await self.collector.client.send_code_request(self.collector.phone)
|
||||
self.collector.phone_code_hash = sent.phone_code_hash
|
||||
await msg.edit(
|
||||
f"📩 <b>Code Sent!</b>\n\n"
|
||||
f"Telegram sent a verification code to <code>{self.collector.phone}</code>.\n\n"
|
||||
f"Please reply with:\n"
|
||||
f"<code>/code <your_code></code>\n\n"
|
||||
f"<i>Example:</i> <code>/code 12345</code>",
|
||||
parse_mode="html"
|
||||
)
|
||||
except Exception as e:
|
||||
await msg.edit(f"❌ Could not request code: {e}")
|
||||
|
||||
@self.client.on(events.NewMessage(pattern=r"^/code\s+(\S+)"))
|
||||
async def cmd_code(event: events.NewMessage.Event):
|
||||
if not self.is_admin(event.sender_id):
|
||||
return
|
||||
if not self.collector:
|
||||
await event.reply("Collector service not linked.")
|
||||
return
|
||||
code = event.pattern_match.group(1).strip()
|
||||
status_msg = await event.reply("⏳ Submitting code to Telegram...")
|
||||
result = await self.collector.submit_code(code)
|
||||
await status_msg.edit(result, parse_mode="html", buttons=get_main_menu_keyboard())
|
||||
|
||||
@self.client.on(events.NewMessage(pattern=r"^/password\s+(.+)"))
|
||||
async def cmd_password(event: events.NewMessage.Event):
|
||||
if not self.is_admin(event.sender_id):
|
||||
return
|
||||
if not self.collector:
|
||||
await event.reply("Collector service not linked.")
|
||||
return
|
||||
pwd = event.pattern_match.group(1).strip()
|
||||
status_msg = await event.reply("⏳ Verifying 2FA password...")
|
||||
result = await self.collector.submit_password(pwd)
|
||||
await status_msg.edit(result, parse_mode="html", buttons=get_main_menu_keyboard())
|
||||
|
||||
# --- Statistics ---
|
||||
@self.client.on(events.NewMessage(pattern=r"(?i)^(/stats|📊 Fleet Statistics)$"))
|
||||
async def cmd_stats(event: events.NewMessage.Event):
|
||||
if not self.is_admin(event.sender_id):
|
||||
return
|
||||
pending_ai = len(await self.repo.get_posts_by_status("pending_ai", limit=1000))
|
||||
pending_review = len(await self.repo.get_posts_by_status("pending_review", limit=1000))
|
||||
approved = len(await self.repo.get_posts_by_status("approved", limit=1000))
|
||||
published = len(await self.repo.get_posts_by_status("published", limit=1000))
|
||||
rejected = len(await self.repo.get_posts_by_status("rejected", limit=1000))
|
||||
|
||||
text = (
|
||||
"📊 <b>Copykar Fleet Metrics</b>\n\n"
|
||||
f"• ⏳ <b>Pending AI:</b> {pending_ai}\n"
|
||||
f"• 📋 <b>Pending Review:</b> {pending_review}\n"
|
||||
f"• 🚀 <b>Approved (In Queue):</b> {approved}\n"
|
||||
f"• ✅ <b>Published:</b> {published}\n"
|
||||
f"• ❌ <b>Rejected:</b> {rejected}\n\n"
|
||||
"📈 <i>Grafana Dashboard:</i> http://localhost:3000"
|
||||
)
|
||||
await event.reply(text, parse_mode="html", buttons=get_main_menu_keyboard())
|
||||
|
||||
# --- Sources Management ---
|
||||
@self.client.on(events.NewMessage(pattern=r"(?i)^(/sources|📡 Monitored Sources)$"))
|
||||
async def cmd_sources(event: events.NewMessage.Event):
|
||||
if not self.is_admin(event.sender_id):
|
||||
return
|
||||
sources = await self.repo.get_active_sources()
|
||||
if not sources:
|
||||
await event.reply("No active source channels configured.\nUse <code>➕ Add Source Guide</code> to add one.", parse_mode="html")
|
||||
return
|
||||
lines = ["<b>📡 Active Monitored Sources:</b>\n"]
|
||||
for s in sources:
|
||||
lines.append(f"• ID: <code>{s.channel_id}</code> | <b>{s.title or 'N/A'}</b> (@{s.username or 'none'})")
|
||||
await event.reply("\n".join(lines), parse_mode="html", buttons=get_main_menu_keyboard())
|
||||
|
||||
@self.client.on(events.NewMessage(pattern=r"(?i)^(➕ Add Source Guide)$"))
|
||||
async def cmd_add_source_guide(event: events.NewMessage.Event):
|
||||
if not self.is_admin(event.sender_id):
|
||||
return
|
||||
guide = (
|
||||
"➕ <b>How to Add a Source Channel:</b>\n\n"
|
||||
"Send the command in this format:\n"
|
||||
"<code>/add_source <channel_id> <title> [username]</code>\n\n"
|
||||
"<i>Example:</i>\n"
|
||||
"<code>/add_source -1001234567890 TechNews technews_chan</code>"
|
||||
)
|
||||
await event.reply(guide, parse_mode="html")
|
||||
|
||||
@self.client.on(events.NewMessage(pattern=r"^/add_source\s+(-?\d+)\s+([^\s]+)(?:\s+([^\s]+))?"))
|
||||
async def cmd_add_source(event: events.NewMessage.Event):
|
||||
if not self.is_admin(event.sender_id):
|
||||
return
|
||||
ch_id = int(event.pattern_match.group(1))
|
||||
title = event.pattern_match.group(2)
|
||||
username = event.pattern_match.group(3)
|
||||
await self.repo.add_source(channel_id=ch_id, title=title, username=username)
|
||||
await event.reply(f"✅ Added source channel <b>{title}</b> (<code>{ch_id}</code>) to monitoring.", parse_mode="html", buttons=get_main_menu_keyboard())
|
||||
|
||||
# --- Targets Management ---
|
||||
@self.client.on(events.NewMessage(pattern=r"(?i)^(/targets|🎯 Target Channels)$"))
|
||||
async def cmd_targets(event: events.NewMessage.Event):
|
||||
if not self.is_admin(event.sender_id):
|
||||
return
|
||||
targets = await self.repo.get_active_targets()
|
||||
if not targets:
|
||||
await event.reply("No target channels configured.\nUse <code>➕ Add Target Guide</code> to add one.", parse_mode="html")
|
||||
return
|
||||
lines = ["<b>🎯 Target Publishing Channels:</b>\n"]
|
||||
for t in targets:
|
||||
lines.append(f"• ID: <code>{t.id}</code> (Channel: <code>{t.channel_id}</code>)\n Title: <b>{t.title}</b> | Interval: <b>{t.post_interval_min}m</b>")
|
||||
await event.reply("\n".join(lines), parse_mode="html", buttons=get_main_menu_keyboard())
|
||||
|
||||
@self.client.on(events.NewMessage(pattern=r"(?i)^(➕ Add Target Guide)$"))
|
||||
async def cmd_add_target_guide(event: events.NewMessage.Event):
|
||||
if not self.is_admin(event.sender_id):
|
||||
return
|
||||
guide = (
|
||||
"🎯 <b>How to Add a Target Channel:</b>\n\n"
|
||||
"Send the command in this format:\n"
|
||||
"<code>/add_target <channel_id> <title> <interval_minutes> [username]</code>\n\n"
|
||||
"<i>Example (posts every 30 minutes):</i>\n"
|
||||
"<code>/add_target -1009876543210 MyMainChannel 30 my_main_chan</code>"
|
||||
)
|
||||
await event.reply(guide, parse_mode="html")
|
||||
|
||||
@self.client.on(events.NewMessage(pattern=r"^/add_target\s+(-?\d+)\s+([^\s]+)\s+(\d+)(?:\s+([^\s]+))?"))
|
||||
async def cmd_add_target(event: events.NewMessage.Event):
|
||||
if not self.is_admin(event.sender_id):
|
||||
return
|
||||
ch_id = int(event.pattern_match.group(1))
|
||||
title = event.pattern_match.group(2)
|
||||
interval_min = int(event.pattern_match.group(3))
|
||||
username = event.pattern_match.group(4)
|
||||
await self.repo.add_target(channel_id=ch_id, title=title, username=username, post_interval_min=interval_min)
|
||||
await event.reply(f"✅ Added target channel <b>{title}</b> with interval <b>{interval_min}m</b>.", parse_mode="html", buttons=get_main_menu_keyboard())
|
||||
|
||||
@self.client.on(events.NewMessage(pattern=r"^/set_interval\s+(\d+)\s+(\d+)"))
|
||||
async def cmd_set_interval(event: events.NewMessage.Event):
|
||||
if not self.is_admin(event.sender_id):
|
||||
return
|
||||
target_id = int(event.pattern_match.group(1))
|
||||
new_interval = int(event.pattern_match.group(2))
|
||||
target = await self.repo.get_target_by_id(target_id)
|
||||
if not target:
|
||||
await event.reply("Target channel not found.")
|
||||
return
|
||||
await self.repo.add_target(
|
||||
channel_id=target.channel_id,
|
||||
title=target.title,
|
||||
username=target.username,
|
||||
post_interval_min=new_interval
|
||||
)
|
||||
await event.reply(f"✅ Updated interval for <b>{target.title}</b> to <b>{new_interval} minutes</b>.", parse_mode="html", buttons=get_main_menu_keyboard())
|
||||
|
||||
@self.client.on(events.NewMessage(pattern=r"(?i)^(/help|❓ Help & Documentation)$"))
|
||||
async def cmd_help(event: events.NewMessage.Event):
|
||||
if not self.is_admin(event.sender_id):
|
||||
return
|
||||
help_text = (
|
||||
"📖 <b>Copykar Bot Quick Help</b>\n\n"
|
||||
"1. <b>Authentication:</b> Click <code>🔑 Request Login Code</code> and submit via <code>/code <12345></code>.\n"
|
||||
"2. <b>Monitored Sources:</b> Add channels the userbot should listen to via <code>/add_source</code>.\n"
|
||||
"3. <b>Review Flow:</b> AI scans posts, checks duplicates, and sends drafts to the review channel with inline approval buttons.\n"
|
||||
"4. <b>Publishing:</b> Approved posts are published to your target channels strictly according to their interval minutes."
|
||||
)
|
||||
await event.reply(help_text, parse_mode="html", buttons=get_main_menu_keyboard())
|
||||
|
||||
# --- Review Keyboard Callbacks ---
|
||||
@self.client.on(events.CallbackQuery)
|
||||
@@ -137,90 +305,6 @@ class AdminBotService:
|
||||
)
|
||||
await event.answer("Post rejected.")
|
||||
|
||||
# --- Admin Configuration Commands ---
|
||||
@self.client.on(events.NewMessage(pattern="/sources"))
|
||||
async def cmd_sources(event: events.NewMessage.Event):
|
||||
if not self.is_admin(event.sender_id):
|
||||
return
|
||||
sources = await self.repo.get_active_sources()
|
||||
if not sources:
|
||||
await event.reply("No active source channels configured. Use /add_source <channel_id> <title> [username]")
|
||||
return
|
||||
lines = ["<b>Active Monitored Sources:</b>"]
|
||||
for s in sources:
|
||||
lines.append(f"• ID: <code>{s.channel_id}</code> | Title: <b>{s.title or 'N/A'}</b> (@{s.username or 'none'})")
|
||||
await event.reply("\n".join(lines), parse_mode="html")
|
||||
|
||||
@self.client.on(events.NewMessage(pattern=r"/add_source\s+(-?\d+)\s+([^\s]+)(?:\s+([^\s]+))?"))
|
||||
async def cmd_add_source(event: events.NewMessage.Event):
|
||||
if not self.is_admin(event.sender_id):
|
||||
return
|
||||
ch_id = int(event.pattern_match.group(1))
|
||||
title = event.pattern_match.group(2)
|
||||
username = event.pattern_match.group(3)
|
||||
await self.repo.add_source(channel_id=ch_id, title=title, username=username)
|
||||
await event.reply(f"✅ Added source channel <b>{title}</b> (<code>{ch_id}</code>).", parse_mode="html")
|
||||
|
||||
@self.client.on(events.NewMessage(pattern="/targets"))
|
||||
async def cmd_targets(event: events.NewMessage.Event):
|
||||
if not self.is_admin(event.sender_id):
|
||||
return
|
||||
targets = await self.repo.get_active_targets()
|
||||
if not targets:
|
||||
await event.reply("No target channels configured. Use /add_target <channel_id> <title> <interval_min> [username]")
|
||||
return
|
||||
lines = ["<b>Target Publishing Channels:</b>"]
|
||||
for t in targets:
|
||||
lines.append(f"• ID: <code>{t.id}</code> (Channel: <code>{t.channel_id}</code>) | <b>{t.title}</b> | Interval: <b>{t.post_interval_min}m</b>")
|
||||
await event.reply("\n".join(lines), parse_mode="html")
|
||||
|
||||
@self.client.on(events.NewMessage(pattern=r"/add_target\s+(-?\d+)\s+([^\s]+)\s+(\d+)(?:\s+([^\s]+))?"))
|
||||
async def cmd_add_target(event: events.NewMessage.Event):
|
||||
if not self.is_admin(event.sender_id):
|
||||
return
|
||||
ch_id = int(event.pattern_match.group(1))
|
||||
title = event.pattern_match.group(2)
|
||||
interval_min = int(event.pattern_match.group(3))
|
||||
username = event.pattern_match.group(4)
|
||||
await self.repo.add_target(channel_id=ch_id, title=title, username=username, post_interval_min=interval_min)
|
||||
await event.reply(f"✅ Added target channel <b>{title}</b> with interval <b>{interval_min}m</b>.", parse_mode="html")
|
||||
|
||||
@self.client.on(events.NewMessage(pattern=r"/set_interval\s+(\d+)\s+(\d+)"))
|
||||
async def cmd_set_interval(event: events.NewMessage.Event):
|
||||
if not self.is_admin(event.sender_id):
|
||||
return
|
||||
target_id = int(event.pattern_match.group(1))
|
||||
new_interval = int(event.pattern_match.group(2))
|
||||
target = await self.repo.get_target_by_id(target_id)
|
||||
if not target:
|
||||
await event.reply("Target channel not found.")
|
||||
return
|
||||
await self.repo.add_target(
|
||||
channel_id=target.channel_id,
|
||||
title=target.title,
|
||||
username=target.username,
|
||||
post_interval_min=new_interval
|
||||
)
|
||||
await event.reply(f"✅ Updated interval for <b>{target.title}</b> to <b>{new_interval} minutes</b>.", parse_mode="html")
|
||||
|
||||
@self.client.on(events.NewMessage(pattern="/stats"))
|
||||
async def cmd_stats(event: events.NewMessage.Event):
|
||||
if not self.is_admin(event.sender_id):
|
||||
return
|
||||
pending_ai = len(await self.repo.get_posts_by_status("pending_ai", limit=1000))
|
||||
pending_review = len(await self.repo.get_posts_by_status("pending_review", limit=1000))
|
||||
approved = len(await self.repo.get_posts_by_status("approved", limit=1000))
|
||||
published = len(await self.repo.get_posts_by_status("published", limit=1000))
|
||||
|
||||
text = (
|
||||
"📊 <b>Copykar Bot Statistics</b>\n\n"
|
||||
f"• ⏳ Pending AI: <b>{pending_ai}</b>\n"
|
||||
f"• 📋 Pending Admin Review: <b>{pending_review}</b>\n"
|
||||
f"• 🚀 Approved (Queued): <b>{approved}</b>\n"
|
||||
f"• ✅ Published: <b>{published}</b>\n"
|
||||
)
|
||||
await event.reply(text, parse_mode="html")
|
||||
|
||||
async def send_review_post(self, post_id: int):
|
||||
post = await self.repo.get_post_by_id(post_id)
|
||||
if not post or not self.review_channel_id:
|
||||
|
||||
Reference in New Issue
Block a user