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… (متن طولانی بود و کوتاه شد)"
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):
pause_btn = "▶️ راهاندازی سیستم" if is_paused else "🛑 توقف سیستم"
return [
[Button.text("🏠 منوی اصلی", resize=True), Button.text("📊 آمار ناوگان", resize=True)],
[Button.text("📡 کانالهای مبدا", resize=True), Button.text("🎯 کانالهای مقصد", resize=True)],
[Button.text("🌐 وبسایتهای مبدا", resize=True), Button.text("📂 دستهبندیها", resize=True)],
[Button.text("🧠 تنظیمات AI", resize=True), Button.text("⚠️ خطاهای سیستم", resize=True)],
[Button.text("➕ افزودن مبدا / مقصد", resize=True), Button.text("📨 پستهای بررسینشده", resize=True)],
[Button.text("🔑 کد لاگین", resize=True), Button.text(pause_btn, 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"⚠️ هشدار جابجایی خودکار هوش مصنوعی (AI Fallback)\n\n"
f"❌ ارائهدهنده با خطا: «{getattr(from_profile, 'name', 'اصلی')}» ({getattr(from_profile, 'provider_type', '')} / {getattr(from_profile, 'model', '')})\n"
f"🔍 علت خطا: {clean_err}\n\n"
f"🔄 سوییچ خودکار به ارائهدهنده پشتیبان:\n"
f"✅ «{getattr(to_profile, 'name', 'پشتیبان')}» ({getattr(to_profile, 'provider_type', '')} / {getattr(to_profile, 'model', '')}) — (گام {step} در زنجیره)"
)
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"🚨 خطای بحرانی: قطعی تمامی ارائهدهندگان هوش مصنوعی (AI Chain Failure)\n\n"
f"تمامی {len(chain)} ارائهدهنده در زنجیره پشتیبان با خطا مواجه شدند:\n"
f"🔗 مسیر زنجیره: {chain_names}\n\n"
f"⚠️ آخرین خطا: {clean_err}\n\n"
f"لطفا وضعیت ارائهدهندهها، اعتبار API یا پراکسی را در منوی «🧠 تنظیمات و لاگهای AI» بررسی کنید."
)
await self.notify_admins(text)
async def notify_admins(self, text: str):
"""Broadcast Persian message to review channel and admin DMs."""
if self.review_channel_id:
try:
await self.client.send_message(self.review_channel_id, text, parse_mode="html")
except Exception as e:
logger.error(f"Failed to notify review channel: {e}")
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"{n}" 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"🎯 تنظیمات کانال مقصد: {target.title}\n\n"
f"• 🆔 شناسه کانال: {target.channel_id} (ID: {target.id})\n"
f"• 🔗 یوزرنیم: @{target.username or 'ندارد'}\n"
f"• 📁 دستهبندی: {cat_name}\n"
f"• 🌐 زبان کانال: {lang_label}\n"
f"• 🔀 ترتیب ارسال از صف: {order_label}\n"
f"• 📜 پیامهای زمینه (Context): {ctx_label}\n"
f"• ⏱ فاصله ارسال پستها: هر {target.post_interval_min} دقیقه\n"
f"• 🌙 وضعیت ساعت خواب: {sleep_st}\n"
f"• 📥 پستهای منتظر در صف: {qsize} پست\n"
f"• 🤖 ارسال خودکار: {auto_st}\n\n"
f"🎭 شخصیت و لحن نگارش:\n"
f"{target.personality or 'پیشفرض'}\n\n"
f"📝 دستورالعمل و فرامین اختصاصی (Custom Prompt):\n"
f"{custom_p or 'ثبت نشده (پیشفرض)'}\n\n"
f"🏷 فوتر و هشتگهای اختصاصی:\n"
f"{target.custom_footer or 'ندارد'}\n\n"
"👇 برای ویرایش هر بخش روی دکمه مربوطه کلیک کنید:"
)
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"{current_cnt} پیام اخیر" if current_cnt > 0 else "غیرفعال (۰ پیام)"
text = (
f"📜 تنظیم تعداد پیامهای زمینه (Context History) برای کانال مقصد\n"
f"🎯 کانال مقصد: {target.title or target.channel_id}\n\n"
f"با فعالسازی این قابلیت، هنگام بازنویسی هر پست برای این کانال، تعداد مشخصی از آخرین پستهای منتشرشده در همین کانال مقصد به هوش مصنوعی داده میشود تا خط داستانی، لحن نگارش و اخبار مرتبط اخیر رعایت شود.\n\n"
f"• وضعیت کنونی: {status_text}\n\n"
f"تعداد پیام مورد نظر را انتخاب کنید:"
)
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"🌐 انتخاب زبان برای کانال «{target.title}»\n\n"
"هوش مصنوعی پستها را متناسب با این زبان بازنویسی و ترجمه میکند:\n"
f"زبان فعلی: {SUPPORTED_LANGUAGES.get(current_lang, {}).get('label', 'فارسی 🇮🇷')}"
)
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 = (
"🧠 تنظیمات هوش مصنوعی (Multi-Provider AI System):\n\n"
f"• 🔌 پروفایل فعال: {prof_name} ({provider})\n"
f"• 🏷 مدل فعال: {model}\n"
f"• 🌐 Base URL: {url_label}\n"
f"• 🔑 API Key: {masked_key}\n"
f"• 🔍 بررسی مجدد (Double Check): {dc_status}\n"
f"• 🧠 میزان تفکر: {reason_label}\n\n"
"👇 برای تغییر پروفایل فعال، ثبت ارائهدهنده جدید یا تست روی دکمهها بزنید:"
)
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 = (
"🔌 لیست سرویسدهندههای هوش مصنوعی (AI Providers):\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 = "🟢 فعال (در حال استفاده)" if p.is_active else "⚪️ غیرفعال"
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"🔌 اطلاعات سرویسدهنده: {p.name}\n\n"
f"• 🏷 نوع سرویس (Type): {p.provider_type}\n"
f"• 🤖 مدل (Model): {p.model}\n"
f"• 🌐 Base URL: {url_label}\n"
f"• 🔑 API Key: {masked_key}\n"
f"• 🧠 تفکر (Reasoning): {p.reasoning_effort or 'پیشفرض'}\n"
f"• 🖼 پردازش تصویر (Vision): {vision_label}\n"
f"• 🔄 پشتیبان دستی (Fallback): {fallback_label}\n"
f"• ⚡️ وضعیت: {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"🔄 تنظیم دستی ارائهدهنده پشتیبان (Fallback)\n"
f"برای سرویسدهنده: {p.name}\n\n"
f"• وضعیت فعلی: {curr_fb}\n\n"
"در صورت بروز هرگونه خطا در این ارائهدهنده، درخواست هوش مصنوعی به کدام سرویسدهنده منتقل شود؟"
)
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"🧠 تنظیم حالت تفکر و استدلال (Thinking Mode / Reasoning Effort)\n"
f"برای سرویسدهنده: {p.name} (مدل: {p.model})\n\n"
f"• وضعیت فعلی: {effort_map.get(current, current)}\n\n"
"میزان عمق تفکر و استدلال مدل قبل از تولید پاسخ را انتخاب کنید:"
)
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 = "📜 هیچ لاگی از درخواستهای هوش مصنوعی یافت نشد."
buttons = [[Button.inline("🔙 بازگشت به تنظیمات AI", data="ai_menu")]]
return text, buttons
text = (
f"📜 آخرین {len(logs)} درخواست هوش مصنوعی (AI Logs):\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"📜 جزئیات درخواست هوش مصنوعی #{log.id}\n\n"
f"• ⏰ زمان: {log.created_at}\n"
f"• 📌 عملیات: {log.action_name}\n"
f"• 🔌 سرویسدهنده: {log.provider}\n"
f"• 🏷 مدل: {log.model}\n"
f"• ⏱ مدت زمان: {log.duration_sec:.2f} ثانیه\n"
f"• 📊 وضعیت: {st_icon}\n\n"
f"⚙️ System Prompt:\n{sys_preview}\n\n"
f"📥 Prompt:\n{prompt_preview}\n\n"
f"📤 Response:\n{resp_preview}"
)
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 = "📤 ارسال شده / در صف ارسال کانالهای:\n"
for item in post.published_to:
if isinstance(item, dict):
t_title = item.get("target_title", "کانال مقصد")
published_lines += f" • {t_title}\n"
published_lines += "➖➖➖➖➖➖➖➖➖➖\n\n"
rejection_line = ""
if post.status == "rejected" or post.rejection_reason:
rejection_line = (
f"❌ وضعیت: رد شده توسط هوش مصنوعی (Rejected)\n"
f"📝 علت رد: {post.rejection_reason or 'طبق ارزیابی AI'}\n"
f"➖➖➖➖➖➖➖➖➖➖\n\n"
)
caption = (
f"📥 پست جدید از مبدا ({post.source_channel_id}):\n\n"
f"{rejection_line}"
f"{published_lines}"
f"{post.raw_text or ''}\n\n"
f"👇 کانال مقصد مورد نظر را برای بازنویسی هوشمند انتخاب کنید:"
)
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 "✅ هیچ خطای رفعنشدهای در سیستم وجود ندارد.", []
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"⚠️ خطاهای رفعنشده سیستم ({total} مورد در {len(groups)} گروه)\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{i + 1}. {g['error_type']} — {g['service_name']}\n"
f" • تعداد: {g['occurrences']} | آخرین: {last_seen}\n"
f" • {message}"
)
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 "هیچ کانال مبدایی ثبت نشده است. روی ➕ افزودن کانال مبدا بزنید.", []
categories = await self.repo.get_categories()
if not categories:
# If no categories exist yet, show flat list
text = (
f"📡 کانالهای مبدا فعال ({len(sources)} کانال)\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"📡 دستهبندیهای کانالهای مبدا ({len(sources)} کانال)\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"📡 کانالهای مبدا در دسته: «{cat_name}»\n\n"
"هیچ کانال مبدایی در این دستهبندی قرار ندارد."
)
buttons = [[Button.inline("🔙 بازگشت به دستهبندیها", data="list_src")]]
return text, buttons
text = (
f"📡 کانالهای مبدا در دسته: «{cat_name}» ({len(sources)} کانال)\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 "هیچ کانال مبدایی ثبت نشده است. روی ➕ افزودن کانال مبدا بزنید.", []
cats = {c.id: c.name for c in await self.repo.get_categories()}
text = (
f"📡 همه کانالهای مبدا فعال ({len(sources)} کانال)\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 "هیچ کانال مقصدی ثبت نشده است. روی ➕ افزودن کانال مقصد بزنید.", []
categories = await self.repo.get_categories()
if not categories:
# If no categories exist yet, show flat list
text = (
f"🎯 کانالهای مقصد ({len(targets)} کانال)\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"🎯 دستهبندیهای کانالهای مقصد ({len(targets)} کانال)\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"🎯 کانالهای مقصد در دسته: «{cat_name}»\n\n"
"هیچ کانال مقصدی در این دستهبندی قرار ندارد."
)
buttons = [[Button.inline("🔙 بازگشت به دستهبندیها", data="list_trg")]]
return text, buttons
text = (
f"🎯 کانالهای مقصد در دسته: «{cat_name}» ({len(targets)} کانال)\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 "هیچ کانال مقصدی ثبت نشده است. روی ➕ افزودن کانال مقصد بزنید.", []
cats = {c.id: c.name for c in await self.repo.get_categories()}
text = (
f"🎯 همه کانالهای مقصد ({len(targets)} کانال)\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 = (
"🤖 ارسال خودکار به: " + "، ".join(f"{t.title}" for t in auto_targets)
if auto_targets else "🛑 ارسال خودکار: غیرفعال"
)
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"📢 {source.title or 'کانال مبدا'}\n\n"
f"• 🆔 شناسه کانال: {source.channel_id}\n"
f"• 🔗 یوزرنیم: @{source.username or 'ندارد'}\n"
f"• 📁 دستهبندی: {cat_name}\n"
f"• 📜 پیامهای زمینه (Context): {ctx_label}\n"
f"• 📥 پستهای دریافتشده: {collected}\n"
f"• {auto_line}\n\n"
"👇 برای تنظیمات زمینه یا دریافت پستهای گذشته یکی از گزینهها را انتخاب کنید:"
)
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"{current_cnt} پیام اخیر" if current_cnt > 0 else "غیرفعال (۰ پیام)"
text = (
f"📜 تنظیم تعداد پیامهای زمینه (Context History)\n"
f"📢 کانال مبدا: {source.title or source.channel_id}\n\n"
f"با فعالسازی این قابلیت، هنگام بازنویسی هر پست ورودی توسط هوش مصنوعی، تعداد مشخصی از پیامهای قبلی این کانال نیز جهت درک پیوستگی موضوعی و خط داستانی در اختیار مدل قرار میگیرد.\n\n"
f"• وضعیت کنونی: {status_text}\n\n"
f"تعداد پیام مورد نظر را انتخاب کنید:"
)
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 = (
"🌐 لیست وبسایتهای مبدا (Source Websites)\n\n"
"هنوز هیچ وبسایت مبدایی ثبت نشده است.\n"
"شما میتوانید هر وبسایت، خبرگزاری یا وبلاگی را به عنوان مبدا ثبت کنید تا هوش مصنوعی بهطور خودکار ساختار آن را تحلیل و محتوای جدید را استخراج کند."
)
buttons = [
[Button.inline("➕ افزودن وبسایت مبدا جدید", data="add_web")],
[Button.inline("🔙 بازگشت به منوی اصلی", data="back_to_main")]
]
return text, buttons
text = f"🌐 لیست وبسایتهای مبدا ({len(sites)} وبسایت):\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"⚠️ خطای اخیر: {site.last_error}\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"🌐 وبسایت مبدا: {site.name}\n\n"
f"• 🔗 آدرس وبسایت: {site.url}\n"
f"• 🔌 اندپوینت دریافت: {endpoint}\n"
f"• 🏷 نوع ساختار: {parser_type}\n"
f"• 📁 دستهبندی: {cat_name}\n"
f"• 📝 نیازمندیها و فیلترهای استخراج:\n"
f"{needs or 'ثبت نشده (استخراج تمام اخبار و پستها)'}\n\n"
f"• ⏱ فاصله بررسی: هر {site.check_interval_min} دقیقه\n"
f"• 🔄 دوره تحلیل مجدد AI: هر {site.auto_reanalyze_hours} ساعت\n"
f"• 📥 آخرین دریافت: {last_fe}\n"
f"• 🧠 آخرین تحلیل هوش مصنوعی: {last_re}\n\n"
f"{err_block}"
f"👇 عملیات مورد نظر را انتخاب کنید:"
)
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"📁 انتخاب دستهبندی برای وبسایت «{site.name}»:"
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_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"🤖 ارسال خودکار برای کانال «{target.title}»\n\n"
"هر کانال مبدایی که روشن باشد، پستهایش بهصورت خودکار بازنویسی شده و "
"به صف ارسال این کانال مقصد اضافه میشود.\n\n"
"⚠️ پستها همچنان به کانال ادمین هم ارسال میشوند تا در جریان باشید.\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 = [
"📂 مدیریت دستهبندی کانالها (Categories)\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"• {c.name}: {s_c} مبدا | {t_c} مقصد")
else:
lines.append("هنوز هیچ دستهبندی ساخته نشده است.")
if uncat_srcs or uncat_trgs:
lines.append(f"\n• 📁 کانالهای بدون دسته: {len(uncat_srcs)} مبدا | {len(uncat_trgs)} مقصد")
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"📁 دستهبندی: {cat_name}\n\n"
f"• 📝 توضیحات: {desc_line}\n"
f"• 📡 کانالهای مبدا ({len(sources)} کانال):\n"
f"• 🎯 کانالهای مقصد ({len(targets)} کانال):\n\n"
"👇 برای مدیریت هر کانال یا ویرایش این دسته، روی دکمه مربوطه بزنید:"
)
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"📁 انتخاب دستهبندی برای: «{title}»\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"⛔ دسترسی غیرمجاز. شناسه عددی شما: {event.sender_id}", 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 = (
"👋 به پنل مدیریت سیستم هوشمند کپیکار خوش آمدید!\n\n"
f"• 🤖 وضعیت ربات جمعآوریکننده: {userbot_status}\n"
f"• 📋 شناسه کانال ادمینها: {self.review_channel_id}\n\n"
"برای مدیریت کانالها از دکمههای زیر استفاده کنید:"
)
menu = await self.get_menu()
await event.reply(welcome_text, parse_mode="html", buttons=menu)
# --- 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)
# --- AI Settings & Logs ---
@self.client.on(events.NewMessage(pattern=r"(?i)^(/ai|🧠 تنظیمات AI|🧠 تنظیمات و لاگهای AI)$"))
async def cmd_ai(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)
# --- 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"📩 کد تایید ارسال شد!\n\n"
f"لطفا کد دریافت شده به شماره {self.collector.phone} را در پیام بعدی ارسال کنید:\n"
f"(یا به فرمت /code 12345)",
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 = (
"📡 افزودن کانال مبدا جدید:\n\n"
"سادهترین روش: یک پیام از کانال مورد نظر به این ربات فوروارد (Forward) کنید!\n\n"
"(یا میتوانید آیدی، یوزرنیم یا لینک کانال مثل @channel_username را بفرستید)"
)
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 = "➕ افزودن منبع یا کانال جدید:\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 = (
"🎯 افزودن کانال مقصد جدید:\n\n"
"سادهترین روش: یک پیام از کانال مورد نظر به این ربات فوروارد (Forward) کنید!\n\n"
"(یا میتوانید آیدی، یوزرنیم یا لینک کانال مثل @channel_username را بفرستید)"
)
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"⏳ در حال ارسال {len(posts)} پست بررسینشده به کانال ادمین...",
parse_mode="html",
)
for post in posts:
await self.send_raw_review_post(post.id)
await msg.edit(
f"✅ تعداد {len(posts)} پست بررسینشده به کانال ادمین ارسال شد.",
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 = (
"🛑 سیستم به صورت اضطراری متوقف شد (System Paused).\n\n"
"• ⏸ دریافت خودکار پستهای جدید از کانالهای مبدا متوقف شد.\n"
"• ⏸ ارسال زمانبندیشده پستها به کانالهای مقصد متوقف شد.\n"
"• 🟢 پنل مدیریت، دستورات و پیامهای ادمین همچنان کاملاً فعال هستند.\n\n"
"برای از سرگیری فعالیت سیستم، روی دکمه «▶️ راهاندازی سیستم» بزنید."
)
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 = (
"▶️ سیستم با موفقیت مجدداً فعال شد (System Resumed).\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 = (
"📖 راهنمای سیستم کپیکار:\n\n"
"• توقف و راهاندازی اضطراری: با دکمه 🛑 توقف اضطراری سیستم تمام فرآیندهای دریافت و ارسال متوقف میشوند در حالی که ربات همیشه به ادمین پاسخ میدهد.\n"
"• زنجیره پشتیبان هوش مصنوعی: در صورت بروز خطا در ارائهدهنده فعال، درخواست طبق تنظیمات دستی شما به ارائهدهنده پشتیبان تعیینشده منتقل میشود.\n"
"• گزارش تغییرات: با دستور /changes آخرین تغییرات و امکانات سیستم را مشاهده کنید.\n"
"• افزودن آسان کانالها: فقط کافیست یک پیام از کانال را به ربات فوروارد کنید تا شناسه و نام آن خودکار ثبت شود!\n"
"• تنظیمات با دکمه: در بخش کانالهای مقصد، روی هر کانال دکمههای تغییر لحن، تگها، فاصله زمانی و ساعت خواب وجود دارد.\n"
"• بازنویسی هوشمند: پستهای مبدا به کانال ادمین میآیند و با لمس دکمه هر مقصد، متن با هوش مصنوعی و لحن همان کانال بازنویسی میشود.\n"
"• پستهای بررسینشده: با دکمه 📨 ارسال پستهای بررسینشده هر پستی که هنوز به کانال ادمین نرفته، دوباره ارسال میشود.\n"
"• ارسال خودکار: در تنظیمات هر کانال مقصد، دکمه 🤖 ارسال خودکار از مبدا را بزنید تا پستهای یک مبدا خودکار به آن مقصد بروند.\n"
"• خطاها: با دکمه ⚠️ خطاهای سیستم خطاهای رفعنشده را ببینید و بعد از رفع، علامتگذاری کنید."
)
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 in Persian from RELEASE_NOTES.md."""
notes_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "RELEASE_NOTES.md")
if os.path.exists(notes_file):
try:
with open(notes_file, "r", encoding="utf-8") as f:
content = f.read().strip()
if content:
return content
except Exception as e:
logger.debug(f"Error reading RELEASE_NOTES.md: {e}")
return "📢 گزارش تغییرات سیستم (Release Notes):\nسیستم با آخرین بهروزرسانیها در حال اجرا است."
async def broadcast_change_notes(self):
"""Send latest change notes to admins and review channel if they are new."""
import hashlib
notes = self.get_latest_change_notes()
current_hash = hashlib.sha256(notes.encode("utf-8")).hexdigest()
last_hash = await self.repo.get_setting("last_broadcast_release_hash")
if last_hash == current_hash:
logger.info("Release notes have not changed since last broadcast. Skipping automatic broadcast.")
return
logger.info(f"Broadcasting new release notes (hash: {current_hash[:8]})...")
await self.notify_admins(notes)
await self.repo.set_setting(
"last_broadcast_release_hash",
current_hash,
description="Hash of last broadcast release notes"
)
# --- 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 = (
"❌ خطایی در انجام این عملیات رخ داد.\n\n"
f"{type(error).__name__}: {error}\n\n"
"جزئیات کامل در جدول خطاهای سیستم ثبت شد."
)
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", "منو", "منوی اصلی", "🏠 منوی اصلی",
"/stats", "/report", "📊 آمار ناوگان", "📊 آمار و وضعیت ناوگان", "📊 گزارش و آمار سیستم",
"/sources", "📡 کانالهای مبدا", "/targets", "🎯 کانالهای مقصد",
"/websites", "/sites", "🌐 وبسایتهای مبدا",
"/request_code", "🔑 کد لاگین", "🔑 درخواست کد لاگین",
"/pending", "📨 پستهای بررسینشده", "📨 ارسال پستهای بررسینشده",
"/errors", "/err", "⚠️ خطاهای سیستم",
"/ai", "🧠 تنظیمات AI", "🧠 تنظیمات و لاگهای AI",
"/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"✅ کانال مبدا {title} با شناسه {ch_id} با موفقیت افزوده شد!\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"✅ کانال مقصد {title} افزوده شد!\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("✅ لحن و شخصیت کانال با موفقیت به روز شد!", 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("✅ فوتر و تگهای اختصاصی کانال با موفقیت به روز شد!", 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("✅ دستورالعمل و فرامین اختصاصی کانال با موفقیت ذخیره شد!", parse_mode="html")
else:
await event.reply("✅ پرامپت اختصاصی حذف شد و به حالت پیشفرض بازگشت.", 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"✅ دستهبندی {cat_name} با موفقیت ایجاد شد!", 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"✅ نام دستهبندی به {cat_name} تغییر یافت!", 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"✅ فاصله ارسال به هر {interval_min} دقیقه تغییر یافت!", 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("⚠️ لطفا ساعت شروع و پایان را به این شکل بفرستید: 23 8 (برای ۲۳:۰۰ تا ۰۸:۰۰)", 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"🌙 ساعت خواب از {start_h}:00 تا {end_h}:00 فعال شد!", 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"⚠️ عدد باید بین {MIN_FETCH_LIMIT} و {MAX_FETCH_LIMIT} باشد. دوباره بفرستید:",
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"⏳ در حال دریافت {count} پست گذشته از {ch_id}...",
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("❌ فرمت نامعتبر است. لطفاً نام و آدرس را با فاصله ارسال کنید (مثال: دیجیاتو https://digiato.com).", 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"✅ وبسایت «{name}» افزوده شد!\n\n"
f"اکنون میتوانید با دکمه 📝 نیازمندیهای استخراج موضوعات مدنظرتان را تعیین کنید یا با دکمه 🤖 شروع / تکرار تحلیل با AI استخراج خودکار را آغاز کنید.\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("✅ نیازمندیها و فیلترهای استخراج وبسایت با موفقیت ذخیره شد!", parse_mode="html")
else:
await event.reply("✅ فیلتر اختصاصی حذف شد (استخراج عمومی فعال شد).", 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"✅ فاصله بررسی وبسایت روی هر {val} دقیقه تنظیم شد.", 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"✅ دوره تحلیل مجدد خودکار وبسایت روی هر {val} ساعت تنظیم شد.", 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"✅ مدل فعال به {new_model} تغییر یافت!", 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("✅ Base URL با موفقیت ذخیره شد!", 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("✅ کلید API با موفقیت ذخیره شد!", 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"📌 نام سرویسدهنده: {name}\n\n"
"اکنون نوع سرویس (Provider Type) را انتخاب کنید:",
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"🤖 مدل: {model_name}\n\n"
f"اکنون آدرس Base URL را ارسال کنید:\n"
f"(پیشفرض: {default_url} - برای استفاده از پیشفرض کلمه default یا خالی بفرستید)",
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(
"🔑 اکنون کلید API (API Key) را ارسال کنید:\n"
"(اگر سرور داخلی بدون کلید است، کلمه none یا خالی بفرستید)",
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"✅ سرویسدهنده «{name}» با موفقیت اضافه شد!", 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("✅ مدل سرویسدهنده بروز شد!", 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("✅ Base URL سرویسدهنده بروز شد!", 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("✅ کلید API سرویسدهنده بروز شد!", 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(
"🏷 نام مدل مورد نظر را ارسال کنید:\n"
"(مثال: antigravity یا google/gemini-3.5-flash)",
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(
"🌐 آدرس Base URL را ارسال کنید:\n"
"(برای پیشفرض یا لوکال، کلمه default یا خالی بفرستید)",
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(
"🔑 کلید API جدید را ارسال کنید:",
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 = (
"🧪 نتیجه تست موفقیتآمیز اتصال هوش مصنوعی:\n\n"
f"• 🔌 Provider: {provider_used}\n"
f"• 🏷 Model: {model_used}\n"
f"• ⏱ مدت زمان پاسخ: {latency:.2f} ثانیه\n"
f"• 📊 وضعیت: ✅ موفق (Success)\n\n"
f"📥 Input Prompt:\n{test_prompt}\n\n"
f"📤 Output JSON:\n{res_text}"
)
else:
card = "❌ سرویس هوش مصنوعی بارگذاری نشده است."
except Exception as e:
latency = time.time() - t0
card = (
"🧪 نتیجه تست اتصال هوش مصنوعی:\n\n"
f"• ⏱ مدت زمان: {latency:.2f}s\n"
f"• 📊 وضعیت: ❌ خطا (Failed)\n\n"
f"❌ علت خطا:\n{e}"
)
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"🧪 نتیجه تست ارائهدهنده «{prof.name}»:\n\n"
f"• 🔌 Provider: {prof.provider_type}\n"
f"• 🏷 Model: {prof.model}\n"
f"• 🖼 پردازش تصویر: {vision_info}\n"
f"• ⏱ مدت زمان: {latency:.2f}s\n"
f"• 📊 وضعیت: ✅ موفق (Success)\n\n"
f"📥 Input Prompt:\n{test_prompt}\n\n"
f"📤 Output JSON:\n{res_text}"
)
except Exception as e:
latency = time.time() - t0
card = (
f"🧪 نتیجه تست ارائهدهنده «{prof.name}»:\n\n"
f"• 🔌 Provider: {prof.provider_type}\n"
f"• 🏷 Model: {prof.model}\n"
f"• ⏱ مدت زمان: {latency:.2f}s\n"
f"• 📊 وضعیت: ❌ خطا (Failed)\n\n"
f"❌ علت خطا:\n{e}"
)
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("🏷 نام مدل جدید (Model Name) را وارد کنید:", 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("🌐 آدرس Base URL جدید را وارد کنید:\n(برای پیشفرض کلمه default یا خالی بفرستید)", 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("🔑 کلید API جدید را وارد کنید:\n(اگر سرور بدون کلید است، کلمه none یا خالی بفرستید)", 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(
"➕ ثبت سرویسدهنده جدید (New AI Provider):\n\n"
"لطفا یک نام دلخواه برای این سرویسدهنده بفرستید:\n"
"(مثال: سرور لوکال AGY یا OpenRouter سریع یا DeepSeek)",
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"📌 نوع انتخابی: {sel_type}\n\n"
f"لطفا نام مدل (Model Name) را ارسال کنید:\n"
f"(پیشنهادی: {default_model})",
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"📊 نمودار متریکهای عملکرد سیستم ({label_map.get(time_range, time_range)}):\n\n"
f"📁 {filename}\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"🎭 تغییر لحن و شخصیت کانال «{target.title}»:\n\n"
"لطفا در پیام بعدی، لحن و استایل نگارش مورد نظرتان را بفرستید:\n"
"(مثال: لحن دوستانه و پرانرژی همراه با ایموجی و تیترهای جذاب)",
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"🏷 تغییر فوتر و تگهای اختصاصی «{target.title}»:\n\n"
"لطفا متن فوتر، آیدی یا هشتگهایی که میخواهید انتهای هر پست قرار بگیرد را بفرستید:\n"
"(مثال: 🆔 @my_chan\n#فناوری #اخبار)",
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پرامپت فعلی:\n{target.custom_prompt}" if target.custom_prompt else ""
await event.reply(
f"📝 تنظیم دستورالعمل و فرامین اختصاصی برای «{target.title}»:\n\n"
"در پیام بعدی، فرامین و قوانین اختصاصی مد نظرتان برای شکلدهی به پیامهای این کانال را بفرستید.\n"
"(مثال: همیشه تیتر را بولد کن، خلاصهای در ۳ خط بنویس، هشتگهای تخصصی بزن و...)\n\n"
"💡 برای حذف پرامپت و بازگشت به حالت پیشفرض، عبارت /clear را ارسال کنید."
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"⏱ تغییر فاصله ارسال کانال «{target.title}»:\n\n"
"فاصله زمانی بین ارسال هر پست را به دقیقه بفرستید:\n"
"(مثال: 30 برای ارسال هر نیم ساعت یک پست)",
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"🌙 تنظیم ساعت خواب کانال «{target.title}»:\n\n"
"ساعت شروع و پایان خواب را با یک فاصله بفرستید:\n"
"(مثال: 23 8 برای عدم ارسال پست از ساعت ۲۳:۰۰ تا ۰۸:۰۰ صبح)",
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 = (
"🌐 افزودن وبسایت مبدا جدید:\n\n"
"لطفاً نام وبسایت و آدرس URL آن را با یک فاصله یا در دو خط ارسال کنید:\n"
"(مثال: دیجیاتو https://digiato.com)\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"⏱ تنظیم فاصله بررسی وبسایت «{site.name}»:\n\n"
f"فاصله زمانی بررسی و استخراج اخبار (به دقیقه) را ارسال کنید:\n"
f"(مثال: 30 برای هر ۳۰ دقیقه)",
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"🔄 تنظیم دوره تحلیل مجدد هوشمند وبسایت «{site.name}»:\n\n"
f"فاصله تحلیل خودکار ساختار و کشف اندپوینتها توسط AI (به ساعت) را ارسال کنید:\n"
f"(مثال: 24 برای هر ۲۴ ساعت، یا 0 برای غیرفعالسازی)",
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"📝 تعیین نیازمندیها و فیلترهای استخراج از «{site.name}»:\n\n"
f"چه موضوعات، بخشها یا کلیدواژههایی مد نظر شماست؟ این متن به عنوان دستورالعمل در تحلیل AI قرار میگیرد:\n"
f"(مثال: فقط مقالات مربوط به هوش مصنوعی، فناوری و سختافزار یا /clear برای حذف فیلتر)",
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 = (
"📡 افزودن کانال مبدا جدید:\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 = (
"🎯 افزودن کانال مقصد جدید:\n\n"
"یک پیام از کانال مقصد را فوروارد کنید یا آیدی/یوزرنیم آن را بفرستید."
)
await event.reply(guide, parse_mode="html", buttons=get_cancel_button())
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"🗑 کانال مقصد حذف شد.\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"🗑 کانال مبدا حذف شد.\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(
"➕ ایجاد دستهبندی جدید:\n\n"
"لطفا یک نام برای دستهبندی جدید ارسال کنید:\n"
"(مثال: اخبار فناوری، رمزارز و کریپتو، سینما و سرگرمی و...)",
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("✏️ نام جدید دستهبندی را ارسال کنید:", 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"⏳ در حال دریافت {limit} پست گذشته از کانال {ch_id}...", 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"🔢 تعداد پستهای مورد نظر برای کانال {ch_id}:\n\n"
f"یک عدد بین {MIN_FETCH_LIMIT} تا {MAX_FETCH_LIMIT} بفرستید.\n"
"(مثال: 100 برای دریافت ۱۰۰ پست آخر)",
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"🤖 در حال بازنویسی هوشمند برای کانال: {target.title}...\n"
f"(اعمال لحن اختصاصی و حذف تگهای مبدا)"
)
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"⚠️ هشدار هوش مصنوعی: این پست توسط هوش مصنوعی رد شده است!\n"
f"📝 علت رد: {reason}\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"🎯 پیشنمایش بازنویسی شده برای: {target.title}\n"
f"🤖 مدل استفادهشده: {model_name}\n"
f"🎭 شخصیت و لحن: {target.personality or 'پیشفرض'}\n"
f"➖➖➖➖➖➖➖➖➖➖\n"
f"{ai_warning}"
f"{rewritten_text}\n\n"
f"➖➖➖➖➖➖➖➖➖➖\n"
f"آیا این متن برای صف انتشار تایید است؟"
)
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"✅ به صف انتشار کانال {target.title} اضافه شد!\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❌ این پست توسط ادمین رد و بایگانی شد.", 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("🗑 این پیام حذف و بایگانی شد.", 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.")