feat(bot): implement conversational input, forwarded message auto-detection, and button-driven channel config

This commit is contained in:
mamad
2026-08-27 22:35:07 +03:30
parent afd99c41f3
commit f7c80b68d2
2 changed files with 366 additions and 259 deletions
+12 -2
View File
@@ -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,
+354 -257
View File
@@ -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"🎯 <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()
@@ -162,6 +260,8 @@ class AdminBotService:
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()
@@ -171,7 +271,7 @@ class AdminBotService:
"👋 <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())
@@ -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"📩 <b>کد تایید ارسال شد!</b>\n\n"
f"کد ارسال شده به شماره <code>{self.collector.phone}</code> را به این صورت ارسال کنید:\n"
f"<code>/code 12345</code>",
parse_mode="html"
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}")
@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("هیچ کانال مبدایی ثبت نشده است. از دکمه <b>➕ افزودن کانال مبدا</b> استفاده کنید.", parse_mode="html")
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"📢 <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"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 = (
" <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
"📡 <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|🎯 کانال‌های مقصد)$"))
@@ -304,196 +365,25 @@ class AdminBotService:
return
targets = await self.repo.get_active_targets()
if not targets:
await event.reply("هیچ کانال مقصدی ثبت نشده است. از دکمه <b>➕ افزودن کانال مقصد</b> استفاده کنید.", parse_mode="html")
await event.reply("هیچ کانال مقصدی ثبت نشده است. روی <b>➕ افزودن کانال مقصد</b> بزنید.", parse_mode="html")
return
lines = ["<b>🎯 کانال‌های مقصد برای انتشار:</b>\n"]
await event.reply(f"🎯 <b>کانال‌های مقصد برای انتشار ({len(targets)} کانال):</b>\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"• <b>{t.title}</b> (شناسه: <code>{t.channel_id}</code> | ID دیتابیس: <code>{t.id}</code>)\n"
f" ⏱ <b>فاصله ارسال:</b> هر <b>{t.post_interval_min}</b> دقیقه\n"
f" 🌙 <b>ساعت خواب:</b> <b>{sleep_info}</b>\n"
f" 📥 <b>تعداد در صف ارسال:</b> <b>{qsize}</b> پست\n"
f" 🎭 <b>شخصیت:</b> <i>{t.personality or 'پیش‌فرض'}</i>\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())
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 = (
"🎯 <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> لحن و با <b>⏰ زمان‌بندی و خواب</b> فواصل ارسال را تنظیم کنید.",
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 = (
"⏰ <b>مدیریت فاصله ارسال و ساعت خواب کانال‌ها:</b>\n\n"
"1. <b>تنظیم فاصله ارسال پست‌ها (به دقیقه):</b>\n"
"<code>/set_interval &lt;شناسه_دیتابیس_کانال&gt; &lt;دقیقه&gt;</code>\n"
"<i>مثال (ارسال هر ۲۰ دقیقه یک پست):</i>\n"
"<code>/set_interval 1 20</code>\n\n"
"2. <b>تنظیم ساعت خواب (عدم ارسال پیام در این ساعات):</b>\n"
"<code>/set_sleep &lt;شناسه_دیتابیس_کانال&gt; &lt;ساعت_شروع&gt; &lt;ساعت_پایان&gt;</code>\n"
"<i>مثال (خواب از ساعت ۲۳ شب تا ۸ صبح):</i>\n"
"<code>/set_sleep 1 23 8</code>\n\n"
"3. <b>غیرفعال کردن ساعت خواب:</b>\n"
"<code>/disable_sleep &lt;شناسه_دیتابیس_کانال&gt;</code>\n\n"
"<b>وضعیت فعلی کانال‌ها:</b>\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: <code>{t.id}</code> | <b>{t.title}</b> ➔ هر <b>{t.post_interval_min}</b> دقیقه | {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"✅ فاصله ارسال برای کانال <b>{target.title}</b> به هر <b>{interval_min} دقیقه</b> تنظیم شد.",
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"🌙 ساعت خواب برای کانال <b>{target.title}</b> از ساعت <b>{start_h}:00 تا {end_h}:00</b> فعال شد.\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"☀️ ساعت خواب برای کانال <b>{target.title}</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()
"🎯 <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|❓ راهنمای سیستم)$"))
@@ -501,13 +391,141 @@ class AdminBotService:
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> پس از تایید، پست در صف ردیس کانال مقصد قرار می‌گیرد و با رعایت فاصله زمانی (Interval) و ساعات خواب (Sleep) به ترتیب منتشر می‌شود."
"📖 <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):
@@ -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"🎭 <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)
@@ -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)