775 lines
41 KiB
Python
775 lines
41 KiB
Python
import os
|
|
import re
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
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
|
|
from core.metrics import ADMIN_ACTIONS_TOTAL, TARGET_ACTIVITY_TOTAL
|
|
from core.proxy import get_telegram_proxy
|
|
from core.error_logger import log_exception
|
|
|
|
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)]
|
|
]
|
|
|
|
def get_cancel_button():
|
|
return [[Button.inline("❌ انصراف و بازگشت به منو", data="cancel_state")]]
|
|
|
|
class AdminBotService:
|
|
def __init__(
|
|
self,
|
|
repo: Repository,
|
|
ai_processor = None,
|
|
queue: Optional[RedisQueue] = 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.queue = queue
|
|
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
|
|
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
|
|
|
|
def set_ai_processor(self, ai_processor):
|
|
self.ai_processor = ai_processor
|
|
|
|
def set_queue(self, queue: RedisQueue):
|
|
self.queue = queue
|
|
|
|
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()
|
|
|
|
# --- 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"🎯 <b>تنظیمات کانال مقصد:</b> <b>{target.title}</b>\n\n"
|
|
f"• 🆔 <b>شناسه کانال:</b> <code>{target.channel_id}</code> (ID: <code>{target.id}</code>)\n"
|
|
f"• 🔗 <b>یوزرنیم:</b> @{target.username or 'ندارد'}\n"
|
|
f"• ⏱ <b>فاصله ارسال پستها:</b> هر <b>{target.post_interval_min} دقیقه</b>\n"
|
|
f"• 🌙 <b>وضعیت ساعت خواب:</b> <b>{sleep_st}</b>\n"
|
|
f"• 📥 <b>پستهای منتظر در صف:</b> <b>{qsize} پست</b>\n\n"
|
|
f"🎭 <b>شخصیت و لحن نگارش:</b>\n"
|
|
f"<i>{target.personality or 'پیشفرض (رسمی و روان)'}</i>\n\n"
|
|
f"🏷 <b>فوتر و هشتگهای اختصاصی:</b>\n"
|
|
f"<code>{target.custom_footer or 'ندارد'}</code>\n\n"
|
|
"<i>👇 برای ویرایش هر بخش روی دکمه مربوطه کلیک کنید:</i>"
|
|
)
|
|
|
|
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()
|
|
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 = []
|
|
if row:
|
|
buttons.append(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 = "📤 <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"
|
|
|
|
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
|
|
|
|
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 or post.is_deleted:
|
|
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"⛔ دسترسی غیرمجاز. شناسه عددی شما: <code>{event.sender_id}</code>", 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()
|
|
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))
|
|
total_redis_q = await self.queue.get_total_queued_posts() if self.queue else 0
|
|
|
|
text = (
|
|
"📊 <b>آمار زنده سیستم کپیکار:</b>\n\n"
|
|
f"• 📥 <b>مجموع پستهای در صف ارسال کانالهای مقصد:</b> <b>{total_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
|
|
self.user_states[event.sender_id] = {"action": "wait_login_code"}
|
|
await msg.edit(
|
|
f"📩 <b>کد تایید ارسال شد!</b>\n\n"
|
|
f"لطفا کد دریافت شده به شماره <code>{self.collector.phone}</code> را در پیام بعدی ارسال کنید:\n"
|
|
f"<i>(یا به فرمت /code 12345)</i>",
|
|
parse_mode="html",
|
|
buttons=get_cancel_button()
|
|
)
|
|
except Exception as e:
|
|
await msg.edit(f"❌ خطا در درخواست کد: {e}")
|
|
|
|
# --- 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"),
|
|
],
|
|
[
|
|
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_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 = (
|
|
"📡 <b>افزودن کانال مبدا جدید:</b>\n\n"
|
|
"سادهترین روش: <b>یک پیام از کانال مورد نظر به این ربات فوروارد (Forward) کنید!</b>\n\n"
|
|
"<i>(یا میتوانید آیدی، یوزرنیم یا لینک کانال مثل @channel_username را بفرستید)</i>"
|
|
)
|
|
await event.reply(guide, parse_mode="html", buttons=get_cancel_button())
|
|
|
|
# --- 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
|
|
|
|
await event.reply(f"🎯 <b>کانالهای مقصد برای انتشار ({len(targets)} کانال):</b>\nبرای تنظیمات هر کانال، روی دکمه آن بزنید:", parse_mode="html")
|
|
for t in targets:
|
|
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_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 = (
|
|
"🎯 <b>افزودن کانال مقصد جدید:</b>\n\n"
|
|
"سادهترین روش: <b>یک پیام از کانال مورد نظر به این ربات فوروارد (Forward) کنید!</b>\n\n"
|
|
"<i>(یا میتوانید آیدی، یوزرنیم یا لینک کانال مثل @channel_username را بفرستید)</i>"
|
|
)
|
|
await event.reply(guide, parse_mode="html", buttons=get_cancel_button())
|
|
|
|
# --- 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"
|
|
"• <b>افزودن آسان کانالها:</b> فقط کافیست یک پیام از کانال را به ربات فوروارد کنید تا شناسه و نام آن خودکار ثبت شود!\n"
|
|
"• <b>تنظیمات با دکمه:</b> در بخش کانالهای مقصد، روی هر کانال دکمههای تغییر لحن، تگها، فاصله زمانی و ساعت خواب وجود دارد.\n"
|
|
"• <b>بازنویسی هوشمند:</b> پستهای مبدا به کانال ادمین میآیند و با لمس دکمه هر مقصد، متن با هوش مصنوعی و لحن همان کانال بازنویسی میشود."
|
|
)
|
|
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"✅ کانال مبدا <b>{title}</b> با شناسه <code>{ch_id}</code> با موفقیت افزوده شد!\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"✅ کانال مقصد <b>{title}</b> افزوده شد!\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("✅ <b>لحن و شخصیت کانال با موفقیت به روز شد!</b>", 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("✅ <b>فوتر و تگهای اختصاصی کانال با موفقیت به روز شد!</b>", 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"✅ <b>فاصله ارسال به هر {interval_min} دقیقه تغییر یافت!</b>", 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("⚠️ لطفا ساعت شروع و پایان را به این شکل بفرستید: <code>23 8</code> (برای ۲۳:۰۰ تا ۰۸:۰۰)", 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"🌙 <b>ساعت خواب از {start_h}:00 تا {end_h}:00 فعال شد!</b>", 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):
|
|
if not self.is_admin(event.sender_id):
|
|
await event.answer("⛔ دسترسی غیرمجاز.", alert=True)
|
|
return
|
|
|
|
data = event.data.decode("utf-8")
|
|
|
|
# 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"🎭 <b>تغییر لحن و شخصیت کانال «{target.title}»:</b>\n\n"
|
|
"لطفا در پیام بعدی، لحن و استایل نگارش مورد نظرتان را بفرستید:\n"
|
|
"<i>(مثال: لحن دوستانه و پرانرژی همراه با ایموجی و تیترهای جذاب)</i>",
|
|
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"🏷 <b>تغییر فوتر و تگهای اختصاصی «{target.title}»:</b>\n\n"
|
|
"لطفا متن فوتر، آیدی یا هشتگهایی که میخواهید انتهای هر پست قرار بگیرد را بفرستید:\n"
|
|
"<i>(مثال: 🆔 @my_chan\n#فناوری #اخبار)</i>",
|
|
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"⏱ <b>تغییر فاصله ارسال کانال «{target.title}»:</b>\n\n"
|
|
"فاصله زمانی بین ارسال هر پست را به <b>دقیقه</b> بفرستید:\n"
|
|
"<i>(مثال: 30 برای ارسال هر نیم ساعت یک پست)</i>",
|
|
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"🌙 <b>تنظیم ساعت خواب کانال «{target.title}»:</b>\n\n"
|
|
"ساعت شروع و پایان خواب را با یک فاصله بفرستید:\n"
|
|
"<i>(مثال: <code>23 8</code> برای عدم ارسال پست از ساعت ۲۳:۰۰ تا ۰۸:۰۰ صبح)</i>",
|
|
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("🗑 <b>کانال مقصد با موفقیت حذف شد.</b>", 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("🗑 <b>کانال مبدا با موفقیت حذف شد.</b>", 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("🎯 <b>لیست کانالهای مقصد به روز شد:</b>")
|
|
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)
|
|
|
|
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("فرآیند دریافت آغاز شد.")
|
|
|
|
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}...")
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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)
|
|
|
|
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 ""
|
|
|
|
payload = {
|
|
"post_id": post.id,
|
|
"text": text_to_publish,
|
|
"media_path": post.media_path,
|
|
"target_id": target.id,
|
|
"target_title": target.title
|
|
}
|
|
if self.queue:
|
|
await self.queue.push_target_post(target.id, payload)
|
|
ADMIN_ACTIONS_TOTAL.labels(action="approved").inc()
|
|
|
|
await self.repo.record_post_published_to_target(post_id, target.id, target.title or "Target")
|
|
|
|
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
|
|
)
|
|
await event.answer(f"به صف {target.title} افزوده شد!")
|
|
|
|
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("پیشنمایش لغو شد.")
|
|
|
|
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("پست بایگانی شد.")
|
|
|
|
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("🗑 <b>این پیام حذف و بایگانی شد.</b>", 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.")
|