feat(publishing): implement immediate raw ingestion, per-target Redis queues, and configurable post intervals & sleep windows
This commit is contained in:
+138
-58
@@ -5,6 +5,7 @@ from typing import Optional, List, Dict
|
||||
from telethon import TelegramClient, events, Button
|
||||
from db.models import Post, TargetChannel, SourceChannel
|
||||
from db.repository import Repository
|
||||
from core.queue import RedisQueue
|
||||
from core.metrics import ADMIN_ACTIONS_TOTAL, TARGET_ACTIVITY_TOTAL
|
||||
from core.proxy import get_telegram_proxy
|
||||
|
||||
@@ -17,7 +18,8 @@ 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), Button.text("⏰ زمانبندی و خواب کانالها", resize=True)],
|
||||
[Button.text("❓ راهنمای سیستم", resize=True)]
|
||||
]
|
||||
|
||||
class AdminBotService:
|
||||
@@ -25,6 +27,7 @@ class AdminBotService:
|
||||
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,
|
||||
@@ -34,6 +37,7 @@ class AdminBotService:
|
||||
):
|
||||
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", "")
|
||||
@@ -44,7 +48,6 @@ 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):
|
||||
@@ -53,6 +56,9 @@ class AdminBotService:
|
||||
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
|
||||
|
||||
@@ -78,7 +84,6 @@ class AdminBotService:
|
||||
|
||||
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:
|
||||
@@ -93,6 +98,9 @@ class AdminBotService:
|
||||
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}")
|
||||
@@ -102,7 +110,7 @@ class AdminBotService:
|
||||
def _format_raw_post_caption(self, post: Post) -> str:
|
||||
published_lines = ""
|
||||
if post.published_to:
|
||||
published_lines = "📤 <b>ارسال شده به کانالهای:</b>\n"
|
||||
published_lines = "📤 <b>ارسال شده / در صف ارسال کانالهای:</b>\n"
|
||||
for item in post.published_to:
|
||||
if isinstance(item, dict):
|
||||
t_title = item.get("target_title", "کانال مقصد")
|
||||
@@ -119,7 +127,7 @@ class AdminBotService:
|
||||
|
||||
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:
|
||||
if not post or not self.review_channel_id or post.is_deleted:
|
||||
return
|
||||
|
||||
targets = await self.repo.get_active_targets()
|
||||
@@ -163,7 +171,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())
|
||||
|
||||
@@ -175,11 +183,11 @@ class AdminBotService:
|
||||
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
|
||||
total_redis_q = await self.queue.get_total_queued_posts() if self.queue else 0
|
||||
|
||||
text = (
|
||||
"📊 <b>آمار زنده سیستم کپیکار:</b>\n\n"
|
||||
f"• 📥 <b>پستهای موجود در صف ردیس:</b> <b>{redis_q}</b>\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"
|
||||
@@ -300,10 +308,15 @@ class AdminBotService:
|
||||
return
|
||||
lines = ["<b>🎯 کانالهای مقصد برای انتشار:</b>\n"]
|
||||
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> {t.personality or 'پیشفرض'}\n"
|
||||
f" 🏷 <b>فوتر / تگها:</b> <code>{t.custom_footer or 'ندارد'}</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())
|
||||
|
||||
@@ -330,11 +343,94 @@ class AdminBotService:
|
||||
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> لحن و تگهای آن را تنظیم کنید.",
|
||||
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 <شناسه_دیتابیس_کانال> <دقیقه></code>\n"
|
||||
"<i>مثال (ارسال هر ۲۰ دقیقه یک پست):</i>\n"
|
||||
"<code>/set_interval 1 20</code>\n\n"
|
||||
"2. <b>تنظیم ساعت خواب (عدم ارسال پیام در این ساعات):</b>\n"
|
||||
"<code>/set_sleep <شناسه_دیتابیس_کانال> <ساعت_شروع> <ساعت_پایان></code>\n"
|
||||
"<i>مثال (خواب از ساعت ۲۳ شب تا ۸ صبح):</i>\n"
|
||||
"<code>/set_sleep 1 23 8</code>\n\n"
|
||||
"3. <b>غیرفعال کردن ساعت خواب:</b>\n"
|
||||
"<code>/disable_sleep <شناسه_دیتابیس_کانال></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):
|
||||
@@ -406,14 +502,13 @@ class AdminBotService:
|
||||
return
|
||||
help_text = (
|
||||
"📖 <b>راهنمای فرآیند کاری سیستم کپیکار:</b>\n\n"
|
||||
"1. 📥 <b>دریافت خام پستها:</b> پستها بدون پردازش هوش مصنوعی مستقیماً به کانال ادمینها ارسال میشوند.\n"
|
||||
"2. 🎯 <b>انتخاب کانال مقصد:</b> با لمس دکمه هر کانال، هوش مصنوعی پست را متناسب با شخصیت، استایل و فوتر اختصاصی همان کانال بازنویسی کرده و تمام تگها و لینکهای مبدا را حذف میکند.\n"
|
||||
"3. 👁 <b>پیشنمایش زنده:</b> پیشنمایش بازنویسی شده به همراه دکمه تایید نهایی نمایش داده میشود.\n"
|
||||
"4. 🚀 <b>انتشار و ارسال مجدد:</b> پس از انتشار، پست اصلی در کانال ادمین بازگردانده شده و سابقه انتشار نمایش مییابد تا بتوانید آن را به سایر کانالها نیز ارسال کنید."
|
||||
"1. 📥 <b>دریافت آنی:</b> پستهای مبدا فوری و بدون تاخیر در کانال ادمین قرار میگیرند.\n"
|
||||
"2. 🎯 <b>بازنویسی بر اساس مقصد:</b> با زدن دکمه کانال مقصد، هوش مصنوعی پست را متناسب با شخصیت و فوتر آن کانال بازنویسی میکند.\n"
|
||||
"3. ⏳ <b>صف ارسال زمانبندی شده:</b> پس از تایید، پست در صف ردیس کانال مقصد قرار میگیرد و با رعایت فاصله زمانی (Interval) و ساعات خواب (Sleep) به ترتیب منتشر میشود."
|
||||
)
|
||||
await event.reply(help_text, parse_mode="html", buttons=get_persian_main_menu())
|
||||
|
||||
# --- Interactive Inline Callbacks for Reviews & Target Rewrites ---
|
||||
# --- Inline Callback Queries ---
|
||||
@self.client.on(events.CallbackQuery)
|
||||
async def on_callback(event: events.CallbackQuery.Event):
|
||||
if not self.is_admin(event.sender_id):
|
||||
@@ -454,7 +549,6 @@ class AdminBotService:
|
||||
|
||||
await event.answer(f"در حال بازنویسی برای {target.title}...")
|
||||
|
||||
# Show loading placeholder
|
||||
loading_caption = (
|
||||
f"🤖 <b>در حال بازنویسی هوشمند برای کانال:</b> <b>{target.title}</b>...\n"
|
||||
f"<i>(اعمال لحن اختصاصی و حذف تگهای مبدا)</i>"
|
||||
@@ -464,24 +558,22 @@ class AdminBotService:
|
||||
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>"
|
||||
f"<i>آیا این متن برای صف انتشار تایید است؟</i>"
|
||||
)
|
||||
|
||||
preview_buttons = [
|
||||
[
|
||||
Button.inline(f"✅ تایید و ارسال به {target.title}", data=f"pub:{post_id}:{target_id}"),
|
||||
Button.inline(f"✅ تایید و افزودن به صف {target.title}", data=f"pub:{post_id}:{target_id}"),
|
||||
],
|
||||
[
|
||||
Button.inline("🔙 انصراف / بازگشت به پست اصلی", data=f"cancel:{post_id}")
|
||||
@@ -490,7 +582,7 @@ class AdminBotService:
|
||||
|
||||
await event.edit(preview_caption, parse_mode="html", buttons=preview_buttons)
|
||||
|
||||
# 3. Publish to Target Confirmed
|
||||
# 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)
|
||||
@@ -505,45 +597,33 @@ class AdminBotService:
|
||||
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}...")
|
||||
# Push to Target's Redis Queue
|
||||
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()
|
||||
|
||||
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"
|
||||
)
|
||||
# Update database record
|
||||
await self.repo.record_post_published_to_target(post_id, target.id, target.title or "Target")
|
||||
|
||||
# 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)
|
||||
|
||||
# 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)
|
||||
await event.edit(
|
||||
f"✅ <b>به صف انتشار کانال {target.title} اضافه شد!</b>\n\n{new_caption}",
|
||||
parse_mode="html",
|
||||
buttons=new_buttons
|
||||
)
|
||||
await event.answer(f"به صف {target.title} افزوده شد!")
|
||||
|
||||
# 4. Cancel Preview & Restore Original Card
|
||||
elif data.startswith("cancel:"):
|
||||
|
||||
Reference in New Issue
Block a user