import os import logging from datetime import datetime, timezone from typing import Optional, List, Dict from telethon import TelegramClient, events, Button from db.models import Post, TargetChannel, SourceChannel from db.repository import Repository from core.metrics import ADMIN_ACTIONS_TOTAL, TARGET_ACTIVITY_TOTAL from core.proxy import get_telegram_proxy 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_persian_main_menu(): return [ [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)] ] class AdminBotService: def __init__( self, repo: Repository, ai_processor = None, bot_token: Optional[str] = None, api_id: Optional[int] = None, api_hash: Optional[str] = None, review_channel_id: Optional[int] = None, admin_user_ids: Optional[List[int]] = None, session_name: Optional[str] = None, ): self.repo = repo self.ai_processor = ai_processor self.bot_token = bot_token or os.getenv("BOT_TOKEN", "") self.api_id = api_id or int(os.getenv("API_ID", "0")) self.api_hash = api_hash or os.getenv("API_HASH", "") self.review_channel_id = review_channel_id or int(os.getenv("REVIEW_CHANNEL_ID", "0")) raw_admins = os.getenv("ADMIN_USER_IDS", "") self.admin_user_ids = admin_user_ids or [int(x.strip()) for x in raw_admins.split(",") if x.strip()] self.session_name = session_name or os.path.join(SESSION_DIR, "admin_bot.session") os.makedirs(os.path.dirname(self.session_name), exist_ok=True) self.client = TelegramClient(self.session_name, self.api_id, self.api_hash, proxy=get_telegram_proxy()) self.collector = None # In-memory store for pending rewritten previews: {f"{post_id}:{target_id}": rewritten_text} self.preview_cache: Dict[str, str] = {} def set_collector(self, collector): self.collector = collector def set_ai_processor(self, ai_processor): self.ai_processor = ai_processor def is_admin(self, user_id: int) -> bool: return not self.admin_user_ids or user_id in self.admin_user_ids async def notify_admins(self, text: str): """Broadcast Persian message to review channel and admin DMs.""" if self.review_channel_id: try: await self.client.send_message(self.review_channel_id, text, parse_mode="html") except Exception as e: logger.error(f"Failed to notify review channel: {e}") for admin_id in self.admin_user_ids: try: await self.client.send_message(admin_id, text, parse_mode="html", buttons=get_persian_main_menu()) except Exception as e: logger.debug(f"Could not send DM to admin {admin_id}: {e}") async def start(self): logger.info("Starting Admin Bot Service...") await self.client.start(bot_token=self.bot_token) logger.info("Admin Bot connected successfully.") self._register_handlers() def _build_raw_post_keyboard(self, post: Post, targets: List[TargetChannel]): buttons = [] # Check which targets this post has already been sent to sent_target_ids = set() if post.published_to: for item in post.published_to: if isinstance(item, dict) and "target_id" in item: sent_target_ids.add(int(item["target_id"])) row = [] for t in targets: is_sent = t.id in sent_target_ids label = f"✅ {t.title}" if is_sent else f"🎯 {t.title}" row.append(Button.inline(label, data=f"sel_trg:{post.id}:{t.id}")) if len(row) == 2: buttons.append(row) row = [] buttons.append([ Button.inline("❌ رد و بایگانی", data=f"rej:{post.id}"), Button.inline("🗑 حذف پیام از کانال", data=f"del_msg:{post.id}") ]) return buttons def _format_raw_post_caption(self, post: Post) -> str: published_lines = "" if post.published_to: published_lines = "📤 ارسال شده به کانال‌های:\n" for item in post.published_to: if isinstance(item, dict): t_title = item.get("target_title", "کانال مقصد") published_lines += f" • {t_title}\n" published_lines += "➖➖➖➖➖➖➖➖➖➖\n\n" caption = ( f"📥 پست جدید از مبدا ({post.source_channel_id}):\n\n" f"{published_lines}" f"{post.raw_text or ''}\n\n" f"👇 کانال مقصد مورد نظر را برای بازنویسی هوشمند انتخاب کنید:" ) return caption async def send_raw_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: return targets = await self.repo.get_active_targets() keyboard = self._build_raw_post_keyboard(post, targets) caption = self._format_raw_post_caption(post) try: if post.media_path and os.path.exists(post.media_path): msg = await self.client.send_file( self.review_channel_id, file=post.media_path, caption=caption, parse_mode="html", buttons=keyboard ) else: msg = await self.client.send_message( self.review_channel_id, caption, parse_mode="html", buttons=keyboard ) await self.repo.update_review_message_id(post.id, msg.id) except Exception as e: logger.error(f"Failed to send raw post {post.id} to review channel: {e}", exc_info=True) def _register_handlers(self): # --- Start / Menu --- @self.client.on(events.NewMessage(pattern=r"(?i)^(/start|/menu|منو)$")) async def cmd_start(event: events.NewMessage.Event): if not self.is_admin(event.sender_id): await event.reply(f"⛔ دسترسی غیرمجاز. شناسه عددی شما: {event.sender_id}", parse_mode="html") return 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() userbot_status = f"🟢 آنلاین ({me.first_name})" welcome_text = ( "👋 به پنل مدیریت سیستم هوشمند کپی‌کار خوش آمدید!\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()) # --- Statistics --- @self.client.on(events.NewMessage(pattern=r"(?i)^(/stats|📊 آمار و وضعیت ناوگان)$")) async def cmd_stats(event: events.NewMessage.Event): if not self.is_admin(event.sender_id): return pending_review = len(await self.repo.get_posts_by_status("pending_review", limit=5000)) published = len(await self.repo.get_posts_by_status("published", limit=5000)) rejected = len(await self.repo.get_posts_by_status("rejected", limit=5000)) redis_q = await self.collector.queue.qsize() if (self.collector and self.collector.queue) else 0 text = ( "📊 آمار زنده سیستم کپی‌کار:\n\n" f"• 📥 پست‌های موجود در صف ردیس: {redis_q}\n" f"• 📋 پست‌های در انتظار بررسی ادمین: {pending_review}\n" f"• 🚀 پست‌های منتشر شده: {published}\n" f"• ❌ پست‌های رد شده: {rejected}\n\n" "📈 داشبورد مانیتورینگ گرانافا: http://localhost:3000" ) await event.reply(text, parse_mode="html", buttons=get_persian_main_menu()) # --- Userbot Authentication --- @self.client.on(events.NewMessage(pattern=r"(?i)^(/request_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("سرویس کالکتور متصل نیست.") return msg = await event.reply("⏳ در حال ارسال درخواست کد لاگین به تلگرام...") 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"📩 کد تایید ارسال شد!\n\n" f"کد ارسال شده به شماره {self.collector.phone} را به این صورت ارسال کنید:\n" f"/code 12345", parse_mode="html" ) 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): if not self.is_admin(event.sender_id): return sources = await self.repo.get_active_sources() if not sources: 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.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"), ] ] 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): if not self.is_admin(event.sender_id): return 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 ) # --- Targets Management --- @self.client.on(events.NewMessage(pattern=r"(?i)^(/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("هیچ کانال مقصدی ثبت نشده است. از دکمه ➕ افزودن کانال مقصد استفاده کنید.", parse_mode="html") return lines = ["🎯 کانال‌های مقصد برای انتشار:\n"] for t in targets: lines.append( f"• {t.title} (شناسه: {t.channel_id} | ID دیتابیس: {t.id})\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()) @self.client.on(events.NewMessage(pattern=r"(?i)^(➕ افزودن کانال مقصد)$")) async def cmd_add_target_guide(event: events.NewMessage.Event): if not self.is_admin(event.sender_id): return 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() ) # --- 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() ) # --- Help --- @self.client.on(events.NewMessage(pattern=r"(?i)^(/help|❓ راهنمای سیستم)$")) async def cmd_help(event: events.NewMessage.Event): if not self.is_admin(event.sender_id): return help_text = ( "📖 راهنمای فرآیند کاری سیستم کپی‌کار:\n\n" "1. 📥 دریافت خام پست‌ها: پست‌ها بدون پردازش هوش مصنوعی مستقیماً به کانال ادمین‌ها ارسال می‌شوند.\n" "2. 🎯 انتخاب کانال مقصد: با لمس دکمه هر کانال، هوش مصنوعی پست را متناسب با شخصیت، استایل و فوتر اختصاصی همان کانال بازنویسی کرده و تمام تگ‌ها و لینک‌های مبدا را حذف می‌کند.\n" "3. 👁 پیش‌نمایش زنده: پیش‌نمایش بازنویسی شده به همراه دکمه تایید نهایی نمایش داده می‌شود.\n" "4. 🚀 انتشار و ارسال مجدد: پس از انتشار، پست اصلی در کانال ادمین بازگردانده شده و سابقه انتشار نمایش می‌یابد تا بتوانید آن را به سایر کانال‌ها نیز ارسال کنید." ) await event.reply(help_text, parse_mode="html", buttons=get_persian_main_menu()) # --- Interactive Inline Callbacks for Reviews & Target Rewrites --- @self.client.on(events.CallbackQuery) async def on_callback(event: events.CallbackQuery.Event): if not self.is_admin(event.sender_id): await event.answer("⛔ دسترسی غیرمجاز.", alert=True) return data = event.data.decode("utf-8") # 1. Historical Scraping Callback if data.startswith("hist:"): _, ch_id_str, limit_str = data.split(":") ch_id = int(ch_id_str) limit = int(limit_str) if not self.collector: await event.answer("کالکتور در دسترس نیست.", alert=True) return await event.edit(f"⏳ در حال دریافت {limit} پست گذشته از کانال {ch_id}...", parse_mode="html") async def progress_notify(txt: str): await event.edit(txt, parse_mode="html") 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) target_id = int(target_id_str) post = await self.repo.get_post_by_id(post_id) target = await self.repo.get_target_by_id(target_id) if not post or not target: await event.answer("پست یا کانال مقصد یافت نشد.", alert=True) return await event.answer(f"در حال بازنویسی برای {target.title}...") # Show loading placeholder loading_caption = ( f"🤖 در حال بازنویسی هوشمند برای کانال: {target.title}...\n" f"(اعمال لحن اختصاصی و حذف تگ‌های مبدا)" ) try: await event.edit(loading_caption, parse_mode="html", buttons=None) except Exception: pass # Run AI Rewrite rewritten_text = await self.ai_processor.rewrite_for_target(post.raw_text or "", target) cache_key = f"{post_id}:{target_id}" self.preview_cache[cache_key] = rewritten_text # Build Preview Card preview_caption = ( f"🎯 پیش‌نمایش بازنویسی شده برای: {target.title}\n" f"🎭 شخصیت و لحن: {target.personality or 'پیش‌فرض'}\n" f"➖➖➖➖➖➖➖➖➖➖\n\n" f"{rewritten_text}\n\n" f"➖➖➖➖➖➖➖➖➖➖\n" f"آیا این متن مورد تایید است؟" ) preview_buttons = [ [ Button.inline(f"✅ تایید و ارسال به {target.title}", data=f"pub:{post_id}:{target_id}"), ], [ Button.inline("🔙 انصراف / بازگشت به پست اصلی", data=f"cancel:{post_id}") ] ] await event.edit(preview_caption, parse_mode="html", buttons=preview_buttons) # 3. Publish to Target Confirmed elif data.startswith("pub:"): _, post_id_str, target_id_str = data.split(":") post_id = int(post_id_str) target_id = int(target_id_str) post = await self.repo.get_post_by_id(post_id) target = await self.repo.get_target_by_id(target_id) if not post or not target: await event.answer("اطلاعات یافت نشد.", alert=True) return cache_key = f"{post_id}:{target_id}" text_to_publish = self.preview_cache.get(cache_key) or post.raw_text or "" await event.answer(f"در حال ارسال به {target.title}...") try: # Publish via userbot or bot client_to_use = self.collector.client if (self.collector and self.collector.client.is_connected()) else self.client if post.media_path and os.path.exists(post.media_path): await client_to_use.send_file( target.channel_id, file=post.media_path, caption=text_to_publish, parse_mode="html" ) else: await client_to_use.send_message( target.channel_id, text_to_publish, parse_mode="html" ) # Record publication in database await self.repo.record_post_published_to_target(post_id, target.id, target.title or "Target") await self.repo.update_target_last_post(target.id) ADMIN_ACTIONS_TOTAL.labels(action="approved").inc() TARGET_ACTIVITY_TOTAL.labels(channel_id=str(target.channel_id), title=target.title or '').inc() # 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) new_buttons = self._build_raw_post_keyboard(updated_post, targets) await event.edit( f"✅ با موفقیت در {target.title} منتشر شد!\n\n{new_caption}", parse_mode="html", buttons=new_buttons ) except Exception as e: logger.error(f"Failed to publish to {target.channel_id}: {e}", exc_info=True) await event.answer(f"❌ خطا در ارسال به کانال: {e}", alert=True) # 4. Cancel Preview & Restore Original Card elif data.startswith("cancel:"): _, post_id_str = data.split(":") post_id = int(post_id_str) post = await self.repo.get_post_by_id(post_id) if not post: return targets = await self.repo.get_active_targets() caption = self._format_raw_post_caption(post) buttons = self._build_raw_post_keyboard(post, targets) 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) await self.repo.reject_post(post_id) ADMIN_ACTIONS_TOTAL.labels(action="rejected").inc() await event.edit( f"{event.text}\n\n❌ این پست توسط ادمین رد و بایگانی شد.", parse_mode="html", buttons=None ) 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) await self.repo.soft_delete_post(post_id) try: await event.delete() except Exception as e: logger.error(f"Could not delete message from review channel: {e}") await event.edit("🗑 این پیام حذف و بایگانی شد.", parse_mode="html", buttons=None) await event.answer("🗑 پیام از کانال ادمین حذف شد.", alert=False) async def stop(self): if self.client.is_connected(): await self.client.disconnect() logger.info("Admin Bot disconnected.")