feat(workflow): implement target channel personalities, raw review cards, on-demand AI rewrites and multi-target dispatch

This commit is contained in:
mamad
2026-08-27 22:11:33 +03:30
parent 4f5570850e
commit 26cd91a48f
10 changed files with 695 additions and 699 deletions
+491 -330
View File
@@ -1,29 +1,30 @@
import os
import logging
from typing import Optional, List
from datetime import datetime, timezone
from typing import Optional, List, Dict
from telethon import TelegramClient, events, Button
from db.models import Post, TargetChannel
from db.models import Post, TargetChannel, SourceChannel
from db.repository import Repository
from bot.keyboards import get_review_keyboard
from core.metrics import ADMIN_ACTIONS_TOTAL
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_main_menu_keyboard():
def get_persian_main_menu():
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)]
[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,
@@ -32,6 +33,7 @@ class AdminBotService:
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", "")
@@ -42,15 +44,20 @@ class AdminBotService:
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 message to review channel and all admin DMs."""
"""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")
@@ -59,340 +66,65 @@ class AdminBotService:
for admin_id in self.admin_user_ids:
try:
await self.client.send_message(admin_id, text, parse_mode="html", buttons=get_main_menu_keyboard())
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 Review Bot...")
logger.info("Starting Admin Bot Service...")
await self.client.start(bot_token=self.bot_token)
logger.info("Admin Review Bot connected successfully.")
logger.info("Admin Bot connected successfully.")
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
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"]))
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})"
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 = []
if row:
buttons.append(row)
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())
buttons.append([Button.inline("❌ رد و بایگانی پست", data=f"rej:{post.id}")])
return buttons
# --- Interactive Userbot Authentication Commands ---
@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
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 &lt;your_code&gt;</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}")
def _format_raw_post_caption(self, post: Post) -> str:
published_lines = ""
if post.published_to:
published_lines = "📤 <b>ارسال شده به کانال‌های:</b>\n"
for item in post.published_to:
if isinstance(item, dict):
t_title = item.get("target_title", "کانال مقصد")
published_lines += f" • <b>{t_title}</b>\n"
published_lines += "➖➖➖➖➖➖➖➖➖➖\n\n"
@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())
caption = (
f"📥 <b>پست جدید از مبدا (<code>{post.source_channel_id}</code>):</b>\n\n"
f"{published_lines}"
f"{post.raw_text or ''}\n\n"
f"<i>👇 کانال مقصد مورد نظر را برای بازنویسی هوشمند انتخاب کنید:</i>"
)
return caption
@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())
# --- Direct History Scraper Command ---
@self.client.on(events.NewMessage(pattern=r"^/scrape_history\s+(-?\d+)(?:\s+(\d+))?"))
async def cmd_scrape_history(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
ch_id = int(event.pattern_match.group(1))
limit = int(event.pattern_match.group(2)) if event.pattern_match.group(2) else 20
status_msg = await event.reply(f"⏳ Scraping the last <b>{limit}</b> posts from <code>{ch_id}</code> in the background...", parse_mode="html")
async def progress_notify(txt: str):
await status_msg.edit(txt, parse_mode="html", buttons=get_main_menu_keyboard())
await self.collector.scrape_channel_history(channel_id=ch_id, limit=limit, progress_callback=progress_notify)
# --- 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))
redis_q = await self.collector.queue.qsize() if (self.collector and self.collector.queue) else 0
text = (
"📊 <b>Copykar Fleet Metrics</b>\n\n"
f"• 📥 <b>Redis Incoming Queue:</b> {redis_q} (Pacing: 1 post / 2m)\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 with Interactive Scrape Buttons ---
@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.\nTap <b> Add Source Guide</b> below to add one.", parse_mode="html", buttons=get_main_menu_keyboard())
return
await event.reply(f"📡 <b>Monitored Sources ({len(sources)} Active):</b>\nTap any button below to scrape past posts:", parse_mode="html")
for s in sources:
card = (
f"📢 <b>{s.title or 'Channel'}</b>\n"
f"• ID: <code>{s.channel_id}</code>\n"
f"• Username: @{s.username or 'none'}"
)
buttons = [
[
Button.inline(f"📥 Scrape 20 Posts", data=f"hist:{s.channel_id}:20"),
Button.inline(f"📥 Scrape 50 Posts", data=f"hist:{s.channel_id}:50"),
]
]
await event.reply(card, parse_mode="html", buttons=buttons)
@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 &lt;channel_id&gt; &lt;title&gt; [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)
buttons = [
[
Button.inline(f"📥 Scrape 20 Posts Now", data=f"hist:{ch_id}:20"),
Button.inline(f"📥 Scrape 50 Posts Now", data=f"hist:{ch_id}:50")
]
]
await event.reply(
f"✅ Added source channel <b>{title}</b> (<code>{ch_id}</code>).\n\nWould you like to scrape past posts now?",
parse_mode="html",
buttons=buttons
)
# --- 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 &lt;channel_id&gt; &lt;title&gt; &lt;interval_minutes&gt; [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>Monitored Sources:</b> Tap <code>📡 Monitored Sources</code> to view channels and click <code>[📥 Scrape Posts]</code> on any channel.\n"
"2. <b>Review Flow:</b> AI scans posts, checks duplicates, and sends drafts to the review channel with inline approval buttons.\n"
"3. <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())
# --- Inline Callback Queries ---
@self.client.on(events.CallbackQuery)
async def on_callback(event: events.CallbackQuery.Event):
if not self.is_admin(event.sender_id):
await event.answer("⛔ You are not authorized.", alert=True)
return
data = event.data.decode("utf-8")
# 1. Historical Scraping Callbacks
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("Collector service not linked.", alert=True)
return
await event.edit(f"⏳ <b>Scraping the last {limit} posts</b> from <code>{ch_id}</code>...", parse_mode="html", buttons=None)
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(f"Started scraping {limit} posts!")
# 2. Approval Callbacks
elif data.startswith("appr:"):
_, post_id_str, target_id_str = data.split(":")
post_id = int(post_id_str)
target_id = int(target_id_str)
target = await self.repo.get_target_by_id(target_id)
target_title = target.title if target else f"Target #{target_id}"
await self.repo.approve_post(post_id, target_id)
ADMIN_ACTIONS_TOTAL.labels(action="approved").inc()
await event.edit(
f"{event.text}\n\n✅ <b>Approved for {target_title}</b> by admin.",
parse_mode="html",
buttons=None
)
await event.answer(f"Approved for {target_title}!")
# 3. Reject Callbacks
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❌ <b>Rejected</b> by admin.",
parse_mode="html",
buttons=None
)
await event.answer("Post rejected.")
async def send_review_post(self, post_id: int):
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 = get_review_keyboard(post.id, targets)
tags_str = ", ".join(post.tags) if post.tags else "None"
dup_warning = ""
if post.is_duplicate:
dup_warning = (
f"⚠️ <b>[DUPLICATE DETECTED]</b>\n"
f"<b>Reason:</b> {post.similarity_reason or 'Similar story already published'}\n"
f"<b>Matched Post ID:</b> #{post.duplicate_of_id}\n\n"
)
caption = (
f"📌 <b>Subject:</b> {post.subject or 'N/A'}\n"
f"🏷 <b>Tags:</b> <code>{tags_str}</code>\n\n"
f"{dup_warning}"
f"📝 <b>Generated Post Draft:</b>\n"
f"{post.ai_text or post.raw_text}\n\n"
f"<i>Source: Channel <code>{post.source_channel_id}</code> | Msg #{post.source_message_id}</i>"
)
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):
@@ -410,12 +142,441 @@ class AdminBotService:
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 review post {post.id} to review channel: {e}", exc_info=True)
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"⛔ دسترسی غیرمجاز. شناسه عددی شما: <code>{event.sender_id}</code>", 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 = (
"👋 <b>به پنل مدیریت سیستم هوشمند کپی‌کار خوش آمدید!</b>\n\n"
f"• 🤖 <b>وضعیت ربات جمع‌آوری‌کننده:</b> {userbot_status}\n"
f"• 📋 <b>شناسه کانال ادمین‌ها:</b> <code>{self.review_channel_id}</code>\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 = (
"📊 <b>آمار زنده سیستم کپی‌کار:</b>\n\n"
f"• 📥 <b>پست‌های موجود در صف ردیس:</b> <b>{redis_q}</b>\n"
f"• 📋 <b>پست‌های در انتظار بررسی ادمین:</b> <b>{pending_review}</b>\n"
f"• 🚀 <b>پست‌های منتشر شده:</b> <b>{published}</b>\n"
f"• ❌ <b>پست‌های رد شده:</b> <b>{rejected}</b>\n\n"
"📈 <i>داشبورد مانیتورینگ گرانافا:</i> 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"📩 <b>کد تایید ارسال شد!</b>\n\n"
f"کد ارسال شده به شماره <code>{self.collector.phone}</code> را به این صورت ارسال کنید:\n"
f"<code>/code 12345</code>",
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("هیچ کانال مبدایی ثبت نشده است. از دکمه <b>➕ افزودن کانال مبدا</b> استفاده کنید.", parse_mode="html")
return
await event.reply(f"📡 <b>کانال‌های مبدا فعال ({len(sources)} کانال):</b>", parse_mode="html")
for s in sources:
card = (
f"📢 <b>{s.title or 'کانال'}</b>\n"
f"• شناسه: <code>{s.channel_id}</code>\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 = (
"➕ <b>راهنمای افزودن کانال مبدا:</b>\n\n"
"دستور را با فرمت زیر ارسال کنید:\n"
"<code>/add_source &lt;شناسه_عددی_کانال&gt; &lt;عنوان&gt; [یوزرنیم]</code>\n\n"
"<i>مثال:</i>\n"
"<code>/add_source -1001234567890 اخبار_فوری fouri_news</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)
buttons = [
[
Button.inline("📥 استخراج ۲۰ پست گذشته این کانال", data=f"hist:{ch_id}:20"),
Button.inline("📥 استخراج ۵۰ پست گذشته این کانال", data=f"hist:{ch_id}:50")
]
]
await event.reply(
f"✅ کانال مبدا <b>{title}</b> (<code>{ch_id}</code>) با موفقیت افزوده شد.\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("هیچ کانال مقصدی ثبت نشده است. از دکمه <b>➕ افزودن کانال مقصد</b> استفاده کنید.", parse_mode="html")
return
lines = ["<b>🎯 کانال‌های مقصد برای انتشار:</b>\n"]
for t in targets:
lines.append(
f"• <b>{t.title}</b> (شناسه: <code>{t.channel_id}</code> | ID دیتابیس: <code>{t.id}</code>)\n"
f" 🎭 <b>شخصیت و لحن:</b> {t.personality or 'پیش‌فرض'}\n"
f" 🏷 <b>فوتر / تگ‌ها:</b> <code>{t.custom_footer or 'ندارد'}</code>\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 = (
"🎯 <b>راهنمای افزودن کانال مقصد:</b>\n\n"
"دستور را با فرمت زیر ارسال کنید:\n"
"<code>/add_target &lt;شناسه_کانال&gt; &lt;عنوان&gt; [یوزرنیم]</code>\n\n"
"<i>مثال:</i>\n"
"<code>/add_target -1009876543210 دنیای_هوش_مصنوعی ai_world_chan</code>"
)
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"✅ کانال مقصد <b>{title}</b> افزوده شد (ID دیتابیس: <code>{tid}</code>).\n\n"
f"اکنون می‌توانید با دکمه <b>🎭 تنظیم شخصیت کانال‌ها</b> لحن و تگ‌های آن را تنظیم کنید.",
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("ابتدا با استفاده از <b>➕ افزودن کانال مقصد</b> یک کانال مقصد اضافه کنید.", parse_mode="html")
return
text = (
"🎭 <b>تنظیم شخصیت، لحن و تگ‌های کانال‌های مقصد:</b>\n\n"
"برای تغییر لحن و استایل نگارش کانال از دستور زیر استفاده کنید:\n"
"<code>/set_personality &lt;شناسه_دیتابیس_کانال&gt; &lt;توضیحات لحن&gt;</code>\n\n"
"<i>مثال:</i>\n"
"<code>/set_personality 1 لحن جذاب و ژورنالیستی، استفاده از تیترهای بولد و ایموجی‌های مرتبط</code>\n\n"
"برای تنظیم فوتر و هشتگ‌های اختصاصی انتهای پست:\n"
"<code>/set_footer &lt;شناسه_دیتابیس_کانال&gt; &lt;تگ‌ها یا آیدی کانال&gt;</code>\n\n"
"<i>مثال:</i>\n"
"<code>/set_footer 1 🆔 @my_tech_chan\n#تکنولوژی #هوش_مصنوعی</code>\n\n"
"<b>کانال‌های موجود و شخصیت فعلی:</b>\n"
)
for t in targets:
text += (
f"• ID: <code>{t.id}</code> | <b>{t.title}</b>\n"
f" 🎭 لحن: <i>{t.personality or 'پیش‌فرض'}</i>\n"
f" 🏷 فوتر: <code>{t.custom_footer or 'ندارد'}</code>\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"✅ شخصیت و لحن کانال <b>{target.title}</b> با موفقیت به روز شد:\n\n<i>{personality}</i>",
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"✅ فوتر اختصاصی کانال <b>{target.title}</b> به روز شد:\n\n<code>{footer}</code>",
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 = (
"📖 <b>راهنمای فرآیند کاری سیستم کپی‌کار:</b>\n\n"
"1. 📥 <b>دریافت خام پست‌ها:</b> پست‌ها بدون پردازش هوش مصنوعی مستقیماً به کانال ادمین‌ها ارسال می‌شوند.\n"
"2. 🎯 <b>انتخاب کانال مقصد:</b> با لمس دکمه هر کانال، هوش مصنوعی پست را متناسب با شخصیت، استایل و فوتر اختصاصی همان کانال بازنویسی کرده و تمام تگ‌ها و لینک‌های مبدا را حذف می‌کند.\n"
"3. 👁 <b>پیش‌نمایش زنده:</b> پیش‌نمایش بازنویسی شده به همراه دکمه تایید نهایی نمایش داده می‌شود.\n"
"4. 🚀 <b>انتشار و ارسال مجدد:</b> پس از انتشار، پست اصلی در کانال ادمین بازگردانده شده و سابقه انتشار نمایش می‌یابد تا بتوانید آن را به سایر کانال‌ها نیز ارسال کنید."
)
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"⏳ <b>در حال دریافت {limit} پست گذشته</b> از کانال <code>{ch_id}</code>...", 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"🤖 <b>در حال بازنویسی هوشمند برای کانال:</b> <b>{target.title}</b>...\n"
f"<i>(اعمال لحن اختصاصی و حذف تگ‌های مبدا)</i>"
)
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"🎯 <b>پیش‌نمایش بازنویسی شده برای: {target.title}</b>\n"
f"🎭 <b>شخصیت و لحن:</b> <i>{target.personality or 'پیش‌فرض'}</i>\n"
f"➖➖➖➖➖➖➖➖➖➖\n\n"
f"{rewritten_text}\n\n"
f"➖➖➖➖➖➖➖➖➖➖\n"
f"<i>آیا این متن مورد تایید است؟</i>"
)
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"✅ <b>با موفقیت در {target.title} منتشر شد!</b>\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❌ <b>این پست توسط ادمین رد و بایگانی شد.</b>",
parse_mode="html",
buttons=None
)
await event.answer("پست بایگانی شد.")
async def stop(self):
if self.client.is_connected():
await self.client.disconnect()
logger.info("Admin Review Bot disconnected.")
logger.info("Admin Bot disconnected.")