From f7c80b68d23620b1bae3b1324b8e77767379ef79 Mon Sep 17 00:00:00 2001 From: mamad Date: Thu, 27 Aug 2026 22:35:07 +0330 Subject: [PATCH] feat(bot): implement conversational input, forwarded message auto-detection, and button-driven channel config --- db/repository.py | 14 +- services/admin_bot.py | 611 ++++++++++++++++++++++++------------------ 2 files changed, 366 insertions(+), 259 deletions(-) diff --git a/db/repository.py b/db/repository.py index 6870ffd..ac20d04 100644 --- a/db/repository.py +++ b/db/repository.py @@ -48,12 +48,22 @@ class Repository: rows = await conn.fetch("SELECT * FROM sources WHERE is_active = TRUE ORDER BY id ASC;") return [SourceChannel(**dict(r)) for r in rows] - async def get_source_by_channel_id(self, channel_id: int) -> Optional[SourceChannel]: + async def get_source_by_id(self, source_id: int) -> Optional[SourceChannel]: pool = await self._get_pool() async with pool.acquire() as conn: - row = await conn.fetchrow("SELECT * FROM sources WHERE channel_id = $1;", channel_id) + row = await conn.fetchrow("SELECT * FROM sources WHERE id = $1;", source_id) return SourceChannel(**dict(row)) if row else None + async def delete_source(self, source_id: int) -> None: + pool = await self._get_pool() + async with pool.acquire() as conn: + await conn.execute("UPDATE sources SET is_active = FALSE WHERE id = $1;", source_id) + + async def delete_target(self, target_id: int) -> None: + pool = await self._get_pool() + async with pool.acquire() as conn: + await conn.execute("UPDATE targets SET is_active = FALSE WHERE id = $1;", target_id) + # --- Target Channels --- async def add_target( self, diff --git a/services/admin_bot.py b/services/admin_bot.py index 4324bd7..636757c 100644 --- a/services/admin_bot.py +++ b/services/admin_bot.py @@ -1,8 +1,10 @@ import os +import re import logging from datetime import datetime, timezone -from typing import Optional, List, Dict +from typing import Optional, List, Dict, Any, Tuple from telethon import TelegramClient, events, Button +from telethon.tl.types import PeerChannel, PeerChat, PeerUser, Channel, Chat from db.models import Post, TargetChannel, SourceChannel from db.repository import Repository from core.queue import RedisQueue @@ -18,10 +20,12 @@ def get_persian_main_menu(): [Button.text("📊 آمار و وضعیت ناوگان", resize=True), Button.text("🔑 درخواست کد لاگین", resize=True)], [Button.text("📡 کانال‌های مبدا", resize=True), Button.text("🎯 کانال‌های مقصد", resize=True)], [Button.text("➕ افزودن کانال مبدا", resize=True), Button.text("➕ افزودن کانال مقصد", resize=True)], - [Button.text("🎭 تنظیم شخصیت کانال‌ها", resize=True), Button.text("⏰ زمان‌بندی و خواب کانال‌ها", resize=True)], [Button.text("❓ راهنمای سیستم", resize=True)] ] +def get_cancel_button(): + return [[Button.inline("❌ انصراف و بازگشت به منو", data="cancel_state")]] + class AdminBotService: def __init__( self, @@ -49,6 +53,8 @@ class AdminBotService: self.client = TelegramClient(self.session_name, self.api_id, self.api_hash, proxy=get_telegram_proxy()) self.collector = None self.preview_cache: Dict[str, str] = {} + # User step-by-step state: {user_id: {"action": "...", "target_id": ...}} + self.user_states: Dict[int, Dict[str, Any]] = {} def set_collector(self, collector): self.collector = collector @@ -82,6 +88,98 @@ class AdminBotService: logger.info("Admin Bot connected successfully.") self._register_handlers() + # --- Helper to resolve channel from forwarded message or text --- + async def _resolve_channel(self, event: events.NewMessage.Event) -> Tuple[Optional[int], Optional[str], Optional[str], str]: + """Extract (channel_id, title, username, error_message) using Userbot or Bot Client.""" + client_to_use = self.collector.client if (self.collector and self.collector.client.is_connected()) else self.client + + # 1. Forwarded Message + if event.message.fwd_from: + fwd = event.message.fwd_from + if fwd.from_id: + try: + entity = await client_to_use.get_entity(fwd.from_id) + raw_id = entity.id + ch_id = int(f"-100{raw_id}") if not str(raw_id).startswith("-100") else raw_id + title = getattr(entity, "title", getattr(entity, "first_name", "کانال")) + username = getattr(entity, "username", None) + return ch_id, title, username, "" + except Exception as e: + logger.debug(f"Failed to resolve fwd entity: {e}") + + if fwd.from_name: + return None, None, None, f"⚠️ فوروارد از کانال به صورت ناشناس ارسال شده است. لطفا آیدی، یوزرنیم یا لینک کانال (مثل @channel یا https://t.me/channel) را ارسال کنید." + + # 2. Text input (Username / Link / Numeric ID) + text = (event.raw_text or "").strip() + if not text: + return None, None, None, "پیامی دریافت نشد." + + clean = text + if "t.me/" in clean: + clean = clean.split("t.me/")[-1].replace("/", "").strip() + if clean.startswith("@"): + clean = clean[1:] + + try: + # Try numeric ID directly + if clean.replace("-", "").isdigit(): + num_id = int(clean) + entity = await client_to_use.get_entity(num_id) + else: + entity = await client_to_use.get_entity(clean) + + raw_id = entity.id + ch_id = int(f"-100{raw_id}") if not str(raw_id).startswith("-100") else raw_id + title = getattr(entity, "title", getattr(entity, "first_name", clean)) + username = getattr(entity, "username", clean if not clean.replace("-", "").isdigit() else None) + return ch_id, title, username, "" + except Exception as e: + return None, None, None, f"❌ امکان واکشی اطلاعات کانال وجود نداشت ({e}). لطفا مطمئن شوید ربات یا یوزربات در کانال عضو است." + + # --- Target Management Card Builder --- + async def _render_target_config(self, target_id: int) -> Tuple[str, List[List[Button]]]: + target = await self.repo.get_target_by_id(target_id) + if not target: + return "❌ کانال یافت نشد.", [] + + qsize = await self.queue.get_target_queue_size(target.id) if self.queue else 0 + sleep_st = f"🌙 فعال ({target.sleep_start_hour}:00 تا {target.sleep_end_hour}:00)" if target.is_sleep_enabled else "☀️ غیرفعال" + + card_text = ( + f"🎯 تنظیمات کانال مقصد: {target.title}\n\n" + f"• 🆔 شناسه کانال: {target.channel_id} (ID: {target.id})\n" + f"• 🔗 یوزرنیم: @{target.username or 'ندارد'}\n" + f"• ⏱ فاصله ارسال پست‌ها: هر {target.post_interval_min} دقیقه\n" + f"• 🌙 وضعیت ساعت خواب: {sleep_st}\n" + f"• 📥 پست‌های منتظر در صف: {qsize} پست\n\n" + f"🎭 شخصیت و لحن نگارش:\n" + f"{target.personality or 'پیش‌فرض (رسمی و روان)'}\n\n" + f"🏷 فوتر و هشتگ‌های اختصاصی:\n" + f"{target.custom_footer or 'ندارد'}\n\n" + "👇 برای ویرایش هر بخش روی دکمه مربوطه کلیک کنید:" + ) + + buttons = [ + [ + Button.inline("🎭 تغییر لحن و استایل", data=f"st_pers:{target.id}"), + Button.inline("🏷 تغییر فوتر و تگ‌ها", data=f"st_foot:{target.id}") + ], + [ + Button.inline("⏱ تغییر فاصله ارسال", data=f"st_intv:{target.id}"), + Button.inline("🌙 تنظیم ساعت خواب", data=f"st_slp:{target.id}") + ] + ] + if target.is_sleep_enabled: + buttons.append([Button.inline("☀️ غیرفعال‌سازی ساعت خواب", data=f"dis_slp:{target.id}")]) + + buttons.append([ + Button.inline("🗑 حذف این کانال مقصد", data=f"del_trg:{target.id}"), + Button.inline("🔙 بازگشت به لیست کانال‌ها", data="list_trg") + ]) + return card_text, buttons + + # --- Review Post UI Helpers --- def _build_raw_post_keyboard(self, post: Post, targets: List[TargetChannel]): buttons = [] sent_target_ids = set() @@ -162,6 +260,8 @@ class AdminBotService: await event.reply(f"⛔ دسترسی غیرمجاز. شناسه عددی شما: {event.sender_id}", parse_mode="html") return + self.user_states.pop(event.sender_id, None) + userbot_status = "🔴 قطع / نیاز به لاگین" if self.collector and self.collector.client.is_connected() and await self.collector.client.is_user_authorized(): me = await self.collector.client.get_me() @@ -171,7 +271,7 @@ class AdminBotService: "👋 به پنل مدیریت سیستم هوشمند کپی‌کار خوش آمدید!\n\n" f"• 🤖 وضعیت ربات جمع‌آوری‌کننده: {userbot_status}\n" f"• 📋 شناسه کانال ادمین‌ها: {self.review_channel_id}\n\n" - "از دکمه‌های زیر برای مدیریت کانال‌ها، تنظیم شخصیت و زمان‌بندی استفاده کنید:" + "برای مدیریت کانال‌ها از دکمه‌های زیر استفاده کنید:" ) await event.reply(welcome_text, parse_mode="html", buttons=get_persian_main_menu()) @@ -207,37 +307,17 @@ class AdminBotService: try: sent = await self.collector.client.send_code_request(self.collector.phone) self.collector.phone_code_hash = sent.phone_code_hash + self.user_states[event.sender_id] = {"action": "wait_login_code"} await msg.edit( f"📩 کد تایید ارسال شد!\n\n" - f"کد ارسال شده به شماره {self.collector.phone} را به این صورت ارسال کنید:\n" - f"/code 12345", - parse_mode="html" + f"لطفا کد دریافت شده به شماره {self.collector.phone} را در پیام بعدی ارسال کنید:\n" + f"(یا به فرمت /code 12345)", + parse_mode="html", + buttons=get_cancel_button() ) except Exception as e: await msg.edit(f"❌ خطا در درخواست کد: {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: - return - code = event.pattern_match.group(1).strip() - msg = await event.reply("⏳ در حال بررسی کد تایید...") - result = await self.collector.submit_code(code) - await msg.edit(result, parse_mode="html", buttons=get_persian_main_menu()) - - @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: - return - pwd = event.pattern_match.group(1).strip() - msg = await event.reply("⏳ در حال تایید رمز دو مرحله‌ای...") - result = await self.collector.submit_password(pwd) - await msg.edit(result, parse_mode="html", buttons=get_persian_main_menu()) - # --- Sources Management --- @self.client.on(events.NewMessage(pattern=r"(?i)^(/sources|📡 کانال‌های مبدا)$")) async def cmd_sources(event: events.NewMessage.Event): @@ -245,57 +325,38 @@ class AdminBotService: return sources = await self.repo.get_active_sources() if not sources: - await event.reply("هیچ کانال مبدایی ثبت نشده است. از دکمه ➕ افزودن کانال مبدا استفاده کنید.", parse_mode="html") + await event.reply("هیچ کانال مبدایی ثبت نشده است. روی ➕ افزودن کانال مبدا بزنید.", parse_mode="html") return await event.reply(f"📡 کانال‌های مبدا فعال ({len(sources)} کانال):", parse_mode="html") for s in sources: card = ( - f"📢 {s.title or 'کانال'}\n" + f"📢 {s.title or 'کانال مبدا'}\n" f"• شناسه: {s.channel_id}\n" f"• یوزرنیم: @{s.username or 'ندارد'}" ) buttons = [ [ - Button.inline("📥 استخراج ۲۰ پست گذشته", data=f"hist:{s.channel_id}:20"), - Button.inline("📥 استخراج ۵۰ پست گذشته", data=f"hist:{s.channel_id}:50"), + Button.inline("📥 استخراج ۲۰ پست", data=f"hist:{s.channel_id}:20"), + Button.inline("📥 استخراج ۵۰ پست", data=f"hist:{s.channel_id}:50"), + ], + [ + Button.inline("🗑 حذف این کانال مبدا", data=f"del_src:{s.id}") ] ] await event.reply(card, parse_mode="html", buttons=buttons) @self.client.on(events.NewMessage(pattern=r"(?i)^(➕ افزودن کانال مبدا)$")) - async def cmd_add_source_guide(event: events.NewMessage.Event): + async def cmd_add_source_prompt(event: events.NewMessage.Event): if not self.is_admin(event.sender_id): return + self.user_states[event.sender_id] = {"action": "wait_source_fwd"} guide = ( - "➕ راهنمای افزودن کانال مبدا:\n\n" - "دستور را با فرمت زیر ارسال کنید:\n" - "/add_source <شناسه_عددی_کانال> <عنوان> [یوزرنیم]\n\n" - "مثال:\n" - "/add_source -1001234567890 اخبار_فوری fouri_news" - ) - 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) - - buttons = [ - [ - Button.inline("📥 استخراج ۲۰ پست گذشته این کانال", data=f"hist:{ch_id}:20"), - Button.inline("📥 استخراج ۵۰ پست گذشته این کانال", data=f"hist:{ch_id}:50") - ] - ] - await event.reply( - f"✅ کانال مبدا {title} ({ch_id}) با موفقیت افزوده شد.\nآیا می‌خواهید پست‌های قبلی این کانال را هم دریافت کنید؟", - parse_mode="html", - buttons=buttons + "📡 افزودن کانال مبدا جدید:\n\n" + "ساده‌ترین روش: یک پیام از کانال مورد نظر به این ربات فوروارد (Forward) کنید!\n\n" + "(یا می‌توانید آیدی، یوزرنیم یا لینک کانال مثل @channel_username را بفرستید)" ) + await event.reply(guide, parse_mode="html", buttons=get_cancel_button()) # --- Targets Management --- @self.client.on(events.NewMessage(pattern=r"(?i)^(/targets|🎯 کانال‌های مقصد)$")) @@ -304,196 +365,25 @@ class AdminBotService: return targets = await self.repo.get_active_targets() if not targets: - await event.reply("هیچ کانال مقصدی ثبت نشده است. از دکمه ➕ افزودن کانال مقصد استفاده کنید.", parse_mode="html") + await event.reply("هیچ کانال مقصدی ثبت نشده است. روی ➕ افزودن کانال مقصد بزنید.", parse_mode="html") return - lines = ["🎯 کانال‌های مقصد برای انتشار:\n"] + + await event.reply(f"🎯 کانال‌های مقصد برای انتشار ({len(targets)} کانال):\nبرای تنظیمات هر کانال، روی دکمه آن بزنید:", parse_mode="html") for t in targets: - qsize = await self.queue.get_target_queue_size(t.id) if self.queue else 0 - sleep_info = f"{t.sleep_start_hour}:00 تا {t.sleep_end_hour}:00" if t.is_sleep_enabled else "غیرفعال" - lines.append( - f"• {t.title} (شناسه: {t.channel_id} | ID دیتابیس: {t.id})\n" - f" ⏱ فاصله ارسال: هر {t.post_interval_min} دقیقه\n" - f" 🌙 ساعت خواب: {sleep_info}\n" - f" 📥 تعداد در صف ارسال: {qsize} پست\n" - f" 🎭 شخصیت: {t.personality or 'پیش‌فرض'}\n" - f" 🏷 فوتر: {t.custom_footer or 'ندارد'}\n" - ) - await event.reply("\n".join(lines), parse_mode="html", buttons=get_persian_main_menu()) + card, buttons = await self._render_target_config(t.id) + await event.reply(card, parse_mode="html", buttons=buttons) @self.client.on(events.NewMessage(pattern=r"(?i)^(➕ افزودن کانال مقصد)$")) - async def cmd_add_target_guide(event: events.NewMessage.Event): + async def cmd_add_target_prompt(event: events.NewMessage.Event): if not self.is_admin(event.sender_id): return + self.user_states[event.sender_id] = {"action": "wait_target_fwd"} guide = ( - "🎯 راهنمای افزودن کانال مقصد:\n\n" - "دستور را با فرمت زیر ارسال کنید:\n" - "/add_target <شناسه_کانال> <عنوان> [یوزرنیم]\n\n" - "مثال:\n" - "/add_target -1009876543210 دنیای_هوش_مصنوعی ai_world_chan" - ) - await event.reply(guide, parse_mode="html") - - @self.client.on(events.NewMessage(pattern=r"^/add_target\s+(-?\d+)\s+([^\s]+)(?:\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) - username = event.pattern_match.group(3) - tid = await self.repo.add_target(channel_id=ch_id, title=title, username=username) - await event.reply( - f"✅ کانال مقصد {title} افزوده شد (ID دیتابیس: {tid}).\n\n" - f"اکنون می‌توانید با دکمه 🎭 تنظیم شخصیت کانال‌ها لحن و با ⏰ زمان‌بندی و خواب فواصل ارسال را تنظیم کنید.", - parse_mode="html", - buttons=get_persian_main_menu() - ) - - # --- Schedule & Sleep Management --- - @self.client.on(events.NewMessage(pattern=r"(?i)^(/schedule|⏰ زمان‌بندی و خواب کانال‌ها)$")) - async def cmd_schedule(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("ابتدا یک کانال مقصد اضافه کنید.", parse_mode="html") - return - - text = ( - "⏰ مدیریت فاصله ارسال و ساعت خواب کانال‌ها:\n\n" - "1. تنظیم فاصله ارسال پست‌ها (به دقیقه):\n" - "/set_interval <شناسه_دیتابیس_کانال> <دقیقه>\n" - "مثال (ارسال هر ۲۰ دقیقه یک پست):\n" - "/set_interval 1 20\n\n" - "2. تنظیم ساعت خواب (عدم ارسال پیام در این ساعات):\n" - "/set_sleep <شناسه_دیتابیس_کانال> <ساعت_شروع> <ساعت_پایان>\n" - "مثال (خواب از ساعت ۲۳ شب تا ۸ صبح):\n" - "/set_sleep 1 23 8\n\n" - "3. غیرفعال کردن ساعت خواب:\n" - "/disable_sleep <شناسه_دیتابیس_کانال>\n\n" - "وضعیت فعلی کانال‌ها:\n" - ) - for t in targets: - qsize = await self.queue.get_target_queue_size(t.id) if self.queue else 0 - sleep_st = f"🌙 خواب از {t.sleep_start_hour}:00 تا {t.sleep_end_hour}:00" if t.is_sleep_enabled else "☀️ بدون ساعت خواب" - text += f"• ID: {t.id} | {t.title} ➔ هر {t.post_interval_min} دقیقه | {sleep_st} (صف: {qsize} پست)\n" - - await event.reply(text, parse_mode="html", buttons=get_persian_main_menu()) - - @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)) - interval_min = int(event.pattern_match.group(2)) - target = await self.repo.get_target_by_id(target_id) - if not target: - await event.reply(f"❌ کانال مقصد با شناسه {target_id} یافت نشد.") - return - await self.repo.update_target_schedule(target_id=target_id, post_interval_min=interval_min) - await event.reply( - f"✅ فاصله ارسال برای کانال {target.title} به هر {interval_min} دقیقه تنظیم شد.", - parse_mode="html", - buttons=get_persian_main_menu() - ) - - @self.client.on(events.NewMessage(pattern=r"^/set_sleep\s+(\d+)\s+(\d+)\s+(\d+)")) - async def cmd_set_sleep(event: events.NewMessage.Event): - if not self.is_admin(event.sender_id): - return - target_id = int(event.pattern_match.group(1)) - start_h = int(event.pattern_match.group(2)) - end_h = int(event.pattern_match.group(3)) - target = await self.repo.get_target_by_id(target_id) - if not target: - await event.reply(f"❌ کانال مقصد با شناسه {target_id} یافت نشد.") - return - await self.repo.update_target_schedule( - target_id=target_id, - sleep_start_hour=start_h, - sleep_end_hour=end_h, - is_sleep_enabled=True - ) - await event.reply( - f"🌙 ساعت خواب برای کانال {target.title} از ساعت {start_h}:00 تا {end_h}:00 فعال شد.\n(در این بازه هیچ پیامی ارسال نخواهد شد و در صف منتظر می‌ماند).", - parse_mode="html", - buttons=get_persian_main_menu() - ) - - @self.client.on(events.NewMessage(pattern=r"^/disable_sleep\s+(\d+)")) - async def cmd_disable_sleep(event: events.NewMessage.Event): - if not self.is_admin(event.sender_id): - return - target_id = int(event.pattern_match.group(1)) - target = await self.repo.get_target_by_id(target_id) - if not target: - await event.reply(f"❌ کانال مقصد با شناسه {target_id} یافت نشد.") - return - await self.repo.update_target_schedule(target_id=target_id, is_sleep_enabled=False) - await event.reply(f"☀️ ساعت خواب برای کانال {target.title} غیرفعال شد.", parse_mode="html", buttons=get_persian_main_menu()) - - # --- Channel Personality & Tags Configuration --- - @self.client.on(events.NewMessage(pattern=r"(?i)^(/personality|🎭 تنظیم شخصیت کانال‌ها)$")) - async def cmd_personality(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("ابتدا با استفاده از ➕ افزودن کانال مقصد یک کانال مقصد اضافه کنید.", parse_mode="html") - return - - text = ( - "🎭 تنظیم شخصیت، لحن و تگ‌های کانال‌های مقصد:\n\n" - "برای تغییر لحن و استایل نگارش کانال از دستور زیر استفاده کنید:\n" - "/set_personality <شناسه_دیتابیس_کانال> <توضیحات لحن>\n\n" - "مثال:\n" - "/set_personality 1 لحن جذاب و ژورنالیستی، استفاده از تیترهای بولد و ایموجی‌های مرتبط\n\n" - "برای تنظیم فوتر و هشتگ‌های اختصاصی انتهای پست:\n" - "/set_footer <شناسه_دیتابیس_کانال> <تگ‌ها یا آیدی کانال>\n\n" - "مثال:\n" - "/set_footer 1 🆔 @my_tech_chan\n#تکنولوژی #هوش_مصنوعی\n\n" - "کانال‌های موجود و شخصیت فعلی:\n" - ) - for t in targets: - text += ( - f"• ID: {t.id} | {t.title}\n" - f" 🎭 لحن: {t.personality or 'پیش‌فرض'}\n" - f" 🏷 فوتر: {t.custom_footer or 'ندارد'}\n\n" - ) - await event.reply(text, parse_mode="html", buttons=get_persian_main_menu()) - - @self.client.on(events.NewMessage(pattern=r"^/set_personality\s+(\d+)\s+(.+)")) - async def cmd_set_personality(event: events.NewMessage.Event): - if not self.is_admin(event.sender_id): - return - target_id = int(event.pattern_match.group(1)) - personality = event.pattern_match.group(2).strip() - target = await self.repo.get_target_by_id(target_id) - if not target: - await event.reply(f"❌ کانال مقصد با شناسه {target_id} یافت نشد.") - return - await self.repo.update_target_personality(target_id, personality) - await event.reply( - f"✅ شخصیت و لحن کانال {target.title} با موفقیت به روز شد:\n\n{personality}", - parse_mode="html", - buttons=get_persian_main_menu() - ) - - @self.client.on(events.NewMessage(pattern=r"^/set_footer\s+(\d+)\s+([\s\S]+)")) - async def cmd_set_footer(event: events.NewMessage.Event): - if not self.is_admin(event.sender_id): - return - target_id = int(event.pattern_match.group(1)) - footer = event.pattern_match.group(2).strip() - target = await self.repo.get_target_by_id(target_id) - if not target: - await event.reply(f"❌ کانال مقصد با شناسه {target_id} یافت نشد.") - return - await self.repo.update_target_footer(target_id, footer) - await event.reply( - f"✅ فوتر اختصاصی کانال {target.title} به روز شد:\n\n{footer}", - parse_mode="html", - buttons=get_persian_main_menu() + "🎯 افزودن کانال مقصد جدید:\n\n" + "ساده‌ترین روش: یک پیام از کانال مورد نظر به این ربات فوروارد (Forward) کنید!\n\n" + "(یا می‌توانید آیدی، یوزرنیم یا لینک کانال مثل @channel_username را بفرستید)" ) + await event.reply(guide, parse_mode="html", buttons=get_cancel_button()) # --- Help --- @self.client.on(events.NewMessage(pattern=r"(?i)^(/help|❓ راهنمای سیستم)$")) @@ -501,13 +391,141 @@ class AdminBotService: if not self.is_admin(event.sender_id): return help_text = ( - "📖 راهنمای فرآیند کاری سیستم کپی‌کار:\n\n" - "1. 📥 دریافت آنی: پست‌های مبدا فوری و بدون تاخیر در کانال ادمین قرار می‌گیرند.\n" - "2. 🎯 بازنویسی بر اساس مقصد: با زدن دکمه کانال مقصد، هوش مصنوعی پست را متناسب با شخصیت و فوتر آن کانال بازنویسی می‌کند.\n" - "3. ⏳ صف ارسال زمان‌بندی شده: پس از تایید، پست در صف ردیس کانال مقصد قرار می‌گیرد و با رعایت فاصله زمانی (Interval) و ساعات خواب (Sleep) به ترتیب منتشر می‌شود." + "📖 راهنمای سیستم کپی‌کار:\n\n" + "• افزودن آسان کانال‌ها: فقط کافیست یک پیام از کانال را به ربات فوروارد کنید تا شناسه و نام آن خودکار ثبت شود!\n" + "• تنظیمات با دکمه: در بخش کانال‌های مقصد، روی هر کانال دکمه‌های تغییر لحن، تگ‌ها، فاصله زمانی و ساعت خواب وجود دارد.\n" + "• بازنویسی هوشمند: پست‌های مبدا به کانال ادمین می‌آیند و با لمس دکمه هر مقصد، متن با هوش مصنوعی و لحن همان کانال بازنویسی می‌شود." ) await event.reply(help_text, parse_mode="html", buttons=get_persian_main_menu()) + # --- Step-by-Step Parameter & Text Input Message Handler --- + @self.client.on(events.NewMessage) + async def handle_user_input(event: events.NewMessage.Event): + if not self.is_admin(event.sender_id): + return + + text = (event.raw_text or "").strip() + + # Ignore main menu commands + if text in ["/start", "/menu", "منو", "/stats", "📊 آمار و وضعیت ناوگان", + "/sources", "📡 کانال‌های مبدا", "/targets", "🎯 کانال‌های مقصد", + "➕ افزودن کانال مبدا", "➕ افزودن کانال مقصد", "❓ راهنمای سیستم", + "/request_code", "🔑 درخواست کد لاگین"]: + return + + # Cancel command + if text in ["/cancel", "انصراف", "لغو"]: + self.user_states.pop(event.sender_id, None) + await event.reply("❌ عملیات لغو شد.", buttons=get_persian_main_menu()) + return + + state = self.user_states.get(event.sender_id) + if not state: + # Direct /code or /password handling + if text.startswith("/code "): + code = text.split("/code ", 1)[1].strip() + res = await self.collector.submit_code(code) + await event.reply(res, parse_mode="html", buttons=get_persian_main_menu()) + elif text.startswith("/password "): + pwd = text.split("/password ", 1)[1].strip() + res = await self.collector.submit_password(pwd) + await event.reply(res, parse_mode="html", buttons=get_persian_main_menu()) + return + + action = state.get("action") + + # 1. Waiting for Source Channel Forward or Text + if action == "wait_source_fwd": + ch_id, title, username, err = await self._resolve_channel(event) + if err or not ch_id: + await event.reply(err or "خطا در دریافت اطلاعات کانال. لطفا مجدد ارسال کنید:", buttons=get_cancel_button()) + return + + self.user_states.pop(event.sender_id, None) + await self.repo.add_source(channel_id=ch_id, title=title, username=username) + + buttons = [ + [ + Button.inline("📥 استخراج ۲۰ پست گذشته", data=f"hist:{ch_id}:20"), + Button.inline("📥 استخراج ۵۰ پست گذشته", data=f"hist:{ch_id}:50") + ] + ] + await event.reply( + f"✅ کانال مبدا {title} با شناسه {ch_id} با موفقیت افزوده شد!\n\n" + f"آیا مایلید پست‌های قبلی این کانال هم دریافت شود؟", + parse_mode="html", + buttons=buttons + ) + + # 2. Waiting for Target Channel Forward or Text + elif action == "wait_target_fwd": + ch_id, title, username, err = await self._resolve_channel(event) + if err or not ch_id: + await event.reply(err or "خطا در دریافت اطلاعات کانال. لطفا مجدد ارسال کنید:", buttons=get_cancel_button()) + return + + self.user_states.pop(event.sender_id, None) + target_id = await self.repo.add_target(channel_id=ch_id, title=title, username=username) + + card, buttons = await self._render_target_config(target_id) + await event.reply( + f"✅ کانال مقصد {title} افزوده شد!\n\nاکنون می‌توانید با دکمه‌های زیر لحن و زمان‌بندی آن را تنظیم کنید:", + parse_mode="html" + ) + await event.reply(card, parse_mode="html", buttons=buttons) + + # 3. Waiting for Target Personality + elif action == "wait_personality": + target_id = state.get("target_id") + self.user_states.pop(event.sender_id, None) + await self.repo.update_target_personality(target_id, text) + card, buttons = await self._render_target_config(target_id) + await event.reply("✅ لحن و شخصیت کانال با موفقیت به روز شد!", parse_mode="html") + await event.reply(card, parse_mode="html", buttons=buttons) + + # 4. Waiting for Target Custom Footer + elif action == "wait_footer": + target_id = state.get("target_id") + self.user_states.pop(event.sender_id, None) + await self.repo.update_target_footer(target_id, text) + card, buttons = await self._render_target_config(target_id) + await event.reply("✅ فوتر و تگ‌های اختصاصی کانال با موفقیت به روز شد!", parse_mode="html") + await event.reply(card, parse_mode="html", buttons=buttons) + + # 5. Waiting for Target Post Interval + elif action == "wait_interval": + target_id = state.get("target_id") + if not text.isdigit() or int(text) < 1: + await event.reply("⚠️ لطفا یک عدد معتبر (به دقیقه) ارسال کنید:", buttons=get_cancel_button()) + return + interval_min = int(text) + self.user_states.pop(event.sender_id, None) + await self.repo.update_target_schedule(target_id=target_id, post_interval_min=interval_min) + card, buttons = await self._render_target_config(target_id) + await event.reply(f"✅ فاصله ارسال به هر {interval_min} دقیقه تغییر یافت!", parse_mode="html") + await event.reply(card, parse_mode="html", buttons=buttons) + + # 6. Waiting for Sleep Window Hours + elif action == "wait_sleep": + target_id = state.get("target_id") + parts = re.findall(r"\d+", text) + if len(parts) < 2: + await event.reply("⚠️ لطفا ساعت شروع و پایان را به این شکل بفرستید: 23 8 (برای ۲۳:۰۰ تا ۰۸:۰۰)", parse_mode="html", buttons=get_cancel_button()) + return + start_h = int(parts[0]) % 24 + end_h = int(parts[1]) % 24 + self.user_states.pop(event.sender_id, None) + await self.repo.update_target_schedule(target_id=target_id, sleep_start_hour=start_h, sleep_end_hour=end_h, is_sleep_enabled=True) + card, buttons = await self._render_target_config(target_id) + await event.reply(f"🌙 ساعت خواب از {start_h}:00 تا {end_h}:00 فعال شد!", parse_mode="html") + await event.reply(card, parse_mode="html", buttons=buttons) + + # 7. Waiting for Login Code + elif action == "wait_login_code": + self.user_states.pop(event.sender_id, None) + res = await self.collector.submit_code(text) + await event.reply(res, parse_mode="html", buttons=get_persian_main_menu()) + # --- Inline Callback Queries --- @self.client.on(events.CallbackQuery) async def on_callback(event: events.CallbackQuery.Event): @@ -517,8 +535,96 @@ class AdminBotService: data = event.data.decode("utf-8") - # 1. Historical Scraping Callback - if data.startswith("hist:"): + # 0. Cancel Active State + if data == "cancel_state": + self.user_states.pop(event.sender_id, None) + await event.edit("❌ عملیات لغو شد.") + await event.answer("لغو شد.") + + # --- Target Channel Config Buttons --- + elif data.startswith("st_pers:"): + target_id = int(data.split(":")[1]) + target = await self.repo.get_target_by_id(target_id) + self.user_states[event.sender_id] = {"action": "wait_personality", "target_id": target_id} + await event.reply( + f"🎭 تغییر لحن و شخصیت کانال «{target.title}»:\n\n" + "لطفا در پیام بعدی، لحن و استایل نگارش مورد نظرتان را بفرستید:\n" + "(مثال: لحن دوستانه و پرانرژی همراه با ایموجی و تیترهای جذاب)", + parse_mode="html", + buttons=get_cancel_button() + ) + await event.answer() + + elif data.startswith("st_foot:"): + target_id = int(data.split(":")[1]) + target = await self.repo.get_target_by_id(target_id) + self.user_states[event.sender_id] = {"action": "wait_footer", "target_id": target_id} + await event.reply( + f"🏷 تغییر فوتر و تگ‌های اختصاصی «{target.title}»:\n\n" + "لطفا متن فوتر، آیدی یا هشتگ‌هایی که می‌خواهید انتهای هر پست قرار بگیرد را بفرستید:\n" + "(مثال: 🆔 @my_chan\n#فناوری #اخبار)", + parse_mode="html", + buttons=get_cancel_button() + ) + await event.answer() + + elif data.startswith("st_intv:"): + target_id = int(data.split(":")[1]) + target = await self.repo.get_target_by_id(target_id) + self.user_states[event.sender_id] = {"action": "wait_interval", "target_id": target_id} + await event.reply( + f"⏱ تغییر فاصله ارسال کانال «{target.title}»:\n\n" + "فاصله زمانی بین ارسال هر پست را به دقیقه بفرستید:\n" + "(مثال: 30 برای ارسال هر نیم ساعت یک پست)", + parse_mode="html", + buttons=get_cancel_button() + ) + await event.answer() + + elif data.startswith("st_slp:"): + target_id = int(data.split(":")[1]) + target = await self.repo.get_target_by_id(target_id) + self.user_states[event.sender_id] = {"action": "wait_sleep", "target_id": target_id} + await event.reply( + f"🌙 تنظیم ساعت خواب کانال «{target.title}»:\n\n" + "ساعت شروع و پایان خواب را با یک فاصله بفرستید:\n" + "(مثال: 23 8 برای عدم ارسال پست از ساعت ۲۳:۰۰ تا ۰۸:۰۰ صبح)", + parse_mode="html", + buttons=get_cancel_button() + ) + await event.answer() + + elif data.startswith("dis_slp:"): + target_id = int(data.split(":")[1]) + await self.repo.update_target_schedule(target_id=target_id, is_sleep_enabled=False) + card, buttons = await self._render_target_config(target_id) + await event.edit(card, parse_mode="html", buttons=buttons) + await event.answer("☀️ ساعت خواب غیرفعال شد.") + + elif data.startswith("del_trg:"): + target_id = int(data.split(":")[1]) + await self.repo.delete_target(target_id) + await event.edit("🗑 کانال مقصد با موفقیت حذف شد.", parse_mode="html", buttons=None) + await event.answer("کانال مقصد حذف شد.") + + elif data.startswith("del_src:"): + source_id = int(data.split(":")[1]) + await self.repo.delete_source(source_id) + await event.edit("🗑 کانال مبدا با موفقیت حذف شد.", parse_mode="html", buttons=None) + await event.answer("کانال مبدا حذف شد.") + + elif data == "list_trg": + targets = await self.repo.get_active_targets() + if not targets: + await event.edit("هیچ کانال مقصدی ثبت نشده است.") + return + await event.edit("🎯 لیست کانال‌های مقصد به روز شد:") + for t in targets: + card, buttons = await self._render_target_config(t.id) + await event.reply(card, parse_mode="html", buttons=buttons) + + # --- Review Channel Buttons --- + elif data.startswith("hist:"): _, ch_id_str, limit_str = data.split(":") ch_id = int(ch_id_str) limit = int(limit_str) @@ -535,7 +641,6 @@ class AdminBotService: await self.collector.scrape_channel_history(channel_id=ch_id, limit=limit, progress_callback=progress_notify) await event.answer("فرآیند دریافت آغاز شد.") - # 2. Target Selected -> Trigger On-Demand AI Rewrite for that Target elif data.startswith("sel_trg:"): _, post_id_str, target_id_str = data.split(":") post_id = int(post_id_str) @@ -579,10 +684,8 @@ class AdminBotService: Button.inline("🔙 انصراف / بازگشت به پست اصلی", data=f"cancel:{post_id}") ] ] - await event.edit(preview_caption, parse_mode="html", buttons=preview_buttons) - # 3. Add to Target's Redis Queue elif data.startswith("pub:"): _, post_id_str, target_id_str = data.split(":") post_id = int(post_id_str) @@ -597,7 +700,6 @@ class AdminBotService: cache_key = f"{post_id}:{target_id}" text_to_publish = self.preview_cache.get(cache_key) or post.raw_text or "" - # Push to Target's Redis Queue payload = { "post_id": post.id, "text": text_to_publish, @@ -609,10 +711,8 @@ class AdminBotService: await self.queue.push_target_post(target.id, payload) ADMIN_ACTIONS_TOTAL.labels(action="approved").inc() - # Update database record await self.repo.record_post_published_to_target(post_id, target.id, target.title or "Target") - # Reload updated post with publication history updated_post = await self.repo.get_post_by_id(post_id) targets = await self.repo.get_active_targets() new_caption = self._format_raw_post_caption(updated_post) @@ -625,7 +725,6 @@ class AdminBotService: ) await event.answer(f"به صف {target.title} افزوده شد!") - # 4. Cancel Preview & Restore Original Card elif data.startswith("cancel:"): _, post_id_str = data.split(":") post_id = int(post_id_str) @@ -641,7 +740,6 @@ class AdminBotService: await event.edit(caption, parse_mode="html", buttons=buttons) await event.answer("پیش‌نمایش لغو شد.") - # 5. Reject Post elif data.startswith("rej:"): _, post_id_str = data.split(":") post_id = int(post_id_str) @@ -656,7 +754,6 @@ class AdminBotService: ) await event.answer("پست بایگانی شد.") - # 6. Soft Delete & Remove Message from Admin Channel elif data.startswith("del_msg:"): _, post_id_str = data.split(":") post_id = int(post_id_str)