3228 lines
174 KiB
Python
3228 lines
174 KiB
Python
import os
|
||
import re
|
||
import io
|
||
import time
|
||
import json
|
||
import asyncio
|
||
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.llm import LLMClient
|
||
from core.metrics import (
|
||
ADMIN_ACTIONS_TOTAL, TARGET_ACTIVITY_TOTAL, AUTO_ROUTED_POSTS_TOTAL,
|
||
ERRORS_RESOLVED_TOTAL, ERRORS_OPEN_GAUGE, ERRORS_OPEN_TOTAL_GAUGE,
|
||
)
|
||
from core.proxy import get_telegram_proxy
|
||
from core.error_logger import log_exception
|
||
from services.collector import MIN_FETCH_LIMIT, MAX_FETCH_LIMIT
|
||
from services.ai_processor import SUPPORTED_LANGUAGES
|
||
from services.metrics_reporter import get_instant_metrics_report, generate_metrics_graph
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
SESSION_DIR = os.getenv("SESSION_DIR", "/app/sessions" if os.path.exists("/app") else "/projects/telegram-bots/copykar/sessions")
|
||
|
||
# Telegram hard limits. Exceeding either raises MediaCaptionTooLongError / MessageTooLongError.
|
||
MAX_MEDIA_CAPTION = 1024
|
||
MAX_TEXT_MESSAGE = 4096
|
||
TRUNCATION_MARK = "\n\n<i>… (متن طولانی بود و کوتاه شد)</i>"
|
||
|
||
|
||
def clamp_for_telegram(text: str, has_media: bool) -> str:
|
||
"""Trim text to what Telegram will accept for this message type."""
|
||
limit = MAX_MEDIA_CAPTION if has_media else MAX_TEXT_MESSAGE
|
||
if len(text) <= limit:
|
||
return text
|
||
return text[: limit - len(TRUNCATION_MARK)].rstrip() + TRUNCATION_MARK
|
||
|
||
|
||
def get_persian_main_menu(is_paused: bool = False):
|
||
return [
|
||
[Button.text("📋 Copy (محتوا)", resize=True), Button.text("🧠 AI (هوش مصنوعی)", resize=True)],
|
||
[Button.text("🤖 Bots (کانالها و رباتها)", resize=True), Button.text("📊 Monitor (مانیتورینگ)", resize=True)],
|
||
[Button.text("⚙️ System (سیستم)", resize=True), Button.text("🏠 منوی اصلی", resize=True)]
|
||
]
|
||
|
||
|
||
def get_source_fetch_buttons(channel_id: int, source_id: Optional[int] = None):
|
||
rows = [
|
||
[
|
||
Button.inline("📥 ۲۰ پست", data=f"hist:{channel_id}:20"),
|
||
Button.inline("📥 ۵۰ پست", data=f"hist:{channel_id}:50"),
|
||
Button.inline("🔢 تعداد دلخواه", data=f"histn:{channel_id}"),
|
||
]
|
||
]
|
||
if source_id is not None:
|
||
rows.append([
|
||
Button.inline("📜 تنظیم پیامهای زمینه (Context)", data=f"src_ctx_menu:{source_id}"),
|
||
])
|
||
rows.append([
|
||
Button.inline("📁 تعیین دستهبندی", data=f"src_cat:{source_id}"),
|
||
Button.inline("🗑 حذف این کانال مبدا", data=f"del_src:{source_id}")
|
||
])
|
||
return rows
|
||
|
||
|
||
|
||
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]] = {}
|
||
# Callback data is limited to 64 bytes, so error groups are referenced by
|
||
# index into this cache rather than by their service/type strings.
|
||
self.error_group_cache: Dict[int, Tuple[str, str]] = {}
|
||
self._metrics_task: Optional[Any] = None
|
||
self._running = False
|
||
|
||
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 set_website_services(self, analyzer, collector):
|
||
self.website_analyzer = analyzer
|
||
self.website_collector = collector
|
||
|
||
async def on_website_error_alert(self, alert_text: str, site_id: int):
|
||
buttons = [[Button.inline("🤖 تحلیل مجدد هوشمند وبسایت", data=f"web_reanalyze:{site_id}")]]
|
||
if self.review_channel_id:
|
||
try:
|
||
await self.client.send_message(self.review_channel_id, alert_text, parse_mode="html", buttons=buttons)
|
||
except Exception as e:
|
||
logger.error(f"Failed to send website alert to review channel: {e}")
|
||
for admin_id in self.admin_user_ids:
|
||
try:
|
||
await self.client.send_message(admin_id, alert_text, parse_mode="html", buttons=buttons)
|
||
except Exception:
|
||
pass
|
||
|
||
def is_admin(self, user_id: int) -> bool:
|
||
return not self.admin_user_ids or user_id in self.admin_user_ids
|
||
|
||
async def get_menu(self):
|
||
is_paused = await self.repo.is_system_paused()
|
||
return get_persian_main_menu(is_paused=is_paused)
|
||
|
||
async def on_ai_fallback_alert(self, from_profile: Any, to_profile: Any, error_msg: str, step: int):
|
||
clean_err = (error_msg or "")[:250].replace("<", "<").replace(">", ">")
|
||
text = (
|
||
f"⚠️ <b>هشدار جابجایی خودکار هوش مصنوعی (AI Fallback)</b>\n\n"
|
||
f"❌ <b>ارائهدهنده با خطا:</b> «{getattr(from_profile, 'name', 'اصلی')}» (<code>{getattr(from_profile, 'provider_type', '')} / {getattr(from_profile, 'model', '')}</code>)\n"
|
||
f"🔍 <b>علت خطا:</b> <code>{clean_err}</code>\n\n"
|
||
f"🔄 <b>سوییچ خودکار به ارائهدهنده پشتیبان:</b>\n"
|
||
f"✅ «{getattr(to_profile, 'name', 'پشتیبان')}» (<code>{getattr(to_profile, 'provider_type', '')} / {getattr(to_profile, 'model', '')}</code>) — <i>(گام {step} در زنجیره)</i>"
|
||
)
|
||
await self.notify_admins(text)
|
||
|
||
async def on_ai_chain_failure_alert(self, chain: List[Any], last_error_msg: str):
|
||
chain_names = " ➔ ".join([f"«{getattr(p, 'name', 'AI')}» ({getattr(p, 'model', '')})" for p in chain])
|
||
clean_err = (last_error_msg or "")[:300].replace("<", "<").replace(">", ">")
|
||
text = (
|
||
f"🚨 <b>خطای بحرانی: قطعی تمامی ارائهدهندگان هوش مصنوعی (AI Chain Failure)</b>\n\n"
|
||
f"تمامی <b>{len(chain)}</b> ارائهدهنده در زنجیره پشتیبان با خطا مواجه شدند:\n"
|
||
f"🔗 <b>مسیر زنجیره:</b> {chain_names}\n\n"
|
||
f"⚠️ <b>آخرین خطا:</b> <code>{clean_err}</code>\n\n"
|
||
f"<i>لطفا وضعیت ارائهدهندهها، اعتبار API یا پراکسی را در منوی «🧠 تنظیمات و لاگهای AI» بررسی کنید.</i>"
|
||
)
|
||
await self.notify_admins(text)
|
||
|
||
async def notify_admins(self, text: str, include_review_channel: bool = True):
|
||
"""Broadcast Persian message to admin DMs, and optionally review channel."""
|
||
if include_review_channel and 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}")
|
||
|
||
menu = await self.get_menu()
|
||
for admin_id in self.admin_user_ids:
|
||
try:
|
||
await self.client.send_message(admin_id, text, parse_mode="html", buttons=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()
|
||
self._running = True
|
||
self._metrics_task = asyncio.create_task(self._error_metrics_loop())
|
||
|
||
# --- 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 "☀️ غیرفعال"
|
||
|
||
auto_ids = list(target.auto_source_ids or [])
|
||
if auto_ids:
|
||
all_sources = {s.channel_id: s for s in await self.repo.get_active_sources()}
|
||
names = [(all_sources[cid].title or str(cid)) if cid in all_sources else str(cid) for cid in auto_ids]
|
||
auto_st = "🤖 فعال از: " + "، ".join(f"<b>{n}</b>" for n in names)
|
||
else:
|
||
auto_st = "🛑 غیرفعال (فقط با تایید دستی ادمین)"
|
||
|
||
lang = getattr(target, "language", "fa") or "fa"
|
||
lang_label = SUPPORTED_LANGUAGES.get(lang, {}).get("label", "فارسی 🇮🇷")
|
||
|
||
cat_name = "بدون دستهبندی"
|
||
if getattr(target, "category_id", None):
|
||
cat = await self.repo.get_category_by_id(target.category_id)
|
||
if cat:
|
||
cat_name = cat.name
|
||
|
||
custom_p = getattr(target, "custom_prompt", "") or ""
|
||
order_mode = getattr(target, "dispatch_order", "order") or "order"
|
||
order_label = "🟢 به ترتیب ورود (FIFO)" if order_mode == "order" else "🎲 تصادفی (Random)"
|
||
ctx_count = getattr(target, "context_message_count", 0) or 0
|
||
ctx_label = f"🟢 {ctx_count} پیام اخیر کانال مقصد" if ctx_count > 0 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>{cat_name}</b>\n"
|
||
f"• 🌐 <b>زبان کانال:</b> <b>{lang_label}</b>\n"
|
||
f"• 🔀 <b>ترتیب ارسال از صف:</b> <b>{order_label}</b>\n"
|
||
f"• 📜 <b>پیامهای زمینه (Context):</b> <b>{ctx_label}</b>\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"
|
||
f"• 🤖 <b>ارسال خودکار:</b> {auto_st}\n\n"
|
||
f"🎭 <b>شخصیت و لحن نگارش:</b>\n"
|
||
f"<i>{target.personality or 'پیشفرض'}</i>\n\n"
|
||
f"📝 <b>دستورالعمل و فرامین اختصاصی (Custom Prompt):</b>\n"
|
||
f"<i>{custom_p 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_cprompt:{target.id}")
|
||
],
|
||
[
|
||
Button.inline(f"📜 پیامهای زمینه ({ctx_count})", data=f"trg_ctx_menu:{target.id}"),
|
||
Button.inline(f"🔀 شیوه ارسال ({'ترتیبی' if order_mode == 'order' else 'تصادفی'})", data=f"trg_order:{target.id}")
|
||
],
|
||
[
|
||
Button.inline("📁 تعیین دستهبندی", data=f"trg_cat:{target.id}"),
|
||
Button.inline("🏷 تغییر فوتر و تگها", data=f"st_foot:{target.id}")
|
||
],
|
||
[
|
||
Button.inline("🌐 تنظیم زبان", data=f"st_lang:{target.id}"),
|
||
Button.inline("⏱ تغییر فاصله ارسال", data=f"st_intv:{target.id}")
|
||
],
|
||
[
|
||
Button.inline("🌙 تنظیم ساعت خواب", data=f"st_slp:{target.id}"),
|
||
Button.inline("🤖 ارسال خودکار از مبدا", data=f"auto_src:{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
|
||
|
||
async def _render_target_context_menu(self, target_id: int):
|
||
target = await self.repo.get_target_by_id(target_id)
|
||
if not target:
|
||
return "❌ کانال مقصد یافت نشد.", []
|
||
|
||
current_cnt = getattr(target, "context_message_count", 0) or 0
|
||
status_text = f"<b>{current_cnt} پیام اخیر</b>" if current_cnt > 0 else "<b>غیرفعال (۰ پیام)</b>"
|
||
|
||
text = (
|
||
f"📜 <b>تنظیم تعداد پیامهای زمینه (Context History) برای کانال مقصد</b>\n"
|
||
f"🎯 کانال مقصد: <b>{target.title or target.channel_id}</b>\n\n"
|
||
f"با فعالسازی این قابلیت، هنگام بازنویسی هر پست برای این کانال، تعداد مشخصی از <b>آخرین پستهای منتشرشده در همین کانال مقصد</b> به هوش مصنوعی داده میشود تا خط داستانی، لحن نگارش و اخبار مرتبط اخیر رعایت شود.\n\n"
|
||
f"• وضعیت کنونی: {status_text}\n\n"
|
||
f"<i>تعداد پیام مورد نظر را انتخاب کنید:</i>"
|
||
)
|
||
|
||
buttons = [
|
||
[
|
||
Button.inline("0️⃣ خاموش (۰)", data=f"trg_ctx_set:{target.id}:0"),
|
||
Button.inline("1️⃣ ۱ پیام", data=f"trg_ctx_set:{target.id}:1"),
|
||
Button.inline("2️⃣ ۲ پیام", data=f"trg_ctx_set:{target.id}:2"),
|
||
],
|
||
[
|
||
Button.inline("3️⃣ ۳ پیام", data=f"trg_ctx_set:{target.id}:3"),
|
||
Button.inline("5️⃣ ۵ پیام", data=f"trg_ctx_set:{target.id}:5"),
|
||
Button.inline("🔟 ۱۰ پیام", data=f"trg_ctx_set:{target.id}:10"),
|
||
],
|
||
[
|
||
Button.inline("🔙 بازگشت به کانال مقصد", data=f"trg_view:{target.id}")
|
||
]
|
||
]
|
||
return text, buttons
|
||
|
||
async def _render_target_language_menu(self, target_id: int) -> Tuple[str, List[List[Button]]]:
|
||
target = await self.repo.get_target_by_id(target_id)
|
||
if not target:
|
||
return "❌ کانال مقصد یافت نشد.", []
|
||
|
||
current_lang = getattr(target, "language", "fa") or "fa"
|
||
text = (
|
||
f"🌐 <b>انتخاب زبان برای کانال «{target.title}»</b>\n\n"
|
||
"هوش مصنوعی پستها را متناسب با این زبان بازنویسی و ترجمه میکند:\n"
|
||
f"زبان فعلی: <b>{SUPPORTED_LANGUAGES.get(current_lang, {}).get('label', 'فارسی 🇮🇷')}</b>"
|
||
)
|
||
buttons = []
|
||
for code, info in SUPPORTED_LANGUAGES.items():
|
||
mark = " ✅" if code == current_lang else ""
|
||
buttons.append([Button.inline(f"{info['label']}{mark}", data=f"set_lang:{target_id}:{code}")])
|
||
buttons.append([Button.inline("🔙 بازگشت به تنظیمات کانال", data=f"trg_view:{target_id}")])
|
||
return text, buttons
|
||
|
||
# --- AI Management & Multi-Provider Profiles ---
|
||
async def _render_ai_menu(self) -> Tuple[str, List[List[Button]]]:
|
||
active_prof = await self.repo.get_active_provider_profile()
|
||
if active_prof:
|
||
provider = active_prof.provider_type
|
||
model = active_prof.model
|
||
base_url = active_prof.base_url
|
||
api_key = active_prof.api_key
|
||
reasoning = active_prof.reasoning_effort
|
||
prof_name = active_prof.name
|
||
else:
|
||
provider = await self.repo.get_setting("ai_provider", os.getenv("AI_PROVIDER", "openai"))
|
||
model = await self.repo.get_setting("ai_model", os.getenv("AI_MODEL", "antigravity" if provider == "agy" else "orcarouter/auto"))
|
||
base_url = await self.repo.get_setting("ai_base_url", os.getenv("AI_BASE_URL", ""))
|
||
api_key = await self.repo.get_setting("ai_api_key", os.getenv("AI_API_KEY", ""))
|
||
reasoning = await self.repo.get_setting("ai_reasoning_effort", os.getenv("AI_REASONING_EFFORT", ""))
|
||
prof_name = "پیشفرض سیستم"
|
||
|
||
double_check = await self.repo.get_setting("ai_double_check", os.getenv("AI_DOUBLE_CHECK", "false"))
|
||
dc_status = "✅ فعال (بررسی مجدد)" if double_check.lower() in ("true", "1", "yes") else "❌ غیرفعال"
|
||
reason_label = reasoning.upper() if reasoning else "پیشفرض"
|
||
masked_key = f"{api_key[:6]}...{api_key[-4:]}" if len(api_key) > 10 else ("تنظیم نشده" if not api_key else "******")
|
||
url_label = base_url if base_url else "پیشفرض (لوکال/داخلی)"
|
||
|
||
text = (
|
||
"🧠 <b>تنظیمات هوش مصنوعی (Multi-Provider AI System):</b>\n\n"
|
||
f"• 🔌 <b>پروفایل فعال:</b> <b>{prof_name}</b> (<code>{provider}</code>)\n"
|
||
f"• 🏷 <b>مدل فعال:</b> <code>{model}</code>\n"
|
||
f"• 🌐 <b>Base URL:</b> <code>{url_label}</code>\n"
|
||
f"• 🔑 <b>API Key:</b> <code>{masked_key}</code>\n"
|
||
f"• 🔍 <b>بررسی مجدد (Double Check):</b> <b>{dc_status}</b>\n"
|
||
f"• 🧠 <b>میزان تفکر:</b> <code>{reason_label}</code>\n\n"
|
||
"<i>👇 برای تغییر پروفایل فعال، ثبت ارائهدهنده جدید یا تست روی دکمهها بزنید:</i>"
|
||
)
|
||
|
||
buttons = [
|
||
[
|
||
Button.inline("🔌 لیست و تغییر سرویسدهندهها", data="ai_prov_list"),
|
||
Button.inline("➕ ثبت سرویسدهنده جدید", data="ai_add_prov"),
|
||
],
|
||
[
|
||
Button.inline(f"🔍 بررسی مجدد ({'فعال' if double_check.lower() in ('true', '1', 'yes') else 'غیرفعال'})", data="ai_toggle_dc"),
|
||
Button.inline("🧪 تست سریع اتصال AI", data="ai_test"),
|
||
],
|
||
[
|
||
Button.inline("📜 مشاهده ۱۰ لاگ اخیر AI", data="ai_logs:10")
|
||
]
|
||
]
|
||
return text, buttons
|
||
|
||
async def _render_ai_providers_list(self) -> Tuple[str, List[List[Button]]]:
|
||
profiles = await self.repo.get_provider_profiles()
|
||
text = (
|
||
"🔌 <b>لیست سرویسدهندههای هوش مصنوعی (AI Providers):</b>\n\n"
|
||
"برای مشاهده جزئیات، ویرایش، تست یا فعالسازی روی هر گزینه کلیک کنید:\n"
|
||
)
|
||
buttons = []
|
||
for p in profiles:
|
||
st = "🟢 [فعال]" if p.is_active else "⚪️"
|
||
label = f"{st} {p.name} ({p.model})"
|
||
buttons.append([Button.inline(label, data=f"ai_pv_view:{p.id}")])
|
||
|
||
buttons.append([
|
||
Button.inline("➕ ثبت سرویسدهنده جدید", data="ai_add_prov"),
|
||
Button.inline("🔙 بازگشت به منوی AI", data="ai_menu"),
|
||
])
|
||
return text, buttons
|
||
|
||
async def _render_ai_provider_detail(self, profile_id: int) -> Tuple[str, List[List[Button]]]:
|
||
p = await self.repo.get_provider_profile_by_id(profile_id)
|
||
if not p:
|
||
return "❌ پروفایل سرویسدهنده یافت نشد.", [[Button.inline("🔙 بازگشت", data="ai_prov_list")]]
|
||
|
||
st_text = "🟢 <b>فعال (در حال استفاده)</b>" if p.is_active else "⚪️ <b>غیرفعال</b>"
|
||
masked_key = f"{p.api_key[:6]}...{p.api_key[-4:]}" if len(p.api_key) > 10 else ("تنظیم نشده" if not p.api_key else "******")
|
||
url_label = p.base_url if p.base_url else "پیشفرض (لوکال)"
|
||
fallback_label = f"🔄 {p.fallback_provider_name}" if p.fallback_provider_name else "❌ بدون پشتیبان (پایان زنجیره)"
|
||
vision_label = "🟢 فعال" if getattr(p, "supports_vision", False) else "⚪️ غیرفعال (پیشفرض)"
|
||
|
||
text = (
|
||
f"🔌 <b>اطلاعات سرویسدهنده: {p.name}</b>\n\n"
|
||
f"• 🏷 <b>نوع سرویس (Type):</b> <code>{p.provider_type}</code>\n"
|
||
f"• 🤖 <b>مدل (Model):</b> <code>{p.model}</code>\n"
|
||
f"• 🌐 <b>Base URL:</b> <code>{url_label}</code>\n"
|
||
f"• 🔑 <b>API Key:</b> <code>{masked_key}</code>\n"
|
||
f"• 🧠 <b>تفکر (Reasoning):</b> <code>{p.reasoning_effort or 'پیشفرض'}</code>\n"
|
||
f"• 🖼 <b>پردازش تصویر (Vision):</b> <b>{vision_label}</b>\n"
|
||
f"• 🔄 <b>پشتیبان دستی (Fallback):</b> <b>{fallback_label}</b>\n"
|
||
f"• ⚡️ <b>وضعیت:</b> {st_text}"
|
||
)
|
||
|
||
buttons = []
|
||
if not p.is_active:
|
||
buttons.append([Button.inline("⚡️ فعالسازی این سرویسدهنده", data=f"ai_pv_activate:{p.id}")])
|
||
|
||
buttons.append([
|
||
Button.inline("🧪 تست اختصاصی این Provider", data=f"ai_pv_test:{p.id}"),
|
||
Button.inline("🔄 تنظیم ارائهدهنده پشتیبان", data=f"ai_pv_fb_menu:{p.id}"),
|
||
])
|
||
buttons.append([
|
||
Button.inline("🖼 تغییر وضعیت پردازش تصویر", data=f"ai_pv_vision:{p.id}"),
|
||
Button.inline("🧠 حالت تفکر و استدلال", data=f"ai_pv_effort:{p.id}"),
|
||
])
|
||
buttons.append([
|
||
Button.inline("🏷 تغییر مدل", data=f"ai_pv_model:{p.id}"),
|
||
Button.inline("🌐 تغییر Base URL", data=f"ai_pv_url:{p.id}"),
|
||
])
|
||
buttons.append([
|
||
Button.inline("🔑 تغییر API Key", data=f"ai_pv_key:{p.id}"),
|
||
])
|
||
if not p.is_active:
|
||
buttons.append([Button.inline("🗑 حذف این سرویسدهنده", data=f"ai_pv_del:{p.id}")])
|
||
|
||
buttons.append([Button.inline("🔙 بازگشت به لیست سرویسدهندهها", data="ai_prov_list")])
|
||
return text, buttons
|
||
|
||
async def _render_ai_provider_fallback_menu(self, profile_id: int) -> Tuple[str, List[List[Button]]]:
|
||
p = await self.repo.get_provider_profile_by_id(profile_id)
|
||
if not p:
|
||
return "❌ سرویسدهنده یافت نشد.", [[Button.inline("🔙 بازگشت", data="ai_prov_list")]]
|
||
|
||
all_providers = await self.repo.get_provider_profiles()
|
||
other_providers = [op for op in all_providers if op.id != profile_id]
|
||
|
||
curr_fb = p.fallback_provider_name or "بدون پشتیبان"
|
||
text = (
|
||
f"🔄 <b>تنظیم دستی ارائهدهنده پشتیبان (Fallback)</b>\n"
|
||
f"برای سرویسدهنده: <b>{p.name}</b>\n\n"
|
||
f"• وضعیت فعلی: <b>{curr_fb}</b>\n\n"
|
||
"<i>در صورت بروز هرگونه خطا در این ارائهدهنده، درخواست هوش مصنوعی به کدام سرویسدهنده منتقل شود؟</i>"
|
||
)
|
||
|
||
buttons: List[List[Button]] = []
|
||
is_none_selected = not p.fallback_provider_id
|
||
buttons.append([
|
||
Button.inline(f"{'✅ ' if is_none_selected else ''}❌ بدون پشتیبان (توقف زنجیره)", data=f"ai_pv_set_fb:{p.id}:0")
|
||
])
|
||
|
||
for op in other_providers:
|
||
is_selected = (p.fallback_provider_id == op.id)
|
||
btn_text = f"{'✅ ' if is_selected else ''}🔄 {op.name} ({op.model})"
|
||
buttons.append([Button.inline(btn_text, data=f"ai_pv_set_fb:{p.id}:{op.id}")])
|
||
|
||
buttons.append([Button.inline("🔙 بازگشت به جزئیات", data=f"ai_pv_view:{p.id}")])
|
||
return text, buttons
|
||
|
||
async def _render_ai_provider_effort_menu(self, profile_id: int) -> Tuple[str, List[List[Button]]]:
|
||
p = await self.repo.get_provider_profile_by_id(profile_id)
|
||
if not p:
|
||
return "❌ سرویسدهنده یافت نشد.", [[Button.inline("🔙 بازگشت", data="ai_prov_list")]]
|
||
|
||
current = (p.reasoning_effort or "").lower() or "none"
|
||
effort_map = {
|
||
"none": "خاموش / بدون استدلال (پیشفرض)",
|
||
"low": "سبک / سریع (Low)",
|
||
"medium": "متوسط و استاندارد (Medium)",
|
||
"high": "عمیق و دقیق (High)"
|
||
}
|
||
text = (
|
||
f"🧠 <b>تنظیم حالت تفکر و استدلال (Thinking Mode / Reasoning Effort)</b>\n"
|
||
f"برای سرویسدهنده: <b>{p.name}</b> (مدل: <code>{p.model}</code>)\n\n"
|
||
f"• وضعیت فعلی: <b>{effort_map.get(current, current)}</b>\n\n"
|
||
"<i>میزان عمق تفکر و استدلال مدل قبل از تولید پاسخ را انتخاب کنید:</i>"
|
||
)
|
||
buttons = [
|
||
[Button.inline(f"{'✅ ' if current in ('', 'none') else ''}⚡️ خاموش (Off)", data=f"ai_pv_set_effort:{p.id}:none")],
|
||
[Button.inline(f"{'✅ ' if current == 'low' else ''}🟢 سبک و سریع (Low)", data=f"ai_pv_set_effort:{p.id}:low")],
|
||
[Button.inline(f"{'✅ ' if current == 'medium' else ''}🟡 متوسط و استاندارد (Medium)", data=f"ai_pv_set_effort:{p.id}:medium")],
|
||
[Button.inline(f"{'✅ ' if current == 'high' else ''}🔴 عمیق و حداکثری (High)", data=f"ai_pv_set_effort:{p.id}:high")],
|
||
[Button.inline("🔙 بازگشت به جزئیات", data=f"ai_pv_view:{p.id}")]
|
||
]
|
||
return text, buttons
|
||
|
||
|
||
async def _render_ai_logs(self, limit: int = 10) -> Tuple[str, List[List[Button]]]:
|
||
logs = await self.repo.get_recent_ai_logs(limit)
|
||
if not logs:
|
||
text = "📜 <b>هیچ لاگی از درخواستهای هوش مصنوعی یافت نشد.</b>"
|
||
buttons = [[Button.inline("🔙 بازگشت به تنظیمات AI", data="ai_menu")]]
|
||
return text, buttons
|
||
|
||
text = (
|
||
f"📜 <b>آخرین {len(logs)} درخواست هوش مصنوعی (AI Logs):</b>\n\n"
|
||
"برای مشاهده جزئیات کامل پرامپت و پاسخ، روی هر لاگ بزنید:"
|
||
)
|
||
buttons = []
|
||
for l in logs:
|
||
st = "✅" if l.status == "success" else "❌"
|
||
dur = f"{l.duration_sec:.1f}s"
|
||
title = f"{st} [{dur}] {l.provider} • {l.action_name} (#{l.id})"
|
||
buttons.append([Button.inline(title, data=f"ai_log_view:{l.id}")])
|
||
buttons.append([Button.inline("🔙 بازگشت به تنظیمات AI", data="ai_menu")])
|
||
return text, buttons
|
||
|
||
async def _render_ai_log_detail(self, log_id: int) -> Tuple[str, List[List[Button]]]:
|
||
log = await self.repo.get_ai_log_by_id(log_id)
|
||
if not log:
|
||
return "❌ لاگ یافت نشد.", [[Button.inline("🔙 بازگشت", data="ai_logs:10")]]
|
||
|
||
st_icon = "✅ موفق" if log.status == "success" else f"❌ خطا: {log.error_message or 'نامشخص'}"
|
||
prompt_preview = (log.prompt[:350] + "...") if len(log.prompt) > 350 else log.prompt
|
||
sys_preview = ((log.system_prompt[:200] + "...") if len(log.system_prompt) > 200 else log.system_prompt) if log.system_prompt else "ندارد"
|
||
resp_text = log.response_text or "ندارد"
|
||
resp_preview = (resp_text[:700] + "...") if len(resp_text) > 700 else resp_text
|
||
|
||
text = (
|
||
f"📜 <b>جزئیات درخواست هوش مصنوعی #{log.id}</b>\n\n"
|
||
f"• ⏰ <b>زمان:</b> <code>{log.created_at}</code>\n"
|
||
f"• 📌 <b>عملیات:</b> <code>{log.action_name}</code>\n"
|
||
f"• 🔌 <b>سرویسدهنده:</b> <code>{log.provider}</code>\n"
|
||
f"• 🏷 <b>مدل:</b> <code>{log.model}</code>\n"
|
||
f"• ⏱ <b>مدت زمان:</b> <b>{log.duration_sec:.2f} ثانیه</b>\n"
|
||
f"• 📊 <b>وضعیت:</b> {st_icon}\n\n"
|
||
f"⚙️ <b>System Prompt:</b>\n<code>{sys_preview}</code>\n\n"
|
||
f"📥 <b>Prompt:</b>\n<code>{prompt_preview}</code>\n\n"
|
||
f"📤 <b>Response:</b>\n<code>{resp_preview}</code>"
|
||
)
|
||
buttons = [
|
||
[Button.inline("🔙 بازگشت به لیست لاگها", data="ai_logs:10")],
|
||
[Button.inline("🧠 بازگشت به منوی AI", data="ai_menu")]
|
||
]
|
||
return 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"
|
||
|
||
rejection_line = ""
|
||
if post.status == "rejected" or post.rejection_reason:
|
||
rejection_line = (
|
||
f"❌ <b>وضعیت: رد شده توسط هوش مصنوعی (Rejected)</b>\n"
|
||
f"📝 <b>علت رد:</b> <i>{post.rejection_reason or 'طبق ارزیابی AI'}</i>\n"
|
||
f"➖➖➖➖➖➖➖➖➖➖\n\n"
|
||
)
|
||
|
||
caption = (
|
||
f"📥 <b>پست جدید از مبدا (<code>{post.source_channel_id}</code>):</b>\n\n"
|
||
f"{rejection_line}"
|
||
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, refresh_only: bool = False):
|
||
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)
|
||
|
||
if refresh_only:
|
||
# Update the card that already exists instead of posting a second one.
|
||
if not post.review_message_id:
|
||
return
|
||
try:
|
||
await self.client.edit_message(
|
||
self.review_channel_id, post.review_message_id,
|
||
clamp_for_telegram(caption, bool(post.media_path)),
|
||
parse_mode="html", buttons=keyboard,
|
||
)
|
||
except Exception as e:
|
||
logger.debug(f"Could not refresh review card for post {post.id}: {e}")
|
||
return
|
||
|
||
has_media = bool(post.media_path and os.path.exists(post.media_path))
|
||
caption = clamp_for_telegram(caption, has_media)
|
||
|
||
try:
|
||
if has_media:
|
||
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:
|
||
await log_exception("admin_bot.send_review_post", e, {"post_id": post.id, "review_channel_id": self.review_channel_id})
|
||
|
||
async def refresh_error_metrics(self) -> int:
|
||
"""Republish the open-error gauges from the database. Returns the open count."""
|
||
groups = await self.repo.get_open_error_summary(limit=200)
|
||
ERRORS_OPEN_GAUGE.clear()
|
||
total = 0
|
||
for g in groups:
|
||
ERRORS_OPEN_GAUGE.labels(service=g["service_name"], error_type=g["error_type"]).set(g["occurrences"])
|
||
total += g["occurrences"]
|
||
ERRORS_OPEN_TOTAL_GAUGE.set(total)
|
||
return total
|
||
|
||
async def _render_error_report(self):
|
||
groups = await self.repo.get_open_error_summary(limit=20)
|
||
await self.refresh_error_metrics()
|
||
|
||
if not groups:
|
||
return "✅ <b>هیچ خطای رفعنشدهای در سیستم وجود ندارد.</b>", []
|
||
|
||
self.error_group_cache = {
|
||
i: (g["service_name"], g["error_type"]) for i, g in enumerate(groups)
|
||
}
|
||
|
||
total = sum(g["occurrences"] for g in groups)
|
||
lines = [f"⚠️ <b>خطاهای رفعنشده سیستم ({total} مورد در {len(groups)} گروه)</b>\n"]
|
||
for i, g in enumerate(groups):
|
||
last_seen = g["last_seen"].strftime("%m-%d %H:%M") if g["last_seen"] else "?"
|
||
message = (g["last_message"] or "")[:110]
|
||
lines.append(
|
||
f"\n<b>{i + 1}. {g['error_type']}</b> — <code>{g['service_name']}</code>\n"
|
||
f" • تعداد: <b>{g['occurrences']}</b> | آخرین: {last_seen}\n"
|
||
f" • <i>{message}</i>"
|
||
)
|
||
|
||
buttons = [
|
||
[Button.inline(f"✅ رفع شد: {i + 1}. {g['error_type'][:22]}", data=f"errfix:{i}")]
|
||
for i, g in enumerate(groups)
|
||
]
|
||
buttons.append([Button.inline("✅✅ علامتگذاری همه بهعنوان رفعشده", data="errfix_all")])
|
||
return "\n".join(lines), buttons
|
||
|
||
async def _render_source_list(self):
|
||
sources = await self.repo.get_active_sources()
|
||
if not sources:
|
||
return "هیچ کانال مبدایی ثبت نشده است. روی <b>➕ افزودن کانال مبدا</b> بزنید.", []
|
||
|
||
categories = await self.repo.get_categories()
|
||
if not categories:
|
||
# If no categories exist yet, show flat list
|
||
text = (
|
||
f"📡 <b>کانالهای مبدا فعال ({len(sources)} کانال)</b>\n\n"
|
||
"برای دیدن جزئیات و استخراج پست، روی نام هر کانال بزنید:"
|
||
)
|
||
buttons = [[Button.inline(f"📢 {s.title or s.channel_id}", data=f"src_view:{s.id}")] for s in sources]
|
||
buttons.append([Button.inline("➕ ایجاد دستهبندی جدید", data="cat_add")])
|
||
return text, buttons
|
||
|
||
# Show categories view for sources
|
||
text = (
|
||
f"📡 <b>دستهبندیهای کانالهای مبدا ({len(sources)} کانال)</b>\n\n"
|
||
"روی هر دستهبندی بزنید تا کانالهای مبدای مربوط به آن نمایش داده شود:"
|
||
)
|
||
buttons = []
|
||
for cat in categories:
|
||
srcs = await self.repo.get_sources_by_category(cat.id)
|
||
buttons.append([Button.inline(f"📁 {cat.name} ({len(srcs)} کانال)", data=f"src_cat_view:{cat.id}")])
|
||
|
||
uncat_srcs = await self.repo.get_sources_by_category(None)
|
||
if uncat_srcs:
|
||
buttons.append([Button.inline(f"📁 کانالهای بدون دستهبندی ({len(uncat_srcs)} کانال)", data="src_cat_view:0")])
|
||
|
||
buttons.append([Button.inline("🌐 نمایش همه کانالهای مبدا یکجا", data="src_all")])
|
||
buttons.append([Button.inline("📂 مدیریت دستهبندیها", data="list_cat")])
|
||
return text, buttons
|
||
|
||
async def _render_source_channels_in_category(self, category_id: int):
|
||
if category_id == 0:
|
||
sources = await self.repo.get_sources_by_category(None)
|
||
cat_name = "کانالهای بدون دستهبندی"
|
||
else:
|
||
cat = await self.repo.get_category_by_id(category_id)
|
||
if not cat:
|
||
return "❌ دستهبندی یافت نشد.", [[Button.inline("🔙 بازگشت به دستهبندیها", data="list_src")]]
|
||
sources = await self.repo.get_sources_by_category(category_id)
|
||
cat_name = cat.name
|
||
|
||
if not sources:
|
||
text = (
|
||
f"📡 <b>کانالهای مبدا در دسته: «{cat_name}»</b>\n\n"
|
||
"<i>هیچ کانال مبدایی در این دستهبندی قرار ندارد.</i>"
|
||
)
|
||
buttons = [[Button.inline("🔙 بازگشت به دستهبندیها", data="list_src")]]
|
||
return text, buttons
|
||
|
||
text = (
|
||
f"📡 <b>کانالهای مبدا در دسته: «{cat_name}» ({len(sources)} کانال)</b>\n\n"
|
||
"برای مشاهده جزئیات و استخراج پست، روی کانال مورد نظر بزنید:"
|
||
)
|
||
buttons = [[Button.inline(f"📢 {s.title or s.channel_id}", data=f"src_view:{s.id}")] for s in sources]
|
||
buttons.append([Button.inline("🔙 بازگشت به دستهبندیها", data="list_src")])
|
||
return text, buttons
|
||
|
||
async def _render_source_all_flat(self):
|
||
sources = await self.repo.get_active_sources()
|
||
if not sources:
|
||
return "هیچ کانال مبدایی ثبت نشده است. روی <b>➕ افزودن کانال مبدا</b> بزنید.", []
|
||
cats = {c.id: c.name for c in await self.repo.get_categories()}
|
||
text = (
|
||
f"📡 <b>همه کانالهای مبدا فعال ({len(sources)} کانال)</b>\n\n"
|
||
"برای دیدن جزئیات و استخراج پست، روی نام هر کانال بزنید:"
|
||
)
|
||
buttons = []
|
||
for s in sources:
|
||
cat_label = f" [{cats[s.category_id]}]" if s.category_id and s.category_id in cats else ""
|
||
buttons.append([Button.inline(f"📢 {s.title or s.channel_id}{cat_label}", data=f"src_view:{s.id}")])
|
||
buttons.append([Button.inline("🔙 بازگشت به دستهبندیها", data="list_src")])
|
||
return text, buttons
|
||
|
||
async def _render_target_list(self):
|
||
targets = await self.repo.get_active_targets()
|
||
if not targets:
|
||
return "هیچ کانال مقصدی ثبت نشده است. روی <b>➕ افزودن کانال مقصد</b> بزنید.", []
|
||
|
||
categories = await self.repo.get_categories()
|
||
if not categories:
|
||
# If no categories exist yet, show flat list
|
||
text = (
|
||
f"🎯 <b>کانالهای مقصد ({len(targets)} کانال)</b>\n\n"
|
||
"برای دیدن و تغییر تنظیمات، روی نام هر کانال بزنید:"
|
||
)
|
||
buttons = []
|
||
for t in targets:
|
||
auto = " 🤖" if t.auto_source_ids else ""
|
||
buttons.append([Button.inline(f"🎯 {t.title or t.channel_id}{auto}".strip(), data=f"trg_view:{t.id}")])
|
||
buttons.append([Button.inline("➕ ایجاد دستهبندی جدید", data="cat_add")])
|
||
return text, buttons
|
||
|
||
# Show categories view for targets
|
||
text = (
|
||
f"🎯 <b>دستهبندیهای کانالهای مقصد ({len(targets)} کانال)</b>\n\n"
|
||
"روی هر دستهبندی بزنید تا کانالهای مقصد مربوط به آن نمایش داده شود:"
|
||
)
|
||
buttons = []
|
||
for cat in categories:
|
||
trgs = await self.repo.get_targets_by_category(cat.id)
|
||
buttons.append([Button.inline(f"📁 {cat.name} ({len(trgs)} کانال)", data=f"trg_cat_view:{cat.id}")])
|
||
|
||
uncat_trgs = await self.repo.get_targets_by_category(None)
|
||
if uncat_trgs:
|
||
buttons.append([Button.inline(f"📁 کانالهای بدون دستهبندی ({len(uncat_trgs)} کانال)", data="trg_cat_view:0")])
|
||
|
||
buttons.append([Button.inline("🌐 نمایش همه کانالهای مقصد یکجا", data="trg_all")])
|
||
buttons.append([Button.inline("📂 مدیریت دستهبندیها", data="list_cat")])
|
||
return text, buttons
|
||
|
||
async def _render_target_channels_in_category(self, category_id: int):
|
||
if category_id == 0:
|
||
targets = await self.repo.get_targets_by_category(None)
|
||
cat_name = "کانالهای بدون دستهبندی"
|
||
else:
|
||
cat = await self.repo.get_category_by_id(category_id)
|
||
if not cat:
|
||
return "❌ دستهبندی یافت نشد.", [[Button.inline("🔙 بازگشت به دستهبندیها", data="list_trg")]]
|
||
targets = await self.repo.get_targets_by_category(category_id)
|
||
cat_name = cat.name
|
||
|
||
if not targets:
|
||
text = (
|
||
f"🎯 <b>کانالهای مقصد در دسته: «{cat_name}»</b>\n\n"
|
||
"<i>هیچ کانال مقصدی در این دستهبندی قرار ندارد.</i>"
|
||
)
|
||
buttons = [[Button.inline("🔙 بازگشت به دستهبندیها", data="list_trg")]]
|
||
return text, buttons
|
||
|
||
text = (
|
||
f"🎯 <b>کانالهای مقصد در دسته: «{cat_name}» ({len(targets)} کانال)</b>\n\n"
|
||
"برای مشاهده و تغییر تنظیمات، روی کانال مورد نظر بزنید:"
|
||
)
|
||
buttons = []
|
||
for t in targets:
|
||
auto = " 🤖" if t.auto_source_ids else ""
|
||
buttons.append([Button.inline(f"🎯 {t.title or t.channel_id}{auto}".strip(), data=f"trg_view:{t.id}")])
|
||
buttons.append([Button.inline("🔙 بازگشت به دستهبندیها", data="list_trg")])
|
||
return text, buttons
|
||
|
||
async def _render_target_all_flat(self):
|
||
targets = await self.repo.get_active_targets()
|
||
if not targets:
|
||
return "هیچ کانال مقصدی ثبت نشده است. روی <b>➕ افزودن کانال مقصد</b> بزنید.", []
|
||
cats = {c.id: c.name for c in await self.repo.get_categories()}
|
||
text = (
|
||
f"🎯 <b>همه کانالهای مقصد ({len(targets)} کانال)</b>\n\n"
|
||
"برای دیدن و تغییر تنظیمات، روی نام هر کانال بزنید:"
|
||
)
|
||
buttons = []
|
||
for t in targets:
|
||
auto = " 🤖" if t.auto_source_ids else ""
|
||
cat_label = f" [{cats[t.category_id]}]" if t.category_id and t.category_id in cats else ""
|
||
buttons.append([Button.inline(f"🎯 {t.title or t.channel_id}{cat_label}{auto}".strip(), data=f"trg_view:{t.id}")])
|
||
buttons.append([Button.inline("🔙 بازگشت به دستهبندیها", data="list_trg")])
|
||
return text, buttons
|
||
|
||
|
||
async def _render_source_config(self, source_id: int):
|
||
source = await self.repo.get_source_by_id(source_id)
|
||
if not source:
|
||
return "❌ کانال مبدا یافت نشد.", []
|
||
|
||
cat_name = "بدون دستهبندی"
|
||
if getattr(source, "category_id", None):
|
||
cat = await self.repo.get_category_by_id(source.category_id)
|
||
if cat:
|
||
cat_name = cat.name
|
||
|
||
auto_targets = await self.repo.get_targets_auto_routed_from(source.channel_id)
|
||
auto_line = (
|
||
"🤖 <b>ارسال خودکار به:</b> " + "، ".join(f"<b>{t.title}</b>" for t in auto_targets)
|
||
if auto_targets else "🛑 <b>ارسال خودکار:</b> غیرفعال"
|
||
)
|
||
collected = await self.repo.count_posts_from_source(source.channel_id)
|
||
ctx_count = getattr(source, "context_message_count", 0)
|
||
ctx_label = f"🟢 {ctx_count} پیام اخیر" if ctx_count > 0 else "⚪️ غیرفعال (فقط پست جاری)"
|
||
|
||
card = (
|
||
f"📢 <b>{source.title or 'کانال مبدا'}</b>\n\n"
|
||
f"• 🆔 <b>شناسه کانال:</b> <code>{source.channel_id}</code>\n"
|
||
f"• 🔗 <b>یوزرنیم:</b> @{source.username or 'ندارد'}\n"
|
||
f"• 📁 <b>دستهبندی:</b> <b>{cat_name}</b>\n"
|
||
f"• 📜 <b>پیامهای زمینه (Context):</b> <b>{ctx_label}</b>\n"
|
||
f"• 📥 <b>پستهای دریافتشده:</b> <b>{collected}</b>\n"
|
||
f"• {auto_line}\n\n"
|
||
"<i>👇 برای تنظیمات زمینه یا دریافت پستهای گذشته یکی از گزینهها را انتخاب کنید:</i>"
|
||
)
|
||
buttons = get_source_fetch_buttons(source.channel_id, source.id)
|
||
buttons.append([Button.inline("🔙 بازگشت به لیست کانالهای مبدا", data="list_src")])
|
||
return card, buttons
|
||
|
||
async def _render_source_context_menu(self, source_id: int):
|
||
source = await self.repo.get_source_by_id(source_id)
|
||
if not source:
|
||
return "❌ کانال مبدا یافت نشد.", []
|
||
|
||
current_cnt = getattr(source, "context_message_count", 0)
|
||
status_text = f"<b>{current_cnt} پیام اخیر</b>" if current_cnt > 0 else "<b>غیرفعال (۰ پیام)</b>"
|
||
|
||
text = (
|
||
f"📜 <b>تنظیم تعداد پیامهای زمینه (Context History)</b>\n"
|
||
f"📢 کانال مبدا: <b>{source.title or source.channel_id}</b>\n\n"
|
||
f"با فعالسازی این قابلیت، هنگام بازنویسی هر پست ورودی توسط هوش مصنوعی، تعداد مشخصی از پیامهای قبلی این کانال نیز جهت درک پیوستگی موضوعی و خط داستانی در اختیار مدل قرار میگیرد.\n\n"
|
||
f"• وضعیت کنونی: {status_text}\n\n"
|
||
f"<i>تعداد پیام مورد نظر را انتخاب کنید:</i>"
|
||
)
|
||
|
||
buttons = [
|
||
[
|
||
Button.inline("0️⃣ خاموش (۰)", data=f"src_ctx_set:{source.id}:0"),
|
||
Button.inline("1️⃣ ۱ پیام", data=f"src_ctx_set:{source.id}:1"),
|
||
Button.inline("2️⃣ ۲ پیام", data=f"src_ctx_set:{source.id}:2"),
|
||
],
|
||
[
|
||
Button.inline("3️⃣ ۳ پیام", data=f"src_ctx_set:{source.id}:3"),
|
||
Button.inline("5️⃣ ۵ پیام", data=f"src_ctx_set:{source.id}:5"),
|
||
Button.inline("🔟 ۱۰ پیام", data=f"src_ctx_set:{source.id}:10"),
|
||
],
|
||
[
|
||
Button.inline("🔙 بازگشت به کانال مبدا", data=f"src_view:{source.id}")
|
||
]
|
||
]
|
||
return text, buttons
|
||
|
||
async def _render_website_list(self) -> Tuple[str, List[List[Button]]]:
|
||
sites = await self.repo.get_source_websites()
|
||
if not sites:
|
||
text = (
|
||
"🌐 <b>لیست وبسایتهای مبدا (Source Websites)</b>\n\n"
|
||
"هنوز هیچ وبسایت مبدایی ثبت نشده است.\n"
|
||
"شما میتوانید هر وبسایت، خبرگزاری یا وبلاگی را به عنوان مبدا ثبت کنید تا هوش مصنوعی بهطور خودکار ساختار آن را تحلیل و محتوای جدید را استخراج کند."
|
||
)
|
||
buttons = [
|
||
[Button.inline("➕ افزودن وبسایت مبدا جدید", data="add_web")],
|
||
[Button.inline("🔙 بازگشت به منوی اصلی", data="back_to_main")]
|
||
]
|
||
return text, buttons
|
||
|
||
text = f"🌐 <b>لیست وبسایتهای مبدا ({len(sites)} وبسایت):</b>\n\nبرای مشاهده تنظیمات، دریافت دستی یا تحلیل مجدد روی وبسایت کلیک کنید:"
|
||
buttons = []
|
||
for s in sites:
|
||
status_icon = "🔴" if s.last_error else "🟢"
|
||
buttons.append([Button.inline(f"{status_icon} {s.name} ({s.check_interval_min}m)", data=f"web_view:{s.id}")])
|
||
buttons.append([Button.inline("➕ افزودن وبسایت مبدا جدید", data="add_web")])
|
||
buttons.append([Button.inline("🔙 بازگشت به منوی اصلی", data="back_to_main")])
|
||
return text, buttons
|
||
|
||
async def _render_website_config(self, site_id: int) -> Tuple[str, List[List[Button]]]:
|
||
site = await self.repo.get_source_website_by_id(site_id)
|
||
if not site:
|
||
return "❌ وبسایت یافت نشد.", []
|
||
|
||
cat_name = "بدون دستهبندی"
|
||
if site.category_id:
|
||
cat = await self.repo.get_category_by_id(site.category_id)
|
||
if cat:
|
||
cat_name = cat.name
|
||
|
||
api_cfg = site.api_config or {}
|
||
has_api = bool(api_cfg.get("endpoint_url"))
|
||
parser_type = api_cfg.get("parser_type", "در انتظار تحلیل AI") if has_api else "⚠️ در انتظار تحلیل اولیه"
|
||
endpoint = api_cfg.get("endpoint_url", "تنظیم نشده (نیازمند تحلیل)")
|
||
err_block = f"⚠️ <b>خطای اخیر:</b> <code>{site.last_error}</code>\n\n" if site.last_error else ""
|
||
last_fe = site.last_fetched_at.strftime("%Y-%m-%d %H:%M") if site.last_fetched_at else "هنوز دریافت نشده"
|
||
last_re = site.last_reanalyzed_at.strftime("%Y-%m-%d %H:%M") if site.last_reanalyzed_at else "انجام نشده"
|
||
needs = getattr(site, "custom_instructions", "") or ""
|
||
|
||
text = (
|
||
f"🌐 <b>وبسایت مبدا:</b> <b>{site.name}</b>\n\n"
|
||
f"• 🔗 <b>آدرس وبسایت:</b> {site.url}\n"
|
||
f"• 🔌 <b>اندپوینت دریافت:</b> <code>{endpoint}</code>\n"
|
||
f"• 🏷 <b>نوع ساختار:</b> <code>{parser_type}</code>\n"
|
||
f"• 📁 <b>دستهبندی:</b> <b>{cat_name}</b>\n"
|
||
f"• 📝 <b>نیازمندیها و فیلترهای استخراج:</b>\n"
|
||
f"<i>{needs or 'ثبت نشده (استخراج تمام اخبار و پستها)'}</i>\n\n"
|
||
f"• ⏱ <b>فاصله بررسی:</b> هر <b>{site.check_interval_min} دقیقه</b>\n"
|
||
f"• 🔄 <b>دوره تحلیل مجدد AI:</b> هر <b>{site.auto_reanalyze_hours} ساعت</b>\n"
|
||
f"• 📥 <b>آخرین دریافت:</b> <code>{last_fe}</code>\n"
|
||
f"• 🧠 <b>آخرین تحلیل هوش مصنوعی:</b> <code>{last_re}</code>\n\n"
|
||
f"{err_block}"
|
||
f"<i>👇 عملیات مورد نظر را انتخاب کنید:</i>"
|
||
)
|
||
buttons = [
|
||
[
|
||
Button.inline("🤖 شروع / تکرار تحلیل با AI", data=f"web_reanalyze:{site.id}"),
|
||
Button.inline("📝 نیازمندیهای استخراج", data=f"web_needs:{site.id}")
|
||
],
|
||
[
|
||
Button.inline("🔄 دریافت دستی (Fetch Now)", data=f"web_fetch:{site.id}"),
|
||
Button.inline("⏱ تغییر فاصله بررسی", data=f"web_intv:{site.id}")
|
||
],
|
||
[
|
||
Button.inline("🔄 دوره تحلیل مجدد AI", data=f"web_reintv:{site.id}"),
|
||
Button.inline("📁 تعیین دستهبندی", data=f"web_cat:{site.id}")
|
||
],
|
||
[
|
||
Button.inline("🗑 حذف این وبسایت", data=f"del_web:{site.id}"),
|
||
Button.inline("🔙 بازگشت به لیست وبسایتها", data="list_web")
|
||
]
|
||
]
|
||
return text, buttons
|
||
|
||
async def _render_website_category_menu(self, site_id: int):
|
||
site = await self.repo.get_source_website_by_id(site_id)
|
||
if not site:
|
||
return "❌ وبسایت یافت نشد.", []
|
||
cats = await self.repo.get_categories()
|
||
text = f"📁 <b>انتخاب دستهبندی برای وبسایت «{site.name}»:</b>"
|
||
buttons = []
|
||
for c in cats:
|
||
mark = "✅ " if site.category_id == c.id else ""
|
||
buttons.append([Button.inline(f"{mark}{c.name}", data=f"web_set_cat:{site.id}:{c.id}")])
|
||
buttons.append([Button.inline("❌ بدون دستهبندی", data=f"web_set_cat:{site.id}:none")])
|
||
buttons.append([Button.inline("🔙 بازگشت به تنظیمات وبسایت", data=f"web_view:{site.id}")])
|
||
return text, buttons
|
||
|
||
|
||
async def _render_copy_hub(self) -> Tuple[str, List[List[Button]]]:
|
||
unreviewed = await self.repo.get_unreviewed_posts(limit=50)
|
||
text = (
|
||
"📋 <b>بخش کپی و محتوا (Copy Hub):</b>\n\n"
|
||
f"• 📨 <b>پستهای منتظر بررسی:</b> {len(unreviewed)} پست\n\n"
|
||
"از گزینههای زیر برای مدیریت جریان محتوا و انتشار استفاده کنید:"
|
||
)
|
||
buttons = [
|
||
[Button.inline(f"📨 ارسال پستهای بررسینشده ({len(unreviewed)})", data="hub_pending")],
|
||
[Button.inline("➕ افزودن مبدا / مقصد جدید", data="hub_add")],
|
||
[Button.inline("📂 دستهبندی موضوعی کانالها", data="hub_cats")],
|
||
[Button.inline("🔙 بازگشت به منوی اصلی", data="back_to_main")]
|
||
]
|
||
return text, buttons
|
||
|
||
async def _render_bots_hub(self) -> Tuple[str, List[List[Button]]]:
|
||
sources = await self.repo.get_active_sources()
|
||
targets = await self.repo.get_active_targets()
|
||
websites = await self.repo.get_active_source_websites()
|
||
text = (
|
||
f"🤖 <b>بخش کانالها و رباتها (Bots Hub):</b>\n\n"
|
||
f"• 📡 <b>کانالهای مبدا:</b> {len(sources)} کانال\n"
|
||
f"• 🎯 <b>کانالهای مقصد:</b> {len(targets)} کانال\n"
|
||
f"• 🌐 <b>وبسایتهای مبدا:</b> {len(websites)} وبسایت\n\n"
|
||
f"<i>بخش مورد نظر را برای مشاهده و مدیریت انتخاب کنید:</i>"
|
||
)
|
||
buttons = [
|
||
[
|
||
Button.inline(f"📡 کانالهای مبدا ({len(sources)})", data="list_src"),
|
||
Button.inline(f"🎯 کانالهای مقصد ({len(targets)})", data="list_trg")
|
||
],
|
||
[
|
||
Button.inline(f"🌐 وبسایتهای مبدا ({len(websites)})", data="list_web"),
|
||
Button.inline("🔑 کد لاگین یوزربات", data="hub_req_code")
|
||
],
|
||
[Button.inline("🔙 بازگشت به منوی اصلی", data="back_to_main")]
|
||
]
|
||
return text, buttons
|
||
|
||
async def _render_system_hub(self) -> Tuple[str, List[List[Button]]]:
|
||
is_paused = await self.repo.is_system_paused()
|
||
st_icon = "🛑 متوقف (Paused)" if is_paused else "🟢 فعال و در حال کار (Running)"
|
||
pause_btn_text = "▶️ راهاندازی و ادامه سیستم" if is_paused else "🛑 توقف اضطراری سیستم"
|
||
pause_cb = "sys_resume" if is_paused else "sys_pause"
|
||
text = (
|
||
f"⚙️ <b>بخش کنترل و مدیریت سیستم (System Hub):</b>\n\n"
|
||
f"• ⚡️ <b>وضعیت کنونی سیستم:</b> <b>{st_icon}</b>\n"
|
||
f"• 📋 <b>کانال نظارت ادمین:</b> <code>{self.review_channel_id}</code>\n\n"
|
||
f"<i>عملیات مورد نظر را انتخاب کنید:</i>"
|
||
)
|
||
buttons = [
|
||
[Button.inline(pause_btn_text, data=pause_cb)],
|
||
[
|
||
Button.inline("📝 گزارش تغییرات (Release Notes)", data="sys_changes"),
|
||
Button.inline("❓ راهنمای سیستم", data="sys_help")
|
||
],
|
||
[Button.inline("🔙 بازگشت به منوی اصلی", data="back_to_main")]
|
||
]
|
||
return text, buttons
|
||
|
||
async def _render_monitor_hub(self) -> Tuple[str, List[List[Button]]]:
|
||
rep = await get_instant_metrics_report()
|
||
buttons = [
|
||
[
|
||
Button.inline("🔄 بروزرسانی آمار", data="rep_refresh"),
|
||
Button.inline("⚠️ بررسی خطاهای سیستم", data="hub_errors")
|
||
],
|
||
[
|
||
Button.inline("⏱ نمودار ۱۵ دقیقه", data="rep_gr:15m"),
|
||
Button.inline("⏱ نمودار ۳ ساعت", data="rep_gr:3h")
|
||
],
|
||
[
|
||
Button.inline("⏱ نمودار ۲۴ ساعت", data="rep_gr:24h"),
|
||
Button.inline("🔙 بازگشت به منوی اصلی", data="back_to_main")
|
||
]
|
||
]
|
||
return rep, buttons
|
||
|
||
async def _render_auto_sources(self, target_id: int):
|
||
"""Toggle screen listing every source with its on/off state for this target."""
|
||
target = await self.repo.get_target_by_id(target_id)
|
||
if not target:
|
||
return "❌ کانال مقصد یافت نشد.", []
|
||
|
||
sources = await self.repo.get_active_sources()
|
||
if not sources:
|
||
return (
|
||
"📡 هنوز هیچ کانال مبدایی ثبت نشده است.\n"
|
||
"ابتدا از منوی اصلی یک کانال مبدا اضافه کنید.",
|
||
[[Button.inline("🔙 بازگشت", data=f"trg_view:{target_id}")]],
|
||
)
|
||
|
||
enabled = set(target.auto_source_ids or [])
|
||
text = (
|
||
f"🤖 <b>ارسال خودکار برای کانال «{target.title}»</b>\n\n"
|
||
"هر کانال مبدایی که <b>روشن</b> باشد، پستهایش بهصورت خودکار بازنویسی شده و "
|
||
"به صف ارسال این کانال مقصد اضافه میشود.\n\n"
|
||
"<i>⚠️ پستها همچنان به کانال ادمین هم ارسال میشوند تا در جریان باشید.</i>\n\n"
|
||
"👇 برای روشن/خاموش کردن هر مبدا روی آن بزنید:"
|
||
)
|
||
buttons = [
|
||
[Button.inline(
|
||
f"{'✅' if s.channel_id in enabled else '⬜️'} {s.title or s.channel_id}",
|
||
data=f"auto_tg:{target_id}:{s.channel_id}",
|
||
)]
|
||
for s in sources
|
||
]
|
||
buttons.append([Button.inline("🔙 بازگشت به تنظیمات کانال", data=f"trg_view:{target_id}")])
|
||
return text, buttons
|
||
|
||
async def _render_categories_menu(self) -> Tuple[str, List[List[Button]]]:
|
||
cats = await self.repo.get_categories()
|
||
uncat_srcs = await self.repo.get_sources_by_category(None)
|
||
uncat_trgs = await self.repo.get_targets_by_category(None)
|
||
|
||
lines = [
|
||
"📂 <b>مدیریت دستهبندی کانالها (Categories)</b>\n",
|
||
"با ایجاد دستهبندی، میتوانید کانالهای مبدا و مقصد را گروهبندی کرده و منظمتر مدیریت کنید:\n"
|
||
]
|
||
buttons = []
|
||
|
||
if cats:
|
||
for c in cats:
|
||
counts = await self.repo.get_category_channel_counts(c.id)
|
||
s_c = counts["sources"]
|
||
t_c = counts["targets"]
|
||
label = f"📁 {c.name} ({s_c} مبدا | {t_c} مقصد)"
|
||
buttons.append([Button.inline(label, data=f"cat_view:{c.id}")])
|
||
lines.append(f"• <b>{c.name}</b>: <b>{s_c}</b> مبدا | <b>{t_c}</b> مقصد")
|
||
else:
|
||
lines.append("<i>هنوز هیچ دستهبندی ساخته نشده است.</i>")
|
||
|
||
if uncat_srcs or uncat_trgs:
|
||
lines.append(f"\n• 📁 <b>کانالهای بدون دسته:</b> <b>{len(uncat_srcs)}</b> مبدا | <b>{len(uncat_trgs)}</b> مقصد")
|
||
buttons.append([Button.inline(f"📁 کانالهای بدون دسته ({len(uncat_srcs)} مبدا | {len(uncat_trgs)} مقصد)", data="cat_view:0")])
|
||
|
||
buttons.append([Button.inline("➕ ایجاد دستهبندی جدید", data="cat_add")])
|
||
return "\n".join(lines), buttons
|
||
|
||
async def _render_category_detail(self, cat_id: int) -> Tuple[str, List[List[Button]]]:
|
||
if cat_id == 0:
|
||
sources = await self.repo.get_sources_by_category(None)
|
||
targets = await self.repo.get_targets_by_category(None)
|
||
cat_name = "کانالهای بدون دستهبندی"
|
||
desc_line = "کانالهایی که هنوز در هیچ دستهبندی قرار نگرفتهاند."
|
||
else:
|
||
cat = await self.repo.get_category_by_id(cat_id)
|
||
if not cat:
|
||
return "❌ دستهبندی یافت نشد.", [[Button.inline("🔙 بازگشت به لیست دستهها", data="list_cat")]]
|
||
sources = await self.repo.get_sources_by_category(cat_id)
|
||
targets = await self.repo.get_targets_by_category(cat_id)
|
||
cat_name = cat.name
|
||
desc_line = cat.description or "توضیحاتی ثبت نشده است."
|
||
|
||
text = (
|
||
f"📁 <b>دستهبندی: {cat_name}</b>\n\n"
|
||
f"• 📝 <b>توضیحات:</b> <i>{desc_line}</i>\n"
|
||
f"• 📡 <b>کانالهای مبدا ({len(sources)} کانال):</b>\n"
|
||
f"• 🎯 <b>کانالهای مقصد ({len(targets)} کانال):</b>\n\n"
|
||
"<i>👇 برای مدیریت هر کانال یا ویرایش این دسته، روی دکمه مربوطه بزنید:</i>"
|
||
)
|
||
|
||
buttons = []
|
||
if sources:
|
||
for s in sources:
|
||
buttons.append([Button.inline(f"📡 {s.title or s.channel_id}", data=f"src_view:{s.id}")])
|
||
if targets:
|
||
for t in targets:
|
||
buttons.append([Button.inline(f"🎯 {t.title or t.channel_id}", data=f"trg_view:{t.id}")])
|
||
|
||
if cat_id != 0:
|
||
buttons.append([
|
||
Button.inline("✏️ تغییر نام دسته", data=f"cat_rename:{cat_id}"),
|
||
Button.inline("🗑 حذف این دستهبندی", data=f"cat_del:{cat_id}")
|
||
])
|
||
buttons.append([Button.inline("🔙 بازگشت به لیست دستهها", data="list_cat")])
|
||
return text, buttons
|
||
|
||
async def _render_set_channel_category_menu(self, channel_type: str, channel_id: int) -> Tuple[str, List[List[Button]]]:
|
||
cats = await self.repo.get_categories()
|
||
if channel_type == "src":
|
||
ch = await self.repo.get_source_by_id(channel_id)
|
||
title = ch.title if ch else f"مبدا #{channel_id}"
|
||
back_cb = f"src_view:{channel_id}"
|
||
else:
|
||
ch = await self.repo.get_target_by_id(channel_id)
|
||
title = ch.title if ch else f"مقصد #{channel_id}"
|
||
back_cb = f"trg_view:{channel_id}"
|
||
|
||
curr_cat_id = getattr(ch, "category_id", None) if ch else None
|
||
|
||
text = (
|
||
f"📁 <b>انتخاب دستهبندی برای: «{title}»</b>\n\n"
|
||
"دستهبندی مورد نظرتان را از لیست زیر انتخاب کنید:"
|
||
)
|
||
buttons = []
|
||
for c in cats:
|
||
mark = " ✅" if curr_cat_id == c.id else ""
|
||
buttons.append([Button.inline(f"📁 {c.name}{mark}", data=f"cat_set:{channel_type}:{channel_id}:{c.id}")])
|
||
|
||
buttons.append([Button.inline("❌ بدون دستهبندی (حذف دسته)", data=f"cat_set:{channel_type}:{channel_id}:0")])
|
||
buttons.append([Button.inline("🔙 بازگشت", data=back_cb)])
|
||
return text, buttons
|
||
|
||
|
||
async def auto_route_post(self, post_id: int) -> int:
|
||
"""Queue a post to every target subscribed to its source channel.
|
||
|
||
The admin review card is always sent as well; auto-routing is additive so an
|
||
operator can still intervene on anything that went out automatically.
|
||
"""
|
||
post = await self.repo.get_post_by_id(post_id)
|
||
if not post or post.is_deleted:
|
||
return 0
|
||
|
||
if os.getenv("AI_AUTO_POSTING_ENABLED", "true").lower() not in ("true", "1", "yes"):
|
||
logger.info(f"Auto-routing skipped for post {post_id}: AI_AUTO_POSTING_ENABLED is disabled")
|
||
return 0
|
||
|
||
targets = await self.repo.get_targets_auto_routed_from(post.source_channel_id)
|
||
if not targets:
|
||
return 0
|
||
|
||
routed = 0
|
||
ai_rejected_reasons = []
|
||
for target in targets:
|
||
try:
|
||
context_posts = []
|
||
if target and getattr(target, "context_message_count", 0) > 0:
|
||
context_posts = await self.repo.get_recent_target_posts(
|
||
target_id=target.id,
|
||
limit=target.context_message_count,
|
||
exclude_post_id=post.id
|
||
)
|
||
|
||
text = post.raw_text or ""
|
||
if self.ai_processor:
|
||
rewrite_res = await self.ai_processor.rewrite_for_target(
|
||
text,
|
||
target,
|
||
has_media=bool(post.media_path),
|
||
image_path=post.media_path,
|
||
context_posts=context_posts or None
|
||
)
|
||
if getattr(rewrite_res, "is_rejected", False):
|
||
reason = getattr(rewrite_res, "rejection_reason", "رد شده توسط هوش مصنوعی")
|
||
logger.info(f"AI rejected post {post.id} for target {target.title}: {reason}")
|
||
ai_rejected_reasons.append(f"{target.title}: {reason}")
|
||
continue
|
||
text = str(rewrite_res)
|
||
|
||
payload = {
|
||
"post_id": post.id,
|
||
"text": text,
|
||
"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)
|
||
await self.repo.record_post_queued_to_target(post.id, target.id, target.title or "Target")
|
||
ADMIN_ACTIONS_TOTAL.labels(action="auto_routed").inc()
|
||
AUTO_ROUTED_POSTS_TOTAL.labels(
|
||
source_channel_id=str(post.source_channel_id),
|
||
target_title=target.title or "",
|
||
).inc()
|
||
routed += 1
|
||
logger.info(f"Auto-routed post {post.id} to target {target.title} (#{target.id})")
|
||
except Exception as e:
|
||
await log_exception("admin_bot.auto_route", e, {"post_id": post.id, "target_id": target.id})
|
||
|
||
if not routed and ai_rejected_reasons:
|
||
combined_reason = " | ".join(ai_rejected_reasons)
|
||
await self.repo.reject_post(post.id, rejection_reason=combined_reason)
|
||
await self.send_raw_review_post(post.id, refresh_only=True)
|
||
elif routed:
|
||
await self.send_raw_review_post(post.id, refresh_only=True)
|
||
return routed
|
||
|
||
|
||
async def handle_collected_post(self, post_id: int):
|
||
"""Entry point used by the collector: review card first, then auto-routing."""
|
||
await self.send_raw_review_post(post_id)
|
||
await self.auto_route_post(post_id)
|
||
|
||
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"
|
||
"برای مدیریت کانالها از دکمههای زیر استفاده کنید:"
|
||
)
|
||
menu = await self.get_menu()
|
||
await event.reply(welcome_text, parse_mode="html", buttons=menu)
|
||
|
||
# --- 5 Primary Category Hubs: Copy, AI, Bots, System, Monitor ---
|
||
@self.client.on(events.NewMessage(pattern=r"(?i)^(/copy|📋 copy \(محتوا\)|📋 copy|copy)$"))
|
||
async def cmd_copy_hub(event: events.NewMessage.Event):
|
||
if not self.is_admin(event.sender_id):
|
||
return
|
||
text, buttons = await self._render_copy_hub()
|
||
await event.reply(text, parse_mode="html", buttons=buttons)
|
||
|
||
@self.client.on(events.NewMessage(pattern=r"(?i)^(/ai|🧠 ai \(هوش مصنوعی\)|🧠 ai|ai)$"))
|
||
async def cmd_ai_hub(event: events.NewMessage.Event):
|
||
if not self.is_admin(event.sender_id):
|
||
return
|
||
text, buttons = await self._render_ai_menu()
|
||
await event.reply(text, parse_mode="html", buttons=buttons)
|
||
|
||
@self.client.on(events.NewMessage(pattern=r"(?i)^(/bots|🤖 bots \(کانالها و رباتها\)|🤖 bots|bots)$"))
|
||
async def cmd_bots_hub(event: events.NewMessage.Event):
|
||
if not self.is_admin(event.sender_id):
|
||
return
|
||
text, buttons = await self._render_bots_hub()
|
||
await event.reply(text, parse_mode="html", buttons=buttons)
|
||
|
||
@self.client.on(events.NewMessage(pattern=r"(?i)^(/system|⚙️ system \(سیستم\)|⚙️ system|system)$"))
|
||
async def cmd_system_hub(event: events.NewMessage.Event):
|
||
if not self.is_admin(event.sender_id):
|
||
return
|
||
text, buttons = await self._render_system_hub()
|
||
await event.reply(text, parse_mode="html", buttons=buttons)
|
||
|
||
@self.client.on(events.NewMessage(pattern=r"(?i)^(/monitor|📊 monitor \(مانیتورینگ\)|📊 monitor|monitor)$"))
|
||
async def cmd_monitor_hub(event: events.NewMessage.Event):
|
||
if not self.is_admin(event.sender_id):
|
||
return
|
||
text, buttons = await self._render_monitor_hub()
|
||
await event.reply(text, parse_mode="html", buttons=buttons)
|
||
|
||
# --- Statistics & Grafana Metrics Report ---
|
||
@self.client.on(events.NewMessage(pattern=r"(?i)^(/stats|/report|📊 آمار ناوگان|📊 آمار و وضعیت ناوگان|📊 گزارش و آمار سیستم)$"))
|
||
async def cmd_stats(event: events.NewMessage.Event):
|
||
if not self.is_admin(event.sender_id):
|
||
return
|
||
report_text = await get_instant_metrics_report()
|
||
buttons = [
|
||
[
|
||
Button.inline("⏱ نمودار ۱۵ دقیقه اخیر", data="rep_gr:15m"),
|
||
Button.inline("⏱ نمودار ۳ ساعت اخیر", data="rep_gr:3h"),
|
||
],
|
||
[
|
||
Button.inline("⏱ نمودار ۲۴ ساعت اخیر", data="rep_gr:24h"),
|
||
Button.inline("🔄 بروزرسانی گزارش", data="rep_refresh"),
|
||
]
|
||
]
|
||
await event.reply(report_text, parse_mode="html", buttons=buttons)
|
||
|
||
# --- System Errors ---
|
||
@self.client.on(events.NewMessage(pattern=r"(?i)^(/errors|/err|⚠️ خطاهای سیستم)$"))
|
||
async def cmd_errors(event: events.NewMessage.Event):
|
||
if not self.is_admin(event.sender_id):
|
||
return
|
||
text, buttons = await self._render_error_report()
|
||
await event.reply(text, parse_mode="html", buttons=buttons or None)
|
||
|
||
|
||
# --- 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
|
||
text, buttons = await self._render_source_list()
|
||
await event.reply(text, parse_mode="html", buttons=buttons or None)
|
||
|
||
@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
|
||
text, buttons = await self._render_target_list()
|
||
await event.reply(text, parse_mode="html", buttons=buttons or None)
|
||
|
||
# --- Source Websites ---
|
||
@self.client.on(events.NewMessage(pattern=r"(?i)^(/websites|/sites|🌐 وبسایتهای مبدا)$"))
|
||
async def cmd_websites(event: events.NewMessage.Event):
|
||
if not self.is_admin(event.sender_id):
|
||
return
|
||
text, buttons = await self._render_website_list()
|
||
await event.reply(text, parse_mode="html", buttons=buttons or None)
|
||
|
||
# --- Categories Management ---
|
||
@self.client.on(events.NewMessage(pattern=r"(?i)^(/categories|/cats|📂 دستهبندیها|📂 دستهبندی کانالها)$"))
|
||
async def cmd_categories(event: events.NewMessage.Event):
|
||
if not self.is_admin(event.sender_id):
|
||
return
|
||
text, buttons = await self._render_categories_menu()
|
||
await event.reply(text, parse_mode="html", buttons=buttons or None)
|
||
|
||
# --- Unified Add Hub ---
|
||
@self.client.on(events.NewMessage(pattern=r"(?i)^(/add|➕ افزودن مبدا / مقصد)$"))
|
||
async def cmd_add_hub(event: events.NewMessage.Event):
|
||
if not self.is_admin(event.sender_id):
|
||
return
|
||
text = "➕ <b>افزودن منبع یا کانال جدید:</b>\n\nلطفاً نوع مورد نظر را انتخاب کنید:"
|
||
buttons = [
|
||
[Button.inline("📡 افزودن کانال تلگرام مبدا", data="hub_add_src")],
|
||
[Button.inline("🎯 افزودن کانال تلگرام مقصد", data="hub_add_trg")],
|
||
[Button.inline("🌐 افزودن وبسایت مبدا (Web)", data="add_web")],
|
||
]
|
||
await event.reply(text, 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())
|
||
|
||
# --- Re-send unreviewed posts ---
|
||
@self.client.on(events.NewMessage(pattern=r"(?i)^(/pending|📨 پستهای بررسینشده|📨 ارسال پستهای بررسینشده)$"))
|
||
async def cmd_pending(event: events.NewMessage.Event):
|
||
if not self.is_admin(event.sender_id):
|
||
return
|
||
posts = await self.repo.get_unreviewed_posts(limit=50)
|
||
if not posts:
|
||
menu = await self.get_menu()
|
||
await event.reply("✅ هیچ پست بررسینشدهای در صف نیست.", buttons=menu)
|
||
return
|
||
|
||
msg = await event.reply(
|
||
f"⏳ در حال ارسال <b>{len(posts)}</b> پست بررسینشده به کانال ادمین...",
|
||
parse_mode="html",
|
||
)
|
||
for post in posts:
|
||
await self.send_raw_review_post(post.id)
|
||
await msg.edit(
|
||
f"✅ تعداد <b>{len(posts)}</b> پست بررسینشده به کانال ادمین ارسال شد.",
|
||
parse_mode="html",
|
||
)
|
||
|
||
# --- System Pause / Emergency Stop & Resume ---
|
||
@self.client.on(events.NewMessage(pattern=r"(?i)^(/pause|/stop|🛑 توقف سیستم|🛑 توقف اضطراری سیستم)$"))
|
||
async def cmd_pause(event: events.NewMessage.Event):
|
||
if not self.is_admin(event.sender_id):
|
||
return
|
||
await self.repo.set_system_paused(True)
|
||
text = (
|
||
"🛑 <b>سیستم به صورت اضطراری متوقف شد (System Paused).</b>\n\n"
|
||
"• ⏸ دریافت خودکار پستهای جدید از کانالهای مبدا متوقف شد.\n"
|
||
"• ⏸ ارسال زمانبندیشده پستها به کانالهای مقصد متوقف شد.\n"
|
||
"• 🟢 پنل مدیریت، دستورات و پیامهای ادمین همچنان کاملاً فعال هستند.\n\n"
|
||
"<i>برای از سرگیری فعالیت سیستم، روی دکمه «▶️ راهاندازی سیستم» بزنید.</i>"
|
||
)
|
||
menu = await self.get_menu()
|
||
await event.reply(text, parse_mode="html", buttons=menu)
|
||
|
||
@self.client.on(events.NewMessage(pattern=r"(?i)^(/resume|/start_fleet|▶️ راهاندازی سیستم|▶️ راهاندازی و ادامه سیستم)$"))
|
||
async def cmd_resume(event: events.NewMessage.Event):
|
||
if not self.is_admin(event.sender_id):
|
||
return
|
||
await self.repo.set_system_paused(False)
|
||
text = (
|
||
"▶️ <b>سیستم با موفقیت مجدداً فعال شد (System Resumed).</b>\n\n"
|
||
"• 🟢 دریافت پستهای جدید از کانالهای مبدا از سر گرفته شد.\n"
|
||
"• 🟢 ارسال پستهای در صف به کانالهای مقصد طبق زمانبندی آغاز شد."
|
||
)
|
||
menu = await self.get_menu()
|
||
await event.reply(text, parse_mode="html", buttons=menu)
|
||
|
||
# --- Release Notes & Changes ---
|
||
@self.client.on(events.NewMessage(pattern=r"(?i)^(/changes|/notes|📝 گزارش تغییرات اخیر)$"))
|
||
async def cmd_changes(event: events.NewMessage.Event):
|
||
if not self.is_admin(event.sender_id):
|
||
return
|
||
notes = self.get_latest_change_notes()
|
||
menu = await self.get_menu()
|
||
await event.reply(notes, parse_mode="html", buttons=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"
|
||
"• <b>توقف و راهاندازی اضطراری:</b> با دکمه <b>🛑 توقف اضطراری سیستم</b> تمام فرآیندهای دریافت و ارسال متوقف میشوند در حالی که ربات همیشه به ادمین پاسخ میدهد.\n"
|
||
"• <b>زنجیره پشتیبان هوش مصنوعی:</b> در صورت بروز خطا در ارائهدهنده فعال، درخواست طبق تنظیمات دستی شما به ارائهدهنده پشتیبان تعیینشده منتقل میشود.\n"
|
||
"• <b>گزارش تغییرات:</b> با دستور <code>/changes</code> آخرین تغییرات و امکانات سیستم را مشاهده کنید.\n"
|
||
"• <b>افزودن آسان کانالها:</b> فقط کافیست یک پیام از کانال را به ربات فوروارد کنید تا شناسه و نام آن خودکار ثبت شود!\n"
|
||
"• <b>تنظیمات با دکمه:</b> در بخش کانالهای مقصد، روی هر کانال دکمههای تغییر لحن، تگها، فاصله زمانی و ساعت خواب وجود دارد.\n"
|
||
"• <b>بازنویسی هوشمند:</b> پستهای مبدا به کانال ادمین میآیند و با لمس دکمه هر مقصد، متن با هوش مصنوعی و لحن همان کانال بازنویسی میشود.\n"
|
||
"• <b>پستهای بررسینشده:</b> با دکمه <b>📨 ارسال پستهای بررسینشده</b> هر پستی که هنوز به کانال ادمین نرفته، دوباره ارسال میشود.\n"
|
||
"• <b>ارسال خودکار:</b> در تنظیمات هر کانال مقصد، دکمه <b>🤖 ارسال خودکار از مبدا</b> را بزنید تا پستهای یک مبدا خودکار به آن مقصد بروند.\n"
|
||
"• <b>خطاها:</b> با دکمه <b>⚠️ خطاهای سیستم</b> خطاهای رفعنشده را ببینید و بعد از رفع، علامتگذاری کنید."
|
||
)
|
||
menu = await self.get_menu()
|
||
await event.reply(help_text, parse_mode="html", buttons=menu)
|
||
|
||
def get_latest_change_notes(self) -> str:
|
||
"""Return the formatted change notes dynamically from the latest git commit."""
|
||
try:
|
||
res = subprocess.run(
|
||
["git", "log", "-1", "--pretty=format:🔹 <b>%s</b>%n%b"],
|
||
cwd=os.path.dirname(os.path.dirname(__file__)),
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=5
|
||
)
|
||
if res.returncode == 0 and res.stdout.strip():
|
||
return f"📢 <b>آخرین تغییرات سیستم (Release Notes):</b>\n\n{res.stdout.strip()}"
|
||
except Exception as e:
|
||
logger.debug(f"Error reading git log for change notes: {e}")
|
||
|
||
return "📢 <b>گزارش تغییرات سیستم (Release Notes):</b>\nسیستم با آخرین بهروزرسانیها در حال اجرا است."
|
||
|
||
|
||
# --- Step-by-Step Parameter & Text Input Message Handler ---
|
||
@self.client.on(events.NewMessage)
|
||
async def handle_user_input(event: events.NewMessage.Event):
|
||
# Telethon swallows handler exceptions into its own logger, so an unhandled
|
||
# error here looks to the admin like the bot simply ignored them.
|
||
try:
|
||
await self._handle_user_input(event)
|
||
except Exception as e:
|
||
await log_exception("admin_bot.user_input", e, {
|
||
"sender_id": event.sender_id,
|
||
"state": self.user_states.get(event.sender_id),
|
||
})
|
||
await self._report_error_to_admin(event, e)
|
||
|
||
# --- Inline Callback Queries ---
|
||
@self.client.on(events.CallbackQuery)
|
||
async def on_callback(event: events.CallbackQuery.Event):
|
||
try:
|
||
await self._handle_callback(event)
|
||
except Exception as e:
|
||
await log_exception("admin_bot.callback", e, {
|
||
"sender_id": event.sender_id,
|
||
"data": event.data.decode("utf-8", errors="replace") if event.data else None,
|
||
})
|
||
await self._report_error_to_admin(event, e)
|
||
|
||
async def _report_error_to_admin(self, event, error: Exception) -> None:
|
||
"""Surface a failure in the chat it happened in, so no action ever fails silently."""
|
||
message = (
|
||
"❌ <b>خطایی در انجام این عملیات رخ داد.</b>\n\n"
|
||
f"<code>{type(error).__name__}: {error}</code>\n\n"
|
||
"<i>جزئیات کامل در جدول خطاهای سیستم ثبت شد.</i>"
|
||
)
|
||
try:
|
||
await event.reply(message, parse_mode="html")
|
||
except Exception:
|
||
try:
|
||
await event.answer("❌ خطا در انجام عملیات. جزئیات در لاگ ثبت شد.", alert=True)
|
||
except Exception as notify_err:
|
||
logger.error(f"Could not report error to admin: {notify_err}")
|
||
|
||
async def _handle_user_input(self, 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", "منو", "منوی اصلی", "🏠 منوی اصلی",
|
||
"/copy", "📋 copy (محتوا)", "📋 Copy (محتوا)", "📋 کپی", "Copy",
|
||
"/ai", "🧠 ai (هوش مصنوعی)", "🧠 AI (هوش مصنوعی)", "🧠 تنظیمات AI", "🧠 تنظیمات و لاگهای AI", "AI",
|
||
"/bots", "🤖 bots (کانالها و رباتها)", "🤖 Bots (کانالها و رباتها)", "🤖 کانالها و رباتها", "Bots",
|
||
"/system", "⚙️ system (سیستم)", "⚙️ System (سیستم)", "⚙️ مدیریت سیستم", "System",
|
||
"/monitor", "📊 monitor (مانیتورینگ)", "📊 Monitor (مانیتورینگ)", "📊 مانیتورینگ", "Monitor",
|
||
"/stats", "/report", "📊 آمار ناوگان", "📊 آمار و وضعیت ناوگان", "📊 گزارش و آمار سیستم",
|
||
"/sources", "📡 کانالهای مبدا", "/targets", "🎯 کانالهای مقصد",
|
||
"/websites", "/sites", "🌐 وبسایتهای مبدا",
|
||
"/request_code", "🔑 کد لاگین", "🔑 درخواست کد لاگین",
|
||
"/pending", "📨 پستهای بررسینشده", "📨 ارسال پستهای بررسینشده",
|
||
"/errors", "/err", "⚠️ خطاهای سیستم",
|
||
"/categories", "/cats", "📂 دستهبندیها", "📂 دستهبندی کانالها",
|
||
"/add", "➕ افزودن مبدا / مقصد", "➕ افزودن کانال مبدا", "➕ افزودن کانال مقصد",
|
||
"/pause", "/stop", "🛑 توقف سیستم", "🛑 توقف اضطراری سیستم",
|
||
"/resume", "/start_fleet", "▶️ راهاندازی سیستم", "▶️ راهاندازی و ادامه سیستم",
|
||
"/help", "❓ راهنمای سیستم"]:
|
||
return
|
||
|
||
# Cancel command
|
||
if text in ["/cancel", "انصراف", "لغو"]:
|
||
self.user_states.pop(event.sender_id, None)
|
||
menu = await self.get_menu()
|
||
await event.reply("❌ عملیات لغو شد.", buttons=menu)
|
||
return
|
||
|
||
state = self.user_states.get(event.sender_id)
|
||
if not state:
|
||
# Direct /code or /password handling
|
||
if text.startswith(("/code ", "/password ")) and not self.collector:
|
||
await event.reply("سرویس کالکتور متصل نیست.")
|
||
return
|
||
if text.startswith("/code "):
|
||
code = text.split("/code ", 1)[1].strip()
|
||
res = await self.collector.submit_code(code)
|
||
menu = await self.get_menu()
|
||
await event.reply(res, parse_mode="html", buttons=menu)
|
||
elif text.startswith("/password "):
|
||
pwd = text.split("/password ", 1)[1].strip()
|
||
res = await self.collector.submit_password(pwd)
|
||
menu = await self.get_menu()
|
||
await event.reply(res, parse_mode="html", buttons=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 = get_source_fetch_buttons(ch_id)
|
||
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)
|
||
|
||
# 4.1. Waiting for Target Custom Prompt
|
||
elif action == "wait_custom_prompt":
|
||
target_id = state.get("target_id")
|
||
self.user_states.pop(event.sender_id, None)
|
||
new_prompt = "" if text.strip() in ("/clear", "clear", "حذف", "پاک") else text.strip()
|
||
await self.repo.update_target_custom_prompt(target_id, new_prompt)
|
||
card, buttons = await self._render_target_config(target_id)
|
||
if new_prompt:
|
||
await event.reply("✅ <b>دستورالعمل و فرامین اختصاصی کانال با موفقیت ذخیره شد!</b>", parse_mode="html")
|
||
else:
|
||
await event.reply("✅ <b>پرامپت اختصاصی حذف شد و به حالت پیشفرض بازگشت.</b>", parse_mode="html")
|
||
await event.reply(card, parse_mode="html", buttons=buttons)
|
||
|
||
# 4.2. Waiting for Category Name Creation
|
||
elif action == "wait_cat_name":
|
||
self.user_states.pop(event.sender_id, None)
|
||
cat_name = text.strip()
|
||
if not cat_name:
|
||
menu = await self.get_menu()
|
||
await event.reply("⚠️ نام دستهبندی نمیتواند خالی باشد.", buttons=menu)
|
||
return
|
||
cat_id = await self.repo.create_category(name=cat_name)
|
||
card, buttons = await self._render_category_detail(cat_id)
|
||
await event.reply(f"✅ دستهبندی <b>{cat_name}</b> با موفقیت ایجاد شد!", parse_mode="html")
|
||
await event.reply(card, parse_mode="html", buttons=buttons)
|
||
|
||
# 4.3. Waiting for Category Rename
|
||
elif action == "wait_cat_rename":
|
||
cat_id = state.get("cat_id")
|
||
self.user_states.pop(event.sender_id, None)
|
||
cat_name = text.strip()
|
||
if not cat_name:
|
||
menu = await self.get_menu()
|
||
await event.reply("⚠️ نام دستهبندی نمیتواند خالی باشد.", buttons=menu)
|
||
return
|
||
await self.repo.update_category(cat_id, name=cat_name)
|
||
card, buttons = await self._render_category_detail(cat_id)
|
||
await event.reply(f"✅ نام دستهبندی به <b>{cat_name}</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 a custom history-fetch count
|
||
elif action == "wait_fetch_count":
|
||
ch_id = state.get("channel_id")
|
||
digits = re.findall(r"\d+", text)
|
||
if not digits:
|
||
await event.reply(
|
||
f"⚠️ لطفا فقط یک عدد بین {MIN_FETCH_LIMIT} تا {MAX_FETCH_LIMIT} بفرستید:",
|
||
buttons=get_cancel_button(),
|
||
)
|
||
return
|
||
count = int(digits[0])
|
||
if not (MIN_FETCH_LIMIT <= count <= MAX_FETCH_LIMIT):
|
||
await event.reply(
|
||
f"⚠️ عدد باید بین <b>{MIN_FETCH_LIMIT}</b> و <b>{MAX_FETCH_LIMIT}</b> باشد. دوباره بفرستید:",
|
||
parse_mode="html",
|
||
buttons=get_cancel_button(),
|
||
)
|
||
return
|
||
|
||
self.user_states.pop(event.sender_id, None)
|
||
if not self.collector:
|
||
await event.reply("سرویس کالکتور متصل نیست.")
|
||
return
|
||
|
||
msg = await event.reply(
|
||
f"⏳ در حال دریافت <b>{count}</b> پست گذشته از <code>{ch_id}</code>...",
|
||
parse_mode="html",
|
||
)
|
||
|
||
async def progress_notify(txt: str):
|
||
await msg.edit(txt, parse_mode="html")
|
||
|
||
await self.collector.scrape_channel_history(
|
||
channel_id=ch_id, limit=count, progress_callback=progress_notify
|
||
)
|
||
|
||
# 8. Waiting for Login Code
|
||
elif action == "wait_login_code":
|
||
self.user_states.pop(event.sender_id, None)
|
||
if not self.collector:
|
||
await event.reply("سرویس کالکتور متصل نیست.")
|
||
return
|
||
res = await self.collector.submit_code(text)
|
||
menu = await self.get_menu()
|
||
await event.reply(res, parse_mode="html", buttons=menu)
|
||
|
||
# 8.1. Waiting for Website Input (Name and URL)
|
||
elif action == "wait_website_input":
|
||
self.user_states.pop(event.sender_id, None)
|
||
parts = text.split()
|
||
if len(parts) < 2:
|
||
menu = await self.get_menu()
|
||
await event.reply("❌ فرمت نامعتبر است. لطفاً نام و آدرس را با فاصله ارسال کنید (مثال: <code>دیجیاتو https://digiato.com</code>).", parse_mode="html", buttons=menu)
|
||
return
|
||
|
||
if parts[-1].startswith(("http://", "https://", "www.")) or "." in parts[-1]:
|
||
url = parts[-1]
|
||
name = " ".join(parts[:-1])
|
||
else:
|
||
url = parts[0]
|
||
name = " ".join(parts[1:])
|
||
|
||
if not url.startswith("http://") and not url.startswith("https://"):
|
||
url = "https://" + url
|
||
|
||
site_id = await self.repo.add_source_website(name=name, url=url, api_config={})
|
||
card, buttons = await self._render_website_config(site_id)
|
||
await event.reply(
|
||
f"✅ <b>وبسایت «{name}» افزوده شد!</b>\n\n"
|
||
f"اکنون میتوانید با دکمه <b>📝 نیازمندیهای استخراج</b> موضوعات مدنظرتان را تعیین کنید یا با دکمه <b>🤖 شروع / تکرار تحلیل با AI</b> استخراج خودکار را آغاز کنید.\n\n"
|
||
f"{card}",
|
||
parse_mode="html",
|
||
buttons=buttons
|
||
)
|
||
|
||
# 8.2. Waiting for Website Custom Needs / Extraction Instructions
|
||
elif action == "wait_website_needs":
|
||
site_id = state.get("site_id")
|
||
self.user_states.pop(event.sender_id, None)
|
||
instr = "" if text.strip() in ("/clear", "clear", "حذف", "پاک") else text.strip()
|
||
await self.repo.update_source_website_custom_instructions(site_id, instr)
|
||
card, buttons = await self._render_website_config(site_id)
|
||
if instr:
|
||
await event.reply("✅ <b>نیازمندیها و فیلترهای استخراج وبسایت با موفقیت ذخیره شد!</b>", parse_mode="html")
|
||
else:
|
||
await event.reply("✅ <b>فیلتر اختصاصی حذف شد (استخراج عمومی فعال شد).</b>", parse_mode="html")
|
||
await event.reply(card, parse_mode="html", buttons=buttons)
|
||
|
||
# 8.2. Waiting for Website Fetch Interval
|
||
elif action == "wait_website_intv":
|
||
site_id = state.get("site_id")
|
||
self.user_states.pop(event.sender_id, None)
|
||
try:
|
||
val = int(text.strip())
|
||
await self.repo.update_source_website_interval(site_id, val)
|
||
card, buttons = await self._render_website_config(site_id)
|
||
await event.reply(f"✅ فاصله بررسی وبسایت روی هر <b>{val} دقیقه</b> تنظیم شد.", parse_mode="html")
|
||
await event.reply(card, parse_mode="html", buttons=buttons)
|
||
except ValueError:
|
||
menu = await self.get_menu()
|
||
await event.reply("❌ مقدار وارد شده باید یک عدد صحیح (به دقیقه) باشد.", buttons=menu)
|
||
|
||
# 8.3. Waiting for Website Auto Re-analysis Interval
|
||
elif action == "wait_website_reintv":
|
||
site_id = state.get("site_id")
|
||
self.user_states.pop(event.sender_id, None)
|
||
try:
|
||
val = int(text.strip())
|
||
await self.repo.update_source_website_reanalyze_hours(site_id, val)
|
||
card, buttons = await self._render_website_config(site_id)
|
||
await event.reply(f"✅ دوره تحلیل مجدد خودکار وبسایت روی هر <b>{val} ساعت</b> تنظیم شد.", parse_mode="html")
|
||
await event.reply(card, parse_mode="html", buttons=buttons)
|
||
except ValueError:
|
||
menu = await self.get_menu()
|
||
await event.reply("❌ مقدار وارد شده باید یک عدد صحیح (به ساعت) باشد.", buttons=menu)
|
||
|
||
# 9. Waiting for AI Model
|
||
elif action == "wait_ai_model":
|
||
self.user_states.pop(event.sender_id, None)
|
||
new_model = text.strip()
|
||
await self.repo.set_setting("ai_model", new_model)
|
||
if self.ai_processor and self.ai_processor.llm:
|
||
await self.ai_processor.llm.sync_config_from_repo()
|
||
card, buttons = await self._render_ai_menu()
|
||
await event.reply(f"✅ <b>مدل فعال به <code>{new_model}</code> تغییر یافت!</b>", parse_mode="html")
|
||
await event.reply(card, parse_mode="html", buttons=buttons)
|
||
|
||
# 10. Waiting for AI Base URL
|
||
elif action == "wait_ai_base_url":
|
||
self.user_states.pop(event.sender_id, None)
|
||
new_url = "" if text.strip().lower() in ("none", "null", "خالی", "default") else text.strip()
|
||
await self.repo.set_setting("ai_base_url", new_url)
|
||
if self.ai_processor and self.ai_processor.llm:
|
||
await self.ai_processor.llm.sync_config_from_repo()
|
||
card, buttons = await self._render_ai_menu()
|
||
await event.reply("✅ <b>Base URL با موفقیت ذخیره شد!</b>", parse_mode="html")
|
||
await event.reply(card, parse_mode="html", buttons=buttons)
|
||
|
||
# 11. Waiting for AI API Key
|
||
elif action == "wait_ai_api_key":
|
||
self.user_states.pop(event.sender_id, None)
|
||
new_key = "" if text.strip().lower() in ("none", "null", "خالی") else text.strip()
|
||
await self.repo.set_setting("ai_api_key", new_key)
|
||
if self.ai_processor and self.ai_processor.llm:
|
||
await self.ai_processor.llm.sync_config_from_repo()
|
||
card, buttons = await self._render_ai_menu()
|
||
await event.reply("✅ <b>کلید API با موفقیت ذخیره شد!</b>", parse_mode="html")
|
||
await event.reply(card, parse_mode="html", buttons=buttons)
|
||
|
||
# 12. Waiting for Provider Profile Name
|
||
elif action == "wait_prov_name":
|
||
name = text.strip()
|
||
self.user_states[event.sender_id] = {"action": "wait_prov_type", "prov_name": name}
|
||
type_buttons = [
|
||
[
|
||
Button.inline("🤖 AGY (سرور داخلی/لوکال)", data="ai_pv_type_sel:agy"),
|
||
Button.inline("⚡️ OpenAI / OrcaRouter", data="ai_pv_type_sel:openai"),
|
||
],
|
||
[
|
||
Button.inline("🌟 Google Gemini", data="ai_pv_type_sel:gemini"),
|
||
],
|
||
[
|
||
Button.inline("❌ انصراف", data="cancel_state")
|
||
]
|
||
]
|
||
await event.reply(
|
||
f"📌 نام سرویسدهنده: <b>{name}</b>\n\n"
|
||
"اکنون <b>نوع سرویس (Provider Type)</b> را انتخاب کنید:",
|
||
parse_mode="html",
|
||
buttons=type_buttons
|
||
)
|
||
|
||
# 13. Waiting for Provider Profile Model
|
||
elif action == "wait_prov_model":
|
||
model_name = text.strip()
|
||
state["prov_model"] = model_name
|
||
self.user_states[event.sender_id] = {
|
||
"action": "wait_prov_url",
|
||
"prov_name": state["prov_name"],
|
||
"prov_type": state["prov_type"],
|
||
"prov_model": model_name
|
||
}
|
||
default_url = "http://host.docker.internal:8000/v1" if state["prov_type"] == "agy" else ("https://api.orcarouter.ai/v1" if state["prov_type"] == "openai" else "https://generativelanguage.googleapis.com/v1beta")
|
||
await event.reply(
|
||
f"🤖 مدل: <code>{model_name}</code>\n\n"
|
||
f"اکنون <b>آدرس Base URL</b> را ارسال کنید:\n"
|
||
f"<i>(پیشفرض: <code>{default_url}</code> - برای استفاده از پیشفرض کلمه <code>default</code> یا خالی بفرستید)</i>",
|
||
parse_mode="html",
|
||
buttons=get_cancel_button()
|
||
)
|
||
|
||
# 14. Waiting for Provider Profile Base URL
|
||
elif action == "wait_prov_url":
|
||
url_val = "" if text.strip().lower() in ("default", "none", "null", "خالی") else text.strip()
|
||
state["prov_url"] = url_val
|
||
self.user_states[event.sender_id] = {
|
||
"action": "wait_prov_key",
|
||
"prov_name": state["prov_name"],
|
||
"prov_type": state["prov_type"],
|
||
"prov_model": state["prov_model"],
|
||
"prov_url": url_val
|
||
}
|
||
await event.reply(
|
||
"🔑 اکنون <b>کلید API (API Key)</b> را ارسال کنید:\n"
|
||
"<i>(اگر سرور داخلی بدون کلید است، کلمه <code>none</code> یا <code>خالی</code> بفرستید)</i>",
|
||
parse_mode="html",
|
||
buttons=get_cancel_button()
|
||
)
|
||
|
||
# 15. Waiting for Provider Profile API Key
|
||
elif action == "wait_prov_key":
|
||
key_val = "" if text.strip().lower() in ("none", "null", "خالی") else text.strip()
|
||
name = state["prov_name"]
|
||
ptype = state["prov_type"]
|
||
pmodel = state["prov_model"]
|
||
purl = state.get("prov_url", "")
|
||
self.user_states.pop(event.sender_id, None)
|
||
|
||
prof_id = await self.repo.add_provider_profile(
|
||
name=name,
|
||
provider_type=ptype,
|
||
model=pmodel,
|
||
base_url=purl,
|
||
api_key=key_val,
|
||
is_active=False
|
||
)
|
||
card, buttons = await self._render_ai_provider_detail(prof_id)
|
||
await event.reply(f"✅ <b>سرویسدهنده «{name}» با موفقیت اضافه شد!</b>", parse_mode="html")
|
||
await event.reply(card, parse_mode="html", buttons=buttons)
|
||
|
||
# 16. Waiting to edit specific provider model
|
||
elif action == "wait_pv_edit_model":
|
||
prof_id = state["prof_id"]
|
||
self.user_states.pop(event.sender_id, None)
|
||
await self.repo.update_provider_profile(prof_id, model=text.strip())
|
||
if self.ai_processor and self.ai_processor.llm:
|
||
await self.ai_processor.llm.sync_config_from_repo()
|
||
card, buttons = await self._render_ai_provider_detail(prof_id)
|
||
await event.reply("✅ <b>مدل سرویسدهنده بروز شد!</b>", parse_mode="html")
|
||
await event.reply(card, parse_mode="html", buttons=buttons)
|
||
|
||
# 17. Waiting to edit specific provider URL
|
||
elif action == "wait_pv_edit_url":
|
||
prof_id = state["prof_id"]
|
||
self.user_states.pop(event.sender_id, None)
|
||
new_url = "" if text.strip().lower() in ("default", "none", "null", "خالی") else text.strip()
|
||
await self.repo.update_provider_profile(prof_id, base_url=new_url)
|
||
if self.ai_processor and self.ai_processor.llm:
|
||
await self.ai_processor.llm.sync_config_from_repo()
|
||
card, buttons = await self._render_ai_provider_detail(prof_id)
|
||
await event.reply("✅ <b>Base URL سرویسدهنده بروز شد!</b>", parse_mode="html")
|
||
await event.reply(card, parse_mode="html", buttons=buttons)
|
||
|
||
# 18. Waiting to edit specific provider Key
|
||
elif action == "wait_pv_edit_key":
|
||
prof_id = state["prof_id"]
|
||
self.user_states.pop(event.sender_id, None)
|
||
new_key = "" if text.strip().lower() in ("none", "null", "خالی") else text.strip()
|
||
await self.repo.update_provider_profile(prof_id, api_key=new_key)
|
||
if self.ai_processor and self.ai_processor.llm:
|
||
await self.ai_processor.llm.sync_config_from_repo()
|
||
card, buttons = await self._render_ai_provider_detail(prof_id)
|
||
await event.reply("✅ <b>کلید API سرویسدهنده بروز شد!</b>", parse_mode="html")
|
||
await event.reply(card, parse_mode="html", buttons=buttons)
|
||
|
||
|
||
|
||
|
||
async def _handle_callback(self, 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("لغو شد.")
|
||
|
||
# --- AI Settings & Logs Callbacks ---
|
||
elif data == "ai_menu":
|
||
text, buttons = await self._render_ai_menu()
|
||
await event.edit(text, parse_mode="html", buttons=buttons)
|
||
await event.answer()
|
||
|
||
elif data == "ai_prov_menu":
|
||
text, buttons = await self._render_ai_provider_menu()
|
||
await event.edit(text, parse_mode="html", buttons=buttons)
|
||
await event.answer()
|
||
|
||
elif data.startswith("ai_set_prov:"):
|
||
new_prov = data.split(":")[1]
|
||
await self.repo.set_setting("ai_provider", new_prov)
|
||
if self.ai_processor and self.ai_processor.llm:
|
||
await self.ai_processor.llm.sync_config_from_repo()
|
||
text, buttons = await self._render_ai_menu()
|
||
await event.edit(text, parse_mode="html", buttons=buttons)
|
||
await event.answer(f"سرویسدهنده به {new_prov} تغییر یافت!", alert=True)
|
||
|
||
elif data == "ai_set_model":
|
||
self.user_states[event.sender_id] = {"action": "wait_ai_model"}
|
||
await event.reply(
|
||
"🏷 <b>نام مدل مورد نظر را ارسال کنید:</b>\n"
|
||
"<i>(مثال: antigravity یا google/gemini-3.5-flash)</i>",
|
||
parse_mode="html",
|
||
buttons=get_cancel_button()
|
||
)
|
||
await event.answer()
|
||
|
||
elif data == "ai_set_url":
|
||
self.user_states[event.sender_id] = {"action": "wait_ai_base_url"}
|
||
await event.reply(
|
||
"🌐 <b>آدرس Base URL را ارسال کنید:</b>\n"
|
||
"<i>(برای پیشفرض یا لوکال، کلمه default یا خالی بفرستید)</i>",
|
||
parse_mode="html",
|
||
buttons=get_cancel_button()
|
||
)
|
||
await event.answer()
|
||
|
||
elif data == "ai_set_key":
|
||
self.user_states[event.sender_id] = {"action": "wait_ai_api_key"}
|
||
await event.reply(
|
||
"🔑 <b>کلید API جدید را ارسال کنید:</b>",
|
||
parse_mode="html",
|
||
buttons=get_cancel_button()
|
||
)
|
||
await event.answer()
|
||
|
||
elif data == "ai_toggle_dc":
|
||
current = await self.repo.get_setting("ai_double_check", os.getenv("AI_DOUBLE_CHECK", "false"))
|
||
new_val = "false" if current.lower() in ("true", "1", "yes") else "true"
|
||
await self.repo.set_setting("ai_double_check", new_val)
|
||
text, buttons = await self._render_ai_menu()
|
||
await event.edit(text, parse_mode="html", buttons=buttons)
|
||
await event.answer(f"بررسی مجدد: {'فعال' if new_val == 'true' else 'غیرفعال'}")
|
||
|
||
elif data == "ai_reason_menu":
|
||
text, buttons = await self._render_ai_reasoning_menu()
|
||
await event.edit(text, parse_mode="html", buttons=buttons)
|
||
await event.answer()
|
||
|
||
elif data.startswith("ai_set_reason:"):
|
||
lvl = data.split(":")[1]
|
||
val = "" if lvl == "none" else lvl
|
||
await self.repo.set_setting("ai_reasoning_effort", val)
|
||
if self.ai_processor and self.ai_processor.llm:
|
||
await self.ai_processor.llm.sync_config_from_repo()
|
||
text, buttons = await self._render_ai_menu()
|
||
await event.edit(text, parse_mode="html", buttons=buttons)
|
||
await event.answer(f"میزان تفکر روی {lvl} تنظیم شد!")
|
||
|
||
elif data.startswith("ai_logs:"):
|
||
limit = int(data.split(":")[1])
|
||
text, buttons = await self._render_ai_logs(limit)
|
||
await event.edit(text, parse_mode="html", buttons=buttons)
|
||
await event.answer()
|
||
|
||
elif data.startswith("ai_log_view:"):
|
||
log_id = int(data.split(":")[1])
|
||
text, buttons = await self._render_ai_log_detail(log_id)
|
||
await event.edit(text, parse_mode="html", buttons=buttons)
|
||
await event.answer()
|
||
|
||
elif data == "ai_test":
|
||
await event.answer("⏳ در حال ارسال پرامپت تستی...", alert=False)
|
||
test_prompt = "پست آزمایشی: سیستم هوشمند کپیکار با معماری Multi-Agent فعال است."
|
||
test_system = "متن ورودی را بازنویسی کن و خروجی را فقط به صورت JSON با کلید 'rewritten_text' برگردان."
|
||
t0 = time.time()
|
||
try:
|
||
if self.ai_processor and self.ai_processor.llm:
|
||
res = await self.ai_processor.llm.generate_json(
|
||
prompt=test_prompt,
|
||
system_prompt=test_system,
|
||
action_name="test_connection"
|
||
)
|
||
latency = time.time() - t0
|
||
model_used = getattr(self.ai_processor.llm, "last_used_model", self.ai_processor.llm.model)
|
||
provider_used = self.ai_processor.llm.provider
|
||
res_text = json.dumps(res, ensure_ascii=False, indent=2)
|
||
card = (
|
||
"🧪 <b>نتیجه تست موفقیتآمیز اتصال هوش مصنوعی:</b>\n\n"
|
||
f"• 🔌 <b>Provider:</b> <code>{provider_used}</code>\n"
|
||
f"• 🏷 <b>Model:</b> <code>{model_used}</code>\n"
|
||
f"• ⏱ <b>مدت زمان پاسخ:</b> <b>{latency:.2f} ثانیه</b>\n"
|
||
f"• 📊 <b>وضعیت:</b> ✅ موفق (Success)\n\n"
|
||
f"📥 <b>Input Prompt:</b>\n<code>{test_prompt}</code>\n\n"
|
||
f"📤 <b>Output JSON:</b>\n<code>{res_text}</code>"
|
||
)
|
||
else:
|
||
card = "❌ سرویس هوش مصنوعی بارگذاری نشده است."
|
||
except Exception as e:
|
||
latency = time.time() - t0
|
||
card = (
|
||
"🧪 <b>نتیجه تست اتصال هوش مصنوعی:</b>\n\n"
|
||
f"• ⏱ <b>مدت زمان:</b> {latency:.2f}s\n"
|
||
f"• 📊 <b>وضعیت:</b> ❌ خطا (Failed)\n\n"
|
||
f"❌ <b>علت خطا:</b>\n<code>{e}</code>"
|
||
)
|
||
buttons = [
|
||
[Button.inline("🔄 اجرای مجدد تست", data="ai_test")],
|
||
[Button.inline("🔙 بازگشت به تنظیمات AI", data="ai_menu")]
|
||
]
|
||
await event.edit(card, parse_mode="html", buttons=buttons)
|
||
|
||
elif data == "ai_prov_list":
|
||
text, buttons = await self._render_ai_providers_list()
|
||
await event.edit(text, parse_mode="html", buttons=buttons)
|
||
await event.answer()
|
||
|
||
elif data.startswith("ai_pv_view:"):
|
||
prof_id = int(data.split(":")[1])
|
||
text, buttons = await self._render_ai_provider_detail(prof_id)
|
||
await event.edit(text, parse_mode="html", buttons=buttons)
|
||
await event.answer()
|
||
|
||
elif data.startswith("ai_pv_activate:"):
|
||
prof_id = int(data.split(":")[1])
|
||
await self.repo.set_active_provider_profile(prof_id)
|
||
if self.ai_processor and self.ai_processor.llm:
|
||
await self.ai_processor.llm.sync_config_from_repo()
|
||
text, buttons = await self._render_ai_provider_detail(prof_id)
|
||
await event.edit(text, parse_mode="html", buttons=buttons)
|
||
await event.answer("✅ این سرویسدهنده با موفقیت فعال شد!")
|
||
|
||
elif data.startswith("ai_pv_test:"):
|
||
prof_id = int(data.split(":")[1])
|
||
prof = await self.repo.get_provider_profile_by_id(prof_id)
|
||
if not prof:
|
||
await event.answer("سرویسدهنده یافت نشد.", alert=True)
|
||
return
|
||
await event.answer("⏳ در حال ارسال درخواست تست به ارائهدهنده...")
|
||
test_client = LLMClient(
|
||
provider=prof.provider_type,
|
||
api_key=prof.api_key,
|
||
model=prof.model,
|
||
base_url=prof.base_url,
|
||
reasoning_effort=prof.reasoning_effort,
|
||
repo=self.repo
|
||
)
|
||
t0 = time.time()
|
||
test_img_path = None
|
||
if getattr(prof, "supports_vision", False):
|
||
test_img_path = "/app/data/media/vision_test.jpg" if os.path.exists("/app") else "/projects/telegram-bots/copykar/data/media/vision_test.jpg"
|
||
if not os.path.exists(test_img_path):
|
||
try:
|
||
from PIL import Image, ImageDraw
|
||
os.makedirs(os.path.dirname(test_img_path), exist_ok=True)
|
||
img = Image.new('RGB', (400, 200), color=(240, 240, 240))
|
||
d = ImageDraw.Draw(img)
|
||
d.rectangle([(20, 20), (380, 180)], outline=(0, 120, 215), width=4)
|
||
d.text((50, 80), 'CODE: COPYKAR-TEST-7799', fill=(200, 30, 30))
|
||
img.save(test_img_path)
|
||
except Exception:
|
||
test_img_path = None
|
||
|
||
if test_img_path:
|
||
test_prompt = "Say hello, analyze the attached image, and return JSON with keys: status (ok), message (Hello from Copykar), ping (pong), and image_seen (describe what text/code is visible in the image)."
|
||
else:
|
||
test_prompt = "Say hello and return JSON with keys: status (ok), message (Hello from Copykar), and ping (pong)."
|
||
|
||
try:
|
||
res = await test_client.generate_json(
|
||
prompt=test_prompt,
|
||
system_prompt="You are a test agent. Always reply with valid JSON only.",
|
||
action_name="test_provider_profile",
|
||
image_path=test_img_path
|
||
)
|
||
latency = time.time() - t0
|
||
res_text = json.dumps(res, ensure_ascii=False, indent=2)
|
||
vision_info = "🟢 ارسال و با تصویر پردازش شد" if test_img_path else "⚪️ فقط متن (غیرفعال)"
|
||
card = (
|
||
f"🧪 <b>نتیجه تست ارائهدهنده «{prof.name}»:</b>\n\n"
|
||
f"• 🔌 <b>Provider:</b> <code>{prof.provider_type}</code>\n"
|
||
f"• 🏷 <b>Model:</b> <code>{prof.model}</code>\n"
|
||
f"• 🖼 <b>پردازش تصویر:</b> {vision_info}\n"
|
||
f"• ⏱ <b>مدت زمان:</b> {latency:.2f}s\n"
|
||
f"• 📊 <b>وضعیت:</b> ✅ موفق (Success)\n\n"
|
||
f"📥 <b>Input Prompt:</b>\n<code>{test_prompt}</code>\n\n"
|
||
f"📤 <b>Output JSON:</b>\n<code>{res_text}</code>"
|
||
)
|
||
except Exception as e:
|
||
latency = time.time() - t0
|
||
card = (
|
||
f"🧪 <b>نتیجه تست ارائهدهنده «{prof.name}»:</b>\n\n"
|
||
f"• 🔌 <b>Provider:</b> <code>{prof.provider_type}</code>\n"
|
||
f"• 🏷 <b>Model:</b> <code>{prof.model}</code>\n"
|
||
f"• ⏱ <b>مدت زمان:</b> {latency:.2f}s\n"
|
||
f"• 📊 <b>وضعیت:</b> ❌ خطا (Failed)\n\n"
|
||
f"❌ <b>علت خطا:</b>\n<code>{e}</code>"
|
||
)
|
||
buttons = [
|
||
[Button.inline("🔄 اجرای مجدد تست", data=f"ai_pv_test:{prof.id}")],
|
||
[Button.inline("🔙 بازگشت به جزئیات", data=f"ai_pv_view:{prof.id}")]
|
||
]
|
||
await event.edit(card, parse_mode="html", buttons=buttons)
|
||
|
||
elif data.startswith("ai_pv_fb_menu:"):
|
||
prof_id = int(data.split(":")[1])
|
||
text, buttons = await self._render_ai_provider_fallback_menu(prof_id)
|
||
await event.edit(text, parse_mode="html", buttons=buttons)
|
||
await event.answer()
|
||
|
||
elif data.startswith("ai_pv_set_fb:"):
|
||
_, prof_id_str, fb_id_str = data.split(":")
|
||
prof_id = int(prof_id_str)
|
||
fb_id = int(fb_id_str)
|
||
target_fb_id = fb_id if fb_id > 0 else None
|
||
await self.repo.update_provider_fallback(prof_id, target_fb_id)
|
||
if self.ai_processor and self.ai_processor.llm:
|
||
await self.ai_processor.llm.sync_config_from_repo()
|
||
text, buttons = await self._render_ai_provider_detail(prof_id)
|
||
await event.edit(text, parse_mode="html", buttons=buttons)
|
||
await event.answer("✅ ارائهدهنده پشتیبان دستی ذخیره شد.")
|
||
|
||
elif data.startswith("ai_pv_vision:"):
|
||
prof_id = int(data.split(":")[1])
|
||
p = await self.repo.get_provider_profile_by_id(prof_id)
|
||
if p:
|
||
new_vision = not getattr(p, "supports_vision", False)
|
||
await self.repo.update_provider_vision(prof_id, new_vision)
|
||
if self.ai_processor and self.ai_processor.llm:
|
||
await self.ai_processor.llm.sync_config_from_repo()
|
||
text, buttons = await self._render_ai_provider_detail(prof_id)
|
||
await event.edit(text, parse_mode="html", buttons=buttons)
|
||
await event.answer(f"✅ پردازش تصویر {'فعال' if new_vision else 'غیرفعال'} شد!")
|
||
else:
|
||
await event.answer("❌ سرویسدهنده یافت نشد.", alert=True)
|
||
|
||
elif data.startswith("ai_pv_effort:"):
|
||
prof_id = int(data.split(":")[1])
|
||
text, buttons = await self._render_ai_provider_effort_menu(prof_id)
|
||
await event.edit(text, parse_mode="html", buttons=buttons)
|
||
await event.answer()
|
||
|
||
elif data.startswith("ai_pv_set_effort:"):
|
||
_, prof_id_str, effort_val = data.split(":")
|
||
prof_id = int(prof_id_str)
|
||
effort = "" if effort_val == "none" else effort_val
|
||
await self.repo.update_provider_reasoning_effort(prof_id, effort)
|
||
if self.ai_processor and self.ai_processor.llm:
|
||
await self.ai_processor.llm.sync_config_from_repo()
|
||
text, buttons = await self._render_ai_provider_detail(prof_id)
|
||
await event.edit(text, parse_mode="html", buttons=buttons)
|
||
await event.answer(f"✅ قدرت استدلال روی {effort or 'خاموش'} تنظیم شد!")
|
||
|
||
elif data.startswith("ai_pv_model:"):
|
||
prof_id = int(data.split(":")[1])
|
||
self.user_states[event.sender_id] = {"action": "wait_pv_edit_model", "prof_id": prof_id}
|
||
await event.reply("🏷 <b>نام مدل جدید (Model Name) را وارد کنید:</b>", parse_mode="html", buttons=get_cancel_button())
|
||
await event.answer()
|
||
|
||
elif data.startswith("ai_pv_url:"):
|
||
prof_id = int(data.split(":")[1])
|
||
self.user_states[event.sender_id] = {"action": "wait_pv_edit_url", "prof_id": prof_id}
|
||
await event.reply("🌐 <b>آدرس Base URL جدید را وارد کنید:</b>\n<i>(برای پیشفرض کلمه <code>default</code> یا خالی بفرستید)</i>", parse_mode="html", buttons=get_cancel_button())
|
||
await event.answer()
|
||
|
||
elif data.startswith("ai_pv_key:"):
|
||
prof_id = int(data.split(":")[1])
|
||
self.user_states[event.sender_id] = {"action": "wait_pv_edit_key", "prof_id": prof_id}
|
||
await event.reply("🔑 <b>کلید API جدید را وارد کنید:</b>\n<i>(اگر سرور بدون کلید است، کلمه <code>none</code> یا خالی بفرستید)</i>", parse_mode="html", buttons=get_cancel_button())
|
||
await event.answer()
|
||
|
||
elif data.startswith("ai_pv_del:"):
|
||
prof_id = int(data.split(":")[1])
|
||
await self.repo.delete_provider_profile(prof_id)
|
||
text, buttons = await self._render_ai_providers_list()
|
||
await event.edit(text, parse_mode="html", buttons=buttons)
|
||
await event.answer("سرویسدهنده حذف شد.")
|
||
|
||
|
||
elif data == "ai_add_prov":
|
||
self.user_states[event.sender_id] = {"action": "wait_prov_name"}
|
||
await event.reply(
|
||
"➕ <b>ثبت سرویسدهنده جدید (New AI Provider):</b>\n\n"
|
||
"لطفا یک <b>نام دلخواه برای این سرویسدهنده</b> بفرستید:\n"
|
||
"<i>(مثال: سرور لوکال AGY یا OpenRouter سریع یا DeepSeek)</i>",
|
||
parse_mode="html",
|
||
buttons=get_cancel_button()
|
||
)
|
||
await event.answer()
|
||
|
||
elif data.startswith("ai_pv_type_sel:"):
|
||
sel_type = data.split(":")[1]
|
||
state = self.user_states.get(event.sender_id, {})
|
||
state["prov_type"] = sel_type
|
||
state["action"] = "wait_prov_model"
|
||
default_model = "antigravity" if sel_type == "agy" else ("google/gemini-2.5-flash" if sel_type == "openai" else "gemini-1.5-flash")
|
||
await event.reply(
|
||
f"📌 نوع انتخابی: <code>{sel_type}</code>\n\n"
|
||
f"لطفا <b>نام مدل (Model Name)</b> را ارسال کنید:\n"
|
||
f"<i>(پیشنهادی: <code>{default_model}</code>)</i>",
|
||
parse_mode="html",
|
||
buttons=get_cancel_button()
|
||
)
|
||
await event.answer()
|
||
|
||
elif data.startswith("rep_gr:"):
|
||
time_range = data.split(":")[1]
|
||
await event.answer(f"⏳ در حال رسم نمودار بازه {time_range}...")
|
||
try:
|
||
chart_jpg, filename = await generate_metrics_graph(time_range)
|
||
label_map = {"15m": "۱۵ دقیقه اخیر", "3h": "۳ ساعت اخیر", "24h": "۲۴ ساعت اخیر"}
|
||
caption = (
|
||
f"📊 <b>نمودار متریکهای عملکرد سیستم ({label_map.get(time_range, time_range)}):</b>\n\n"
|
||
f"📁 <code>{filename}</code>\n\n"
|
||
"• 📥 نرخ دریافت و انتشار پیام\n"
|
||
"• 🧠 نرخ درخواست هوش مصنوعی\n"
|
||
"• 📬 حجم صف پیامها\n"
|
||
"• ⚠️ نرخ خطاهای سیستم"
|
||
)
|
||
chart_file = io.BytesIO(chart_jpg)
|
||
chart_file.name = filename
|
||
await self.client.send_file(
|
||
event.chat_id,
|
||
file=chart_file,
|
||
caption=caption,
|
||
parse_mode="html"
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"Failed to generate metrics graph: {e}", exc_info=True)
|
||
await event.reply(f"❌ خطا در تولید نمودار گرافیکی: {e}")
|
||
|
||
|
||
elif data == "rep_refresh":
|
||
report_text = await get_instant_metrics_report()
|
||
buttons = [
|
||
[
|
||
Button.inline("⏱ نمودار ۱۵ دقیقه اخیر", data="rep_gr:15m"),
|
||
Button.inline("⏱ نمودار ۳ ساعت اخیر", data="rep_gr:3h"),
|
||
],
|
||
[
|
||
Button.inline("⏱ نمودار ۲۴ ساعت اخیر", data="rep_gr:24h"),
|
||
Button.inline("🔄 بروزرسانی گزارش", data="rep_refresh"),
|
||
]
|
||
]
|
||
await event.edit(report_text, parse_mode="html", buttons=buttons)
|
||
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)
|
||
if not target:
|
||
await event.answer("کانال مقصد یافت نشد.", alert=True)
|
||
return
|
||
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)
|
||
if not target:
|
||
await event.answer("کانال مقصد یافت نشد.", alert=True)
|
||
return
|
||
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_cprompt:"):
|
||
target_id = int(data.split(":")[1])
|
||
target = await self.repo.get_target_by_id(target_id)
|
||
if not target:
|
||
await event.answer("کانال مقصد یافت نشد.", alert=True)
|
||
return
|
||
self.user_states[event.sender_id] = {"action": "wait_custom_prompt", "target_id": target_id}
|
||
current_p = f"\n\n<b>پرامپت فعلی:</b>\n<code>{target.custom_prompt}</code>" if target.custom_prompt else ""
|
||
await event.reply(
|
||
f"📝 <b>تنظیم دستورالعمل و فرامین اختصاصی برای «{target.title}»:</b>\n\n"
|
||
"در پیام بعدی، فرامین و قوانین اختصاصی مد نظرتان برای شکلدهی به پیامهای این کانال را بفرستید.\n"
|
||
"<i>(مثال: همیشه تیتر را بولد کن، خلاصهای در ۳ خط بنویس، هشتگهای تخصصی بزن و...)</i>\n\n"
|
||
"💡 <i>برای حذف پرامپت و بازگشت به حالت پیشفرض، عبارت <code>/clear</code> را ارسال کنید.</i>"
|
||
f"{current_p}",
|
||
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)
|
||
if not target:
|
||
await event.answer("کانال مقصد یافت نشد.", alert=True)
|
||
return
|
||
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_lang:"):
|
||
target_id = int(data.split(":")[1])
|
||
text, buttons = await self._render_target_language_menu(target_id)
|
||
await event.edit(text, parse_mode="html", buttons=buttons)
|
||
await event.answer()
|
||
|
||
elif data.startswith("set_lang:"):
|
||
_, target_id_str, lang_code = data.split(":")
|
||
target_id = int(target_id_str)
|
||
if lang_code in SUPPORTED_LANGUAGES:
|
||
await self.repo.update_target_language(target_id, lang_code)
|
||
card_text, buttons = await self._render_target_config(target_id)
|
||
lang_label = SUPPORTED_LANGUAGES[lang_code]["label"]
|
||
await event.edit(card_text, parse_mode="html", buttons=buttons)
|
||
await event.answer(f"زبان کانال روی {lang_label} تنظیم شد!", alert=True)
|
||
else:
|
||
await event.answer("زبان نامعتبر است.", alert=True)
|
||
|
||
elif data.startswith("st_slp:"):
|
||
target_id = int(data.split(":")[1])
|
||
target = await self.repo.get_target_by_id(target_id)
|
||
if not target:
|
||
await event.answer("کانال مقصد یافت نشد.", alert=True)
|
||
return
|
||
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("errfix:"):
|
||
index = int(data.split(":")[1])
|
||
group = self.error_group_cache.get(index)
|
||
if not group:
|
||
await event.answer("این گزارش منقضی شده. دوباره /errors را بزنید.", alert=True)
|
||
return
|
||
service_name, error_type = group
|
||
fixed = await self.repo.resolve_errors(
|
||
service_name=service_name, error_type=error_type, note=f"marked fixed by admin {event.sender_id}"
|
||
)
|
||
ERRORS_RESOLVED_TOTAL.labels(service=service_name, error_type=error_type).inc(fixed)
|
||
text, buttons = await self._render_error_report()
|
||
await event.edit(text, parse_mode="html", buttons=buttons or None)
|
||
await event.answer(f"✅ {fixed} خطای «{error_type}» رفعشده علامت خورد.")
|
||
|
||
elif data == "errfix_all":
|
||
fixed = await self.repo.resolve_errors(note=f"bulk marked fixed by admin {event.sender_id}")
|
||
ERRORS_RESOLVED_TOTAL.labels(service="all", error_type="all").inc(fixed)
|
||
text, buttons = await self._render_error_report()
|
||
await event.edit(text, parse_mode="html", buttons=buttons or None)
|
||
await event.answer(f"✅ {fixed} خطا رفعشده علامت خورد.")
|
||
|
||
elif data == "list_src":
|
||
text, buttons = await self._render_source_list()
|
||
await event.edit(text, parse_mode="html", buttons=buttons or None)
|
||
await event.answer()
|
||
|
||
elif data.startswith("src_view:"):
|
||
source_id = int(data.split(":")[1])
|
||
card, buttons = await self._render_source_config(source_id)
|
||
await event.edit(card, parse_mode="html", buttons=buttons or None)
|
||
await event.answer()
|
||
|
||
elif data.startswith("src_ctx_menu:"):
|
||
src_id = int(data.split(":")[1])
|
||
text, buttons = await self._render_source_context_menu(src_id)
|
||
await event.edit(text, parse_mode="html", buttons=buttons)
|
||
await event.answer()
|
||
|
||
elif data.startswith("src_ctx_set:"):
|
||
_, src_id_str, cnt_str = data.split(":")
|
||
src_id = int(src_id_str)
|
||
cnt = int(cnt_str)
|
||
await self.repo.update_source_context_count(src_id, cnt)
|
||
card, buttons = await self._render_source_config(src_id)
|
||
await event.edit(card, parse_mode="html", buttons=buttons or None)
|
||
await event.answer(f"✅ تعداد پیامهای زمینه روی {cnt} تنظیم شد.")
|
||
|
||
elif data.startswith("trg_view:"):
|
||
target_id = int(data.split(":")[1])
|
||
card, buttons = await self._render_target_config(target_id)
|
||
await event.edit(card, parse_mode="html", buttons=buttons)
|
||
await event.answer()
|
||
|
||
elif data == "list_web":
|
||
text, buttons = await self._render_website_list()
|
||
await event.edit(text, parse_mode="html", buttons=buttons or None)
|
||
await event.answer()
|
||
|
||
elif data.startswith("web_view:"):
|
||
site_id = int(data.split(":")[1])
|
||
card, buttons = await self._render_website_config(site_id)
|
||
await event.edit(card, parse_mode="html", buttons=buttons)
|
||
await event.answer()
|
||
|
||
elif data == "add_web":
|
||
self.user_states[event.sender_id] = {"action": "wait_website_input"}
|
||
guide = (
|
||
"🌐 <b>افزودن وبسایت مبدا جدید:</b>\n\n"
|
||
"لطفاً نام وبسایت و آدرس URL آن را با یک فاصله یا در دو خط ارسال کنید:\n"
|
||
"<i>(مثال: <code>دیجیاتو https://digiato.com</code>)</i>\n\n"
|
||
"🤖 هوش مصنوعی بلافاصله وبسایت را بررسی کرده و اندپوینتهای دریافت خبر را کشف میکند."
|
||
)
|
||
await event.reply(guide, parse_mode="html", buttons=get_cancel_button())
|
||
await event.answer()
|
||
|
||
elif data.startswith("web_fetch:"):
|
||
site_id = int(data.split(":")[1])
|
||
await event.answer("🔄 در حال دریافت آخرین پستها از وبسایت...")
|
||
cnt = await self.website_collector.fetch_website_posts(site_id) if self.website_collector else 0
|
||
card, buttons = await self._render_website_config(site_id)
|
||
await event.edit(card, parse_mode="html", buttons=buttons)
|
||
await event.answer(f"✅ تعداد {cnt} پست جدید دریافت و پردازش شد.", alert=True)
|
||
|
||
elif data.startswith("web_reanalyze:"):
|
||
site_id = int(data.split(":")[1])
|
||
await event.answer("🤖 در حال تحلیل مجدد ساختار وبسایت توسط هوش مصنوعی...")
|
||
ok, msg = await self.website_collector.reanalyze_website(site_id) if self.website_collector else (False, "سرویس در دسترس نیست")
|
||
card, buttons = await self._render_website_config(site_id)
|
||
await event.edit(card, parse_mode="html", buttons=buttons)
|
||
await event.answer("✅ تحلیل مجدد با موفقیت انجام شد." if ok else f"❌ خطا: {msg}", alert=True)
|
||
|
||
elif data.startswith("web_intv:"):
|
||
site_id = int(data.split(":")[1])
|
||
site = await self.repo.get_source_website_by_id(site_id)
|
||
if not site:
|
||
await event.answer("وبسایت یافت نشد.", alert=True)
|
||
return
|
||
self.user_states[event.sender_id] = {"action": "wait_website_intv", "site_id": site_id}
|
||
await event.reply(
|
||
f"⏱ <b>تنظیم فاصله بررسی وبسایت «{site.name}»:</b>\n\n"
|
||
f"فاصله زمانی بررسی و استخراج اخبار (به دقیقه) را ارسال کنید:\n"
|
||
f"<i>(مثال: <code>30</code> برای هر ۳۰ دقیقه)</i>",
|
||
parse_mode="html",
|
||
buttons=get_cancel_button()
|
||
)
|
||
await event.answer()
|
||
|
||
elif data.startswith("web_reintv:"):
|
||
site_id = int(data.split(":")[1])
|
||
site = await self.repo.get_source_website_by_id(site_id)
|
||
if not site:
|
||
await event.answer("وبسایت یافت نشد.", alert=True)
|
||
return
|
||
self.user_states[event.sender_id] = {"action": "wait_website_reintv", "site_id": site_id}
|
||
await event.reply(
|
||
f"🔄 <b>تنظیم دوره تحلیل مجدد هوشمند وبسایت «{site.name}»:</b>\n\n"
|
||
f"فاصله تحلیل خودکار ساختار و کشف اندپوینتها توسط AI (به ساعت) را ارسال کنید:\n"
|
||
f"<i>(مثال: <code>24</code> برای هر ۲۴ ساعت، یا <code>0</code> برای غیرفعالسازی)</i>",
|
||
parse_mode="html",
|
||
buttons=get_cancel_button()
|
||
)
|
||
await event.answer()
|
||
|
||
elif data.startswith("web_cat:"):
|
||
site_id = int(data.split(":")[1])
|
||
text, buttons = await self._render_website_category_menu(site_id)
|
||
await event.edit(text, parse_mode="html", buttons=buttons)
|
||
await event.answer()
|
||
|
||
elif data.startswith("web_set_cat:"):
|
||
_, site_id_str, cat_id_str = data.split(":")
|
||
site_id = int(site_id_str)
|
||
cat_id = None if cat_id_str == "none" else int(cat_id_str)
|
||
await self.repo.update_source_website_category(site_id, cat_id)
|
||
card, buttons = await self._render_website_config(site_id)
|
||
await event.edit(card, parse_mode="html", buttons=buttons)
|
||
await event.answer("✅ دستهبندی وبسایت بهروز شد.")
|
||
|
||
elif data.startswith("del_web:"):
|
||
site_id = int(data.split(":")[1])
|
||
await self.repo.delete_source_website(site_id)
|
||
text, buttons = await self._render_website_list()
|
||
await event.edit(text, parse_mode="html", buttons=buttons or None)
|
||
await event.answer("🗑 وبسایت مبدا با موفقیت حذف شد.", alert=True)
|
||
|
||
elif data.startswith("trg_order:"):
|
||
target_id = int(data.split(":")[1])
|
||
target = await self.repo.get_target_by_id(target_id)
|
||
if target:
|
||
curr_order = getattr(target, "dispatch_order", "order") or "order"
|
||
new_order = "random" if curr_order == "order" else "order"
|
||
await self.repo.update_target_dispatch_order(target_id, new_order)
|
||
card, buttons = await self._render_target_config(target_id)
|
||
await event.edit(card, parse_mode="html", buttons=buttons)
|
||
await event.answer("🎲 شیوه ارسال روی تصادفی تنظیم شد." if new_order == "random" else "🟢 شیوه ارسال روی به ترتیب تنظیم شد.")
|
||
|
||
elif data.startswith("trg_ctx_menu:"):
|
||
target_id = int(data.split(":")[1])
|
||
text, buttons = await self._render_target_context_menu(target_id)
|
||
await event.edit(text, parse_mode="html", buttons=buttons)
|
||
await event.answer()
|
||
|
||
elif data.startswith("trg_ctx_set:"):
|
||
_, target_id_str, cnt_str = data.split(":")
|
||
target_id = int(target_id_str)
|
||
cnt = int(cnt_str)
|
||
await self.repo.update_target_context_count(target_id, cnt)
|
||
card, buttons = await self._render_target_config(target_id)
|
||
await event.edit(card, parse_mode="html", buttons=buttons or None)
|
||
await event.answer(f"✅ پیامهای زمینه کانال مقصد روی {cnt} تنظیم شد.")
|
||
|
||
elif data.startswith("web_needs:"):
|
||
site_id = int(data.split(":")[1])
|
||
site = await self.repo.get_source_website_by_id(site_id)
|
||
if not site:
|
||
await event.answer("وبسایت یافت نشد.", alert=True)
|
||
return
|
||
self.user_states[event.sender_id] = {"action": "wait_website_needs", "site_id": site_id}
|
||
await event.reply(
|
||
f"📝 <b>تعیین نیازمندیها و فیلترهای استخراج از «{site.name}»:</b>\n\n"
|
||
f"چه موضوعات، بخشها یا کلیدواژههایی مد نظر شماست؟ این متن به عنوان دستورالعمل در تحلیل AI قرار میگیرد:\n"
|
||
f"<i>(مثال: <code>فقط مقالات مربوط به هوش مصنوعی، فناوری و سختافزار</code> یا <code>/clear</code> برای حذف فیلتر)</i>",
|
||
parse_mode="html",
|
||
buttons=get_cancel_button()
|
||
)
|
||
await event.answer()
|
||
|
||
elif data == "hub_add_src":
|
||
self.user_states[event.sender_id] = {"action": "wait_source_fwd"}
|
||
guide = (
|
||
"📡 <b>افزودن کانال مبدا جدید:</b>\n\n"
|
||
"یک پیام از کانال مورد نظر را فوروارد کنید یا آیدی/یوزرنیم آن را بفرستید."
|
||
)
|
||
await event.reply(guide, parse_mode="html", buttons=get_cancel_button())
|
||
await event.answer()
|
||
|
||
elif data == "hub_add_trg":
|
||
self.user_states[event.sender_id] = {"action": "wait_target_fwd"}
|
||
guide = (
|
||
"🎯 <b>افزودن کانال مقصد جدید:</b>\n\n"
|
||
"یک پیام از کانال مقصد را فوروارد کنید یا آیدی/یوزرنیم آن را بفرستید."
|
||
)
|
||
await event.reply(guide, parse_mode="html", buttons=get_cancel_button())
|
||
await event.answer()
|
||
|
||
elif data == "hub_add":
|
||
text = "➕ <b>افزودن منبع یا کانال جدید:</b>\n\nلطفاً نوع مورد نظر را انتخاب کنید:"
|
||
buttons = [
|
||
[Button.inline("📡 افزودن کانال تلگرام مبدا", data="hub_add_src")],
|
||
[Button.inline("🎯 افزودن کانال تلگرام مقصد", data="hub_add_trg")],
|
||
[Button.inline("🌐 افزودن وبسایت مبدا (Web)", data="add_web")],
|
||
[Button.inline("🔙 بازگشت به منوی کپی", data="hub_copy_back")]
|
||
]
|
||
await event.edit(text, parse_mode="html", buttons=buttons)
|
||
await event.answer()
|
||
|
||
elif data == "hub_copy_back":
|
||
text, buttons = await self._render_copy_hub()
|
||
await event.edit(text, parse_mode="html", buttons=buttons)
|
||
await event.answer()
|
||
|
||
elif data == "hub_pending":
|
||
posts = await self.repo.get_unreviewed_posts(limit=50)
|
||
if not posts:
|
||
await event.answer("✅ هیچ پست بررسینشدهای در صف نیست.", alert=True)
|
||
return
|
||
for post in posts:
|
||
await self.send_raw_review_post(post.id)
|
||
await event.answer(f"✅ تعداد {len(posts)} پست بررسینشده به کانال ادمین ارسال شد.", alert=True)
|
||
|
||
elif data == "hub_cats":
|
||
text, buttons = await self._render_categories_menu()
|
||
await event.edit(text, parse_mode="html", buttons=buttons or None)
|
||
await event.answer()
|
||
|
||
elif data == "hub_req_code":
|
||
if not self.collector:
|
||
await event.answer("سرویس کالکتور متصل نیست.", alert=True)
|
||
return
|
||
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 event.reply(
|
||
f"📩 <b>کد تایید ارسال شد!</b>\n\n"
|
||
f"لطفا کد دریافت شده به شماره <code>{self.collector.phone}</code> را ارسال کنید:\n"
|
||
f"<i>(یا به فرمت /code 12345)</i>",
|
||
parse_mode="html",
|
||
buttons=get_cancel_button()
|
||
)
|
||
await event.answer("کد ارسال شد.")
|
||
except Exception as e:
|
||
await event.answer(f"خطا در ارسال کد: {e}", alert=True)
|
||
|
||
elif data == "hub_errors":
|
||
text, buttons = await self._render_error_report()
|
||
await event.edit(text, parse_mode="html", buttons=buttons or None)
|
||
await event.answer()
|
||
|
||
elif data == "sys_pause":
|
||
await self.repo.set_system_paused(True)
|
||
text, buttons = await self._render_system_hub()
|
||
await event.edit(text, parse_mode="html", buttons=buttons)
|
||
await event.answer("🛑 سیستم متوقف شد.", alert=True)
|
||
|
||
elif data == "sys_resume":
|
||
await self.repo.set_system_paused(False)
|
||
text, buttons = await self._render_system_hub()
|
||
await event.edit(text, parse_mode="html", buttons=buttons)
|
||
await event.answer("▶️ سیستم مجدداً فعال شد.", alert=True)
|
||
|
||
elif data == "sys_changes":
|
||
notes = self.get_latest_change_notes()
|
||
await event.reply(notes, parse_mode="html")
|
||
await event.answer()
|
||
|
||
elif data == "sys_help":
|
||
help_text = (
|
||
"📖 <b>راهنمای جامع سیستم کپیکار:</b>\n\n"
|
||
"• 📋 <b>کپی (Copy):</b> مدیریت پستهای بررسینشده، دستهبندیها و اضافه کردن منابع.\n"
|
||
"• 🧠 <b>هوش مصنوعی (AI):</b> تنظیم ارائهدهندهها، زنجیره پشتیبان (Fallback)، Vision و پرامپتها.\n"
|
||
"• 🤖 <b>رباتها (Bots):</b> کانالهای مبدا، کانالهای مقصد، وبسایتهای مبدا و لاگین یوزربات.\n"
|
||
"• 📊 <b>مانیتورینگ (Monitor):</b> آمار لحظهای، صفها، نرخ پردازش و لاگ خطاهای سیستم.\n"
|
||
"• ⚙️ <b>سیستم (System):</b> توقف اضطراری، راهاندازی مجدد، گزارش تغییرات و راهنما."
|
||
)
|
||
await event.reply(help_text, parse_mode="html")
|
||
await event.answer()
|
||
|
||
elif data == "back_to_main":
|
||
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"
|
||
"برای مدیریت بخشهای مختلف از دکمههای زیر استفاده کنید:"
|
||
)
|
||
menu = await self.get_menu()
|
||
await event.edit(welcome_text, parse_mode="html", buttons=menu)
|
||
await event.answer()
|
||
|
||
elif data.startswith("auto_src:"):
|
||
target_id = int(data.split(":")[1])
|
||
text, buttons = await self._render_auto_sources(target_id)
|
||
await event.edit(text, parse_mode="html", buttons=buttons)
|
||
await event.answer()
|
||
|
||
elif data.startswith("auto_tg:"):
|
||
_, target_id_str, source_channel_str = data.split(":")
|
||
target_id = int(target_id_str)
|
||
enabled = await self.repo.toggle_target_auto_source(target_id, int(source_channel_str))
|
||
text, buttons = await self._render_auto_sources(target_id)
|
||
await event.edit(text, parse_mode="html", buttons=buttons)
|
||
await event.answer("✅ ارسال خودکار روشن شد." if enabled else "🛑 ارسال خودکار خاموش شد.")
|
||
|
||
|
||
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)
|
||
text, buttons = await self._render_target_list()
|
||
await event.edit(f"🗑 <b>کانال مقصد حذف شد.</b>\n\n{text}", parse_mode="html", buttons=buttons or None)
|
||
await event.answer("کانال مقصد حذف شد.")
|
||
|
||
elif data.startswith("del_src:"):
|
||
source_id = int(data.split(":")[1])
|
||
await self.repo.delete_source(source_id)
|
||
text, buttons = await self._render_source_list()
|
||
await event.edit(f"🗑 <b>کانال مبدا حذف شد.</b>\n\n{text}", parse_mode="html", buttons=buttons or None)
|
||
await event.answer("کانال مبدا حذف شد.")
|
||
|
||
elif data == "list_trg":
|
||
text, buttons = await self._render_target_list()
|
||
await event.edit(text, parse_mode="html", buttons=buttons or None)
|
||
await event.answer()
|
||
|
||
# --- Category Callbacks ---
|
||
elif data.startswith("src_cat_view:"):
|
||
cat_id = int(data.split(":")[1])
|
||
text, buttons = await self._render_source_channels_in_category(cat_id)
|
||
await event.edit(text, parse_mode="html", buttons=buttons)
|
||
await event.answer()
|
||
|
||
elif data.startswith("trg_cat_view:"):
|
||
cat_id = int(data.split(":")[1])
|
||
text, buttons = await self._render_target_channels_in_category(cat_id)
|
||
await event.edit(text, parse_mode="html", buttons=buttons)
|
||
await event.answer()
|
||
|
||
elif data == "src_all":
|
||
text, buttons = await self._render_source_all_flat()
|
||
await event.edit(text, parse_mode="html", buttons=buttons)
|
||
await event.answer()
|
||
|
||
elif data == "trg_all":
|
||
text, buttons = await self._render_target_all_flat()
|
||
await event.edit(text, parse_mode="html", buttons=buttons)
|
||
await event.answer()
|
||
|
||
elif data == "list_cat":
|
||
text, buttons = await self._render_categories_menu()
|
||
await event.edit(text, parse_mode="html", buttons=buttons)
|
||
await event.answer()
|
||
|
||
elif data.startswith("cat_view:"):
|
||
cat_id = int(data.split(":")[1])
|
||
text, buttons = await self._render_category_detail(cat_id)
|
||
await event.edit(text, parse_mode="html", buttons=buttons)
|
||
await event.answer()
|
||
|
||
|
||
elif data == "cat_add":
|
||
self.user_states[event.sender_id] = {"action": "wait_cat_name"}
|
||
await event.reply(
|
||
"➕ <b>ایجاد دستهبندی جدید:</b>\n\n"
|
||
"لطفا یک <b>نام برای دستهبندی جدید</b> ارسال کنید:\n"
|
||
"<i>(مثال: اخبار فناوری، رمزارز و کریپتو، سینما و سرگرمی و...)</i>",
|
||
parse_mode="html",
|
||
buttons=get_cancel_button()
|
||
)
|
||
await event.answer()
|
||
|
||
elif data.startswith("cat_rename:"):
|
||
cat_id = int(data.split(":")[1])
|
||
self.user_states[event.sender_id] = {"action": "wait_cat_rename", "cat_id": cat_id}
|
||
await event.reply("✏️ <b>نام جدید دستهبندی را ارسال کنید:</b>", parse_mode="html", buttons=get_cancel_button())
|
||
await event.answer()
|
||
|
||
elif data.startswith("cat_del:"):
|
||
cat_id = int(data.split(":")[1])
|
||
await self.repo.delete_category(cat_id)
|
||
text, buttons = await self._render_categories_menu()
|
||
await event.edit(text, parse_mode="html", buttons=buttons)
|
||
await event.answer("دستهبندی حذف شد.")
|
||
|
||
elif data.startswith("src_cat:"):
|
||
src_id = int(data.split(":")[1])
|
||
text, buttons = await self._render_set_channel_category_menu("src", src_id)
|
||
await event.edit(text, parse_mode="html", buttons=buttons)
|
||
await event.answer()
|
||
|
||
elif data.startswith("trg_cat:"):
|
||
trg_id = int(data.split(":")[1])
|
||
text, buttons = await self._render_set_channel_category_menu("trg", trg_id)
|
||
await event.edit(text, parse_mode="html", buttons=buttons)
|
||
await event.answer()
|
||
|
||
elif data.startswith("cat_set:"):
|
||
_, ch_type, ch_id_str, cat_id_str = data.split(":")
|
||
ch_id = int(ch_id_str)
|
||
cat_id = None if cat_id_str == "0" else int(cat_id_str)
|
||
if ch_type == "src":
|
||
await self.repo.set_source_category(ch_id, cat_id)
|
||
card, buttons = await self._render_source_config(ch_id)
|
||
await event.edit(card, parse_mode="html", buttons=buttons)
|
||
else:
|
||
await self.repo.set_target_category(ch_id, cat_id)
|
||
card, buttons = await self._render_target_config(ch_id)
|
||
await event.edit(card, parse_mode="html", buttons=buttons)
|
||
await event.answer("✅ دستهبندی کانال با موفقیت به روز شد!")
|
||
|
||
|
||
# --- 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("histn:"):
|
||
ch_id = int(data.split(":")[1])
|
||
self.user_states[event.sender_id] = {"action": "wait_fetch_count", "channel_id": ch_id}
|
||
await event.reply(
|
||
f"🔢 <b>تعداد پستهای مورد نظر برای کانال</b> <code>{ch_id}</code>:\n\n"
|
||
f"یک عدد بین <b>{MIN_FETCH_LIMIT}</b> تا <b>{MAX_FETCH_LIMIT}</b> بفرستید.\n"
|
||
"<i>(مثال: 100 برای دریافت ۱۰۰ پست آخر)</i>",
|
||
parse_mode="html",
|
||
buttons=get_cancel_button(),
|
||
)
|
||
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
|
||
|
||
context_posts = []
|
||
if target and getattr(target, "context_message_count", 0) > 0:
|
||
context_posts = await self.repo.get_recent_target_posts(
|
||
target_id=target.id,
|
||
limit=target.context_message_count,
|
||
exclude_post_id=post.id
|
||
)
|
||
|
||
rewrite_res = await self.ai_processor.rewrite_for_target(
|
||
post.raw_text or "",
|
||
target,
|
||
has_media=bool(post.media_path),
|
||
image_path=post.media_path,
|
||
context_posts=context_posts or None
|
||
)
|
||
rewritten_text = str(rewrite_res)
|
||
cache_key = f"{post_id}:{target_id}"
|
||
self.preview_cache[cache_key] = rewritten_text
|
||
|
||
ai_warning = ""
|
||
if getattr(rewrite_res, "is_rejected", False):
|
||
reason = getattr(rewrite_res, "rejection_reason", "نامناسب برای این کانال")
|
||
ai_warning = (
|
||
f"⚠️ <b>هشدار هوش مصنوعی:</b> این پست توسط هوش مصنوعی رد شده است!\n"
|
||
f"📝 <b>علت رد:</b> <i>{reason}</i>\n"
|
||
f"➖➖➖➖➖➖➖➖➖➖\n\n"
|
||
)
|
||
|
||
model_name = getattr(self.ai_processor.llm, "last_used_model", getattr(self.ai_processor.llm, "model", "AI")) if self.ai_processor else "AI"
|
||
preview_caption = (
|
||
f"🎯 <b>پیشنمایش بازنویسی شده برای: {target.title}</b>\n"
|
||
f"🤖 <b>مدل استفادهشده:</b> <code>{model_name}</code>\n"
|
||
f"🎭 <b>شخصیت و لحن:</b> <i>{target.personality or 'پیشفرض'}</i>\n"
|
||
f"➖➖➖➖➖➖➖➖➖➖\n"
|
||
f"{ai_warning}"
|
||
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(
|
||
clamp_for_telegram(preview_caption, bool(post.media_path and os.path.exists(post.media_path))),
|
||
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_queued_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(
|
||
clamp_for_telegram(
|
||
f"✅ <b>به صف انتشار کانال {target.title} اضافه شد!</b>\n\n{new_caption}",
|
||
bool(updated_post.media_path),
|
||
),
|
||
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()
|
||
|
||
try:
|
||
original = await event.get_message()
|
||
original_text = (original.text or "") if original else ""
|
||
except Exception:
|
||
original_text = ""
|
||
|
||
await event.edit(
|
||
clamp_for_telegram(
|
||
f"{original_text}\n\n❌ <b>این پست توسط ادمین رد و بایگانی شد.</b>", False
|
||
),
|
||
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 _error_metrics_loop(self):
|
||
"""Keep the open-error gauges fresh for Prometheus without an admin opening /errors."""
|
||
while self._running:
|
||
try:
|
||
await self.refresh_error_metrics()
|
||
except Exception as e:
|
||
logger.debug(f"Could not refresh error metrics: {e}")
|
||
await asyncio.sleep(30)
|
||
|
||
async def stop(self):
|
||
self._running = False
|
||
if self._metrics_task:
|
||
self._metrics_task.cancel()
|
||
if self.client.is_connected():
|
||
await self.client.disconnect()
|
||
logger.info("Admin Bot disconnected.")
|