diff --git "a/bot_core.py" "b/bot_core.py" deleted file mode 100644--- "a/bot_core.py" +++ /dev/null @@ -1,7308 +0,0 @@ -import asyncio -import re -import logging -import tempfile -import os -import time -import shutil -import html -import hashlib -import difflib - -from pyrogram import Client, filters -from pyrogram.handlers import MessageHandler, CallbackQueryHandler -from pyrogram.types import InlineKeyboardMarkup, InlineKeyboardButton, InputMediaPhoto, InputMediaVideo -try: - from pyrogram.types import MessageEntity - from pyrogram.enums import MessageEntityType -except Exception: - MessageEntity = None - MessageEntityType = None -from pyrogram.errors import MessageNotModified, FloodWait -from pyrogram.enums import ParseMode - -import config -import database as db -from modules.blogger.ui import register_blogger_handlers, BLOGGER_CALLBACKS, handle_blogger_text_input -from modules.blogger.publisher import BloggerPublisher - -logging.basicConfig(level=logging.INFO) -from modules.logging_security import install_logging_safety -install_logging_safety() -logger = logging.getLogger(__name__) -logging.getLogger("pyrogram").setLevel(logging.WARNING) -logging.getLogger("pyrogram.session").setLevel(logging.WARNING) -logging.getLogger("pyrogram.connection").setLevel(logging.WARNING) -logging.getLogger("pyrogram.dispatcher").setLevel(logging.WARNING) - -bot_client = None -user_client = None -BOT_LOG_FILE = "/tmp/telegram_auto_poster_bot.log" -user_states = {} -# Per-user back navigation stack so every shared UI page can return one step -# to the exact page the user came from, like a mobile app back gesture. -_BACK_STACKS = {} -_BACK_CURRENT = {} -_BACK_STACK_LIMIT = 16 - - -def _back_stack_key(callback): - from_user = getattr(callback, "from_user", None) or {} - user_id = from_user.get("id") if isinstance(from_user, dict) else getattr(from_user, "id", None) - if user_id is None: - msg = getattr(callback, "message", None) - chat = getattr(msg, "chat", None) - user_id = getattr(chat, "id", None) if chat is not None else None - return user_id - - -def _push_back(callback): - key = _back_stack_key(callback) - if key is None: - return - stack = list(_BACK_STACKS.get(key) or []) - stack.append(str(callback.data)) - if len(stack) > _BACK_STACK_LIMIT: - stack = stack[-_BACK_STACK_LIMIT:] - _BACK_STACKS[key] = stack - _BACK_CURRENT[key] = str(callback.data) -def _back_step(callback): - """الصفحة السابقة للصفحة الحالية (سلوك زر الرجوع في تطبيقات الموبايل). - يزيل مدخلات الصفحة الحالية المتكررة في أعلى المكدس ثم يعيد الصفحة التي تحتها.""" - key = _back_stack_key(callback) - stack = list(_BACK_STACKS.get(key) or []) - current = _BACK_CURRENT.get(key) - while stack and stack[-1] == current: - stack.pop() - previous = None - if stack: - previous = stack.pop() - _BACK_STACKS[key] = stack - return previous -def _peek_prev_back(callback): - """الصفحة قبل الصفحة الحالية في المكدس (زر الرجوع في صفحات الشرح والتنفيذ).""" - key = _back_stack_key(callback) - stack = _BACK_STACKS.get(key) or [] - if len(stack) < 2: - return None - return stack[-2] - - -_middle_peer_warmed = False -_middle_auto_ignore_until = 0.0 -_middle_album_buffers = {} -_middle_album_tasks = {} -_last_poll_ok = time.time() -_last_publish_ok = time.time() -_last_watchdog_alert = 0.0 -_last_heartbeat_log = 0.0 -_notification_alerted_ids = set() -_last_alert_ts = {} - -# Telegram limits callback_data to 64 UTF-8 bytes. UI pages can carry nested -# back callbacks and resource IDs that exceed that limit, so retain the full -# UI payload server-side and expose only a short opaque token to Telegram. -_UI_CALLBACK_CONTEXTS = {} -_UI_CALLBACK_CONTEXT_LIMIT = 4096 - - -def _compact_ui_callback_data(callback_data): - if not callback_data: - return callback_data - if len(str(callback_data).encode("utf-8")) <= 64: - return callback_data - raw = str(callback_data) - token = hashlib.sha1(raw.encode("utf-8")).hexdigest()[:12] - key = f"ui_ctx|{token}" - _UI_CALLBACK_CONTEXTS[key] = raw - if len(_UI_CALLBACK_CONTEXTS) > _UI_CALLBACK_CONTEXT_LIMIT: - oldest = next(iter(_UI_CALLBACK_CONTEXTS), None) - if oldest is not None and oldest != key: - _UI_CALLBACK_CONTEXTS.pop(oldest, None) - return key - - -def _expand_ui_callback_data(callback_data): - if not str(callback_data).startswith("ui_ctx|"): - return callback_data - return _UI_CALLBACK_CONTEXTS.get(str(callback_data)) - - -def _compact_ui_markup(reply_markup): - if reply_markup is None: - return reply_markup - for row in getattr(reply_markup, "inline_keyboard", ()) or (): - for button in row or (): - callback_data = getattr(button, "callback_data", None) - compacted = _compact_ui_callback_data(callback_data) - if compacted != callback_data: - button.callback_data = compacted - return reply_markup - - -def is_admin(user_id): - return user_id in config.ADMINS - -def mark_runtime_activity(kind="poll"): - """يسجل آخر نشاط ناجح حتى نعرف إذا البوت عالق بدون نشر/فحص.""" - global _last_poll_ok, _last_publish_ok - now = time.time() - if kind == "publish": - _last_publish_ok = now - _last_poll_ok = now - - -def is_transient_network_error(error): - """أخطاء اتصال مؤقتة من HuggingFace/Telegram لا تعتبر عطل منطقي بالكود.""" - text = str(error).lower() - transient_terms = [ - "broken pipe", - "socket.send() raised exception", - "connection reset", - "connection aborted", - "server disconnected", - "transport closed", - "timed out", - "timeout", - "network is unreachable", - "cannot write to closing transport", - ] - return any(term in text for term in transient_terms) - - -async def reconnect_user_client_if_needed(reason=""): - """محاولة إنعاش جلسة الحساب عند Broken pipe بدون لمس إعدادات النشر.""" - global _middle_peer_warmed - try: - logger.warning(f"🔌 محاولة إعادة تهيئة اتصال user_client بسبب: {reason}") - try: - await user_client.stop() - except Exception: - pass - await asyncio.sleep(3) - await user_client.start() - _middle_peer_warmed = False - await warm_middle_peer() - logger.info("✅ تمت إعادة تهيئة اتصال user_client") - return True - except Exception as e: - logger.warning(f"⚠️ فشلت إعادة تهيئة user_client: {e}") - return False - - -async def _retry_on_flood(send_func, label="", max_retries=3): - """ينفذ عملية إرسال مع انتظار تلقائي عند حظر تيليجرام للإرسال السريع.""" - for attempt in range(max_retries): - try: - return await send_func() - except FloodWait as e: - wait = int(e.value) + 2 - logger.warning(f"⏳ FloodWait{' - ' + label if label else ''}: انتظار {wait}s (محاولة {attempt+1}/{max_retries})") - await asyncio.sleep(wait) - except Exception: - raise - return await send_func() - - -# ============================================================ -# VerificationService - طبقة التحقق الموحدة -# ============================================================ - -class VerificationService: - """طبقة تحقق موحدة. - - - جميع عمليات التحقق تمر من هنا فقط. - - يستخدم Smart Cache للواجهات والقوائم. - - يقوم بتحقق حقيقي من Telegram قبل العمليات الحساسة. - - يستخدم BotManager للحصول على التوكنات (لا وصول مباشر للتوكن). - """ - - def __init__(self): - self._verify_cache_ttl = 120 # 2 دقيقة للعمليات - self._ui_cache_ttl = 600 # 10 دقائق للواجهات - - # ---- تحقق داخلي من Telegram (بدون كاش) ---- - - async def _api_check_bot_in_channel(self, token, channel_id): - """اتصال مباشر بـ Telegram للتحقق من وجود البوت في القناة.""" - result = {"verified": False, "can_post": False, "status": "", "permissions": {}} - if not token or not channel_id: - return result - try: - temp = Client(":memory:", api_id=config.API_ID, api_hash=config.API_HASH, bot_token=token, in_memory=True) - await temp.start() - me = await temp.get_me() - try: - member = await temp.get_chat_member(int(channel_id), me.id) - status = member_status_value(getattr(member, "status", None)) - result["status"] = status - if is_bot_admin_status(status): - result["verified"] = True - if hasattr(member, "can_post_messages"): - result["can_post"] = bool(member.can_post_messages) - elif status in ("owner", "creator"): - result["can_post"] = True - else: - result["can_post"] = True - if hasattr(member, "privileges"): - priv = member.privileges - if priv: - result["permissions"] = { - "can_post_messages": bool(getattr(priv, "can_post_messages", True)), - "can_edit_messages": bool(getattr(priv, "can_edit_messages", True)), - "can_delete_messages": bool(getattr(priv, "can_delete_messages", True)), - } - except Exception as e: - result["status"] = str(e)[:50] - await temp.stop() - except Exception as e: - result["status"] = f"فشل الاتصال: {str(e)[:50]}" - return result - - async def _api_check_token(self, token): - """التحقق من صحة التوكن عبر Telegram.""" - result = {"valid": False, "id": "", "username": "", "first_name": "", "error": ""} - try: - temp = Client(":memory:", api_id=config.API_ID, api_hash=config.API_HASH, bot_token=token, in_memory=True) - await temp.start() - me = await temp.get_me() - result["valid"] = True - result["id"] = str(me.id) - result["username"] = me.username or "" - result["first_name"] = me.first_name or "" - await temp.stop() - except Exception as e: - result["error"] = str(e)[:100] - return result - - async def _api_check_session(self, session_string, api_id, api_hash): - """التحقق من صلاحية جلسة مستخدم.""" - result = {"valid": False, "id": "", "username": "", "error": ""} - try: - temp = Client(":memory:", api_id=api_id, api_hash=api_hash, session_string=session_string, in_memory=True) - await temp.start() - me = await temp.get_me() - result["valid"] = True - result["id"] = str(me.id) - result["username"] = me.username or "" - await temp.stop() - except Exception as e: - result["error"] = str(e)[:100] - return result - - async def _api_check_channel(self, channel_id): - """التحقق من إمكانية الوصول للقناة عبر البوت الرئيسي.""" - result = {"ok": False, "title": "", "username": "", "type": "", "error": ""} - try: - chat = await bot_client.get_chat(int(channel_id)) - result["ok"] = True - result["title"] = getattr(chat, "title", "") or "" - result["username"] = getattr(chat, "username", "") or "" - result["type"] = str(getattr(chat, "type", "")) or "" - except Exception as e: - result["error"] = str(e)[:100] - return result - - # ---- تحقق مع كاش للعمليات الحساسة ---- - - async def check_bot_in_channel(self, bot_id, channel_id, force=False): - """التحقق من وجود بوت في قناة. - - - force=True: يتجاهل الكاش ويتصل بـ Telegram مباشرة. - - force=False: يستخدم الكاش إذا كان حديثاً. - """ - token = db.bot_manager.get_token(bot_id) if hasattr(db, "bot_manager") else "" - if not token: - return {"verified": False, "can_post": False, "status": "no_token", - "permissions": {}, "from_cache": False, "last_check": 0} - - cache_key = f"verify_bot|{bot_id}|{channel_id}" - if not force: - cached = db.cache_get(cache_key) - if cached is not None: - cached["from_cache"] = True - return cached - - result = await self._api_check_bot_in_channel(token, channel_id) - result["last_check"] = time.time() - result["from_cache"] = False - result["bot_id"] = bot_id - - db.cache_set(cache_key, result, ttl=self._verify_cache_ttl) - if hasattr(db, "set_bot_channel_verification"): - bname = "" - b = db.get_publishing_bot(bot_id) - if b: - bname = b.get("name", "") - db.set_bot_channel_verification(bot_id, channel_id, - result["verified"], result["can_post"], - result["status"], result["permissions"], - bname, "") - return result - - async def check_channel_before_operation(self, channel_id, force=False): - """التحقق من القناة قبل أي عملية حساسة (نشر/حذف).""" - cache_key = f"channel_op|{channel_id}" - if not force: - cached = db.cache_get(cache_key) - if cached is not None: - return cached.get("ok", False) - - ch = db.get_channel(str(channel_id)) - if not ch: - return False - - ver = await self._api_check_channel(channel_id) - if ver.get("ok"): - db.cache_set(cache_key, {"ok": True}, ttl=self._verify_cache_ttl) - return True - - db.cache_set(cache_key, {"ok": False}, ttl=60) - return False - - async def validate_token(self, token): - """التحقق من صحة توكن بوت.""" - cache_key = f"token_valid|{token[:10]}" - cached = db.cache_get(cache_key) - if cached is not None: - return cached - result = await self._api_check_token(token) - db.cache_set(cache_key, result, ttl=db.CACHE_TTL.get("bot_token_valid", 3600)) - return result - - async def validate_session(self, session_string, api_id, api_hash): - """التحقق من صلاحية جلسة مستخدم.""" - cache_key = f"session_valid|{session_string[:20]}" - cached = db.cache_get(cache_key) - if cached is not None: - return cached - result = await self._api_check_session(session_string, api_id, api_hash) - db.cache_set(cache_key, result, ttl=db.CACHE_TTL.get("session_valid", 600)) - return result - - # ---- دوال مساعدة للواجهات ---- - - def get_cached_verification(self, bot_id, channel_id): - """إرجاع التحقق المخبأ للعرض في الواجهات فقط.""" - if hasattr(db, "get_bot_channel_verification"): - return db.get_bot_channel_verification(bot_id, channel_id) - return {"verified": False, "can_post": False, "status": "", "last_check": 0} - - def _get_from_smartcache_or_db(self, bot_id, channel_id): - """محاولة الحصول على التحقق من Smart Cache أولاً (TTL=120s)، - فإن لم يكن موجوداً→ من Persistent DB (data.json). - يطبع Trace كامل في Console.""" - # 1. حاول من Smart Cache - cache_key = f"verify_bot|{bot_id}|{channel_id}" - cached = db.cache_get(cache_key) if hasattr(db, "cache_get") else None - if cached is not None: - age = int(time.time() - cached.get("last_check", 0)) - logger.info( - f"🔍 TRACE: bot={bot_id} channel={channel_id} " - f"المصدر=SmartCache عمر={age}ث " - f"verified={cached.get('verified')} can_post={cached.get('can_post')}" - ) - return cached - # 2. من Persistent DB - persistent = db.get_bot_channel_verification(bot_id, channel_id) if hasattr(db, "get_bot_channel_verification") else {} - age = int(time.time() - persistent.get("last_check", 0)) if persistent.get("last_check") else -1 - logger.info( - f"🔍 TRACE: bot={bot_id} channel={channel_id} " - f"المصدر=PersistentDB عمر={age}ث " - f"verified={persistent.get('verified')} can_post={persistent.get('can_post')}" - ) - return persistent - - def get_cached_verifications_for_channel(self, channel_id): - """إرجاع جميع التحققات لقناة (للواجهات). - - يحاول Smart Cache أولاً - - يرجع Persistent DB إذا الكاش فارغ - """ - result = {} - # نحتاج IDs البوتات المرتبطة بالقناة - if hasattr(db, "mapper"): - bot_ids = db.mapper.get_bots_for_channel(channel_id) - else: - cfg = db.get_channel_config(channel_id) if hasattr(db, "get_channel_config") else {} - bot_ids = cfg.get("assigned_bots", []) - for bid in bot_ids: - result[str(bid)] = self._get_from_smartcache_or_db(bid, channel_id) - # إذا ما في بوتات مرتبطة بالم mapper، نبحث في persistent - if not bot_ids and hasattr(db, "get_verifications_for_channel"): - result = db.get_verifications_for_channel(channel_id) - return result - - def get_cached_verifications_for_bot(self, bot_id): - """إرجاع جميع التحققات لبوت (للواجهات). - - يحاول Smart Cache أولاً - - يرجع Persistent DB إذا الكاش فارغ - """ - result = {} - # نحتاج IDs القنوات المرتبطة بالبوت - if hasattr(db, "mapper"): - channel_ids = db.mapper.get_channels_for_bot(bot_id) - else: - deps = db.get_dependencies_for("publishing_bot", bot_id) if hasattr(db, "get_dependencies_for") else [] - channel_ids = [d.get("id", "") for d in deps] - for cid in channel_ids: - result[str(cid)] = self._get_from_smartcache_or_db(bot_id, cid) - # إذا ما في قنوات مرتبطة، نبحث في persistent - if not channel_ids and hasattr(db, "get_verifications_for_bot"): - result = db.get_verifications_for_bot(bot_id) - return result - - -# إنشاء instance عامة -verifier = VerificationService() - - -# ============================================================ -# دوال تحقق قديمة (متوافقة مع الكود الموجود) -# ============================================================ - -async def _telegram_verify_bot_in_channel(bot_token, channel_id): - """للتوافق مع الكود القديم - يستخدم VerificationService.""" - return await verifier._api_check_bot_in_channel(bot_token, channel_id) - - -async def require_bot_token_valid(token): - """للتوافق مع الكود القديم.""" - return await verifier.validate_token(token) - - -def member_status_value(member_status): - """يرجع قيمة حالة العضوية كنص نظيف (متوافق مع Enum أو نص من أي إصدار pyrogram).""" - if member_status is None: - return "" - if hasattr(member_status, "value"): - return str(member_status.value) - return str(member_status) - - -def is_bot_admin_status(status_value): - """الحالات الصالحة: ADMINISTRATOR / OWNER (و creator للتوافق القديم). - غير الصالحة: MEMBER / LEFT / BANNED / RESTRICTED.""" - return status_value in ("administrator", "owner", "creator") - - -async def _verify_channel_before_publish(target): - """التحقق من القناة قبل النشر.""" - return await verifier.check_channel_before_operation(str(target)) - - -async def heartbeat_watchdog_loop(): - """نبض مراقبة: يطبع حالة دورية وينبه إذا توقف الفحص/النشر مدة طويلة.""" - global _last_watchdog_alert, _last_heartbeat_log - await asyncio.sleep(60) - while True: - try: - now = time.time() - idle_poll = int(now - _last_poll_ok) - idle_publish = int(now - _last_publish_ok) - - if now - _last_heartbeat_log >= 600: - _last_heartbeat_log = now - logger.info(f"💓 Heartbeat OK | آخر فحص قبل {idle_poll}s | آخر نشر قبل {idle_publish}s") - - if idle_poll >= 3600 and now - _last_watchdog_alert >= 3600: - _last_watchdog_alert = now - logger.warning(f"⚠️ Watchdog: لا يوجد فحص ناجح منذ {idle_poll} ثانية") - await notify_admins(f"⚠️ تنبيه مراقبة: لا يوجد فحص ناجح منذ {idle_poll // 60} دقيقة. إذا لم يرجع النشر، افتح الاستضافة أو سوِ Restart.") - await reconnect_user_client_if_needed("watchdog idle poll") - except Exception as e: - logger.warning(f"⚠️ خطأ في Heartbeat/Watchdog: {e}") - await asyncio.sleep(60) - -def clean_text(text, source_id=None, channel_id=None): - """تنظيف نهائي قبل النشر لقناة معيّنة. يرجع None إذا النص يحتوي كلمة محظورة خاصة بهذه القناة.""" - if not text: - return text - - if isinstance(text, str): - text = full_clean_text(text, source_id=source_id) - text = apply_channel_remove_terms(text, channel_id) - text = apply_channel_link_filters(text, channel_id) - - blocked = db.get_channel_blocked_words(channel_id) if channel_id is not None and hasattr(db, "get_channel_blocked_words") else [] - for word in blocked: - if word and word in text: - return None - - return "\n".join(line for line in text.splitlines() if line.strip()) - - return text - -def apply_tail(text, tail): - if tail: - return f"{text}\n{tail}" if text else tail - return text - - -def get_publish_delay_for_channel(channel_id, default=0.5): - try: - return db.get_channel_publish_delay(channel_id, default) if hasattr(db, "get_channel_publish_delay") else default - except Exception: - return default - -def is_channel_bold_enabled(channel_id): - try: - return bool(db.get_channel_bold_publish(channel_id, True)) if hasattr(db, "get_channel_bold_publish") else True - except Exception: - return True - - -def _utf16_len(text): - try: - return len(str(text or "").encode("utf-16-le")) // 2 - except Exception: - return len(str(text or "")) - - -def _entity_type(name): - if MessageEntityType is None: - return None - try: - return getattr(MessageEntityType, name) - except Exception: - return None - - -def build_quote_entities_for_channel(text, channel_id, content_type="text"): - """يبني entities رسمية للاقتباس حتى لا نعتمد على HTML blockquote إذا ما يدعمه Pyrogram.""" - if not text: - return None - if not is_channel_quote_enabled(channel_id, content_type): - return None - if MessageEntity is None or MessageEntityType is None: - return None - - length = _utf16_len(text) - if length <= 0: - return None - - entities = [] - blockquote_type = _entity_type("BLOCKQUOTE") - bold_type = _entity_type("BOLD") - - if blockquote_type is not None: - try: - entities.append(MessageEntity(type=blockquote_type, offset=0, length=length)) - except Exception: - return None - - if is_channel_bold_enabled(channel_id) and bold_type is not None: - try: - entities.append(MessageEntity(type=bold_type, offset=0, length=length)) - except Exception: - pass - - return entities or None - - -def _html_quote_text_for_channel(text, channel_id): - """يبني اقتباس Telegram الرسمي بصيغة HTML بدون إضافة رموز مرئية مثل ▌.""" - escaped = html.escape(str(text or "")) - if is_channel_bold_enabled(channel_id): - escaped = f"{escaped}" - return f"
{escaped}" - - -def format_outgoing_payload_for_channel(text, channel_id, content_type="text", news_text=None, tail=None): - """يرجع النص + parse_mode + entities. عند تفعيل الاقتباس نستخدم Telegram Quote الرسمي فقط.""" - if not text: - return text, None, None - - cfg = db.get_channel_config(channel_id) if hasattr(db, "get_channel_config") else {} - title_quote = bool(cfg.get("title_quote", False)) - sig_quote = bool(cfg.get("signature_quote", False)) - - if title_quote or sig_quote: - bold_enabled = is_channel_bold_enabled(channel_id) - body = str(text) - news_text_str = str(news_text or "").strip() - tail_str = str(tail or "").strip() - escaped_body = html.escape(body) - # نبحث عن النص الأصلي ونبدله بالنسخة المقتبسة داخل الـ HTML - if title_quote and news_text_str: - escaped_news = html.escape(news_text_str) - if escaped_news in escaped_body: - escaped_body = escaped_body.replace(escaped_news, f"
{escaped_news}", 1) - if sig_quote and tail_str: - escaped_tail = html.escape(tail_str) - idx = escaped_body.rfind(escaped_tail) - if idx != -1: - escaped_body = escaped_body[:idx] + f"
{escaped_tail}" + escaped_body[idx + len(escaped_tail):] - if bold_enabled: - escaped_body = f"{escaped_body}" - return escaped_body, ParseMode.HTML, None - - if is_channel_quote_enabled(channel_id, content_type): - entities = build_quote_entities_for_channel(str(text), channel_id, content_type) - if entities: - return str(text), None, entities - return _html_quote_text_for_channel(text, channel_id), ParseMode.HTML, None - - formatted, parse_mode = format_outgoing_text_for_channel(text, channel_id) - return formatted, parse_mode, None - - -def format_outgoing_text_for_channel(text, channel_id): - """يرجع النص مع ParseMode المناسب. ترتيب الهاشتاك/التوقيع يتم قبل هذه الدالة.""" - if not text: - return text, None - - bold_enabled = is_channel_bold_enabled(channel_id) - - escaped = html.escape(str(text)) - if bold_enabled: - escaped = f"{escaped}" - - if bold_enabled: - return escaped, ParseMode.HTML - return str(text), None - -def channel_display_name(ch_or_id): - ch = ch_or_id if isinstance(ch_or_id, dict) else db.get_channel(ch_or_id) - if isinstance(ch, dict): - name = entity_name(ch) - cid = ch.get("id") - if isinstance(cid, (str, int)): - return f"{name} | ID: `{cid}`" - return name - if isinstance(ch_or_id, (str, int)): - return f"ID: `{ch_or_id}`" - return str(ch_or_id or "") - - -def source_display_name(src_id): - meta = db.get_source_meta(src_id) if hasattr(db, "get_source_meta") else {"id": src_id} - return f"{entity_name(meta)} | ID: `{src_id}`" - - -def pair_buttons(buttons): - """يرتب الأزرار المفردة على عمودين قدر الإمكان.""" - rows = [] - pending = [] - for item in buttons: - if isinstance(item, list): - if pending: - rows.append(pending) - pending = [] - rows.append(item) - else: - pending.append(item) - if len(pending) == 2: - rows.append(pending) - pending = [] - if pending: - rows.append(pending) - return rows - - - - -def grid_buttons(buttons, max_cols=3): - """يرتب الأزرار المفردة بعمودين فقط حتى تظهر أسماء الأزرار بوضوح.""" - rows = [] - pending = [] - max_cols = 2 - for item in buttons: - if isinstance(item, list): - if pending: - rows.append(pending) - pending = [] - rows.append(item) - else: - pending.append(item) - if len(pending) >= max_cols: - rows.append(pending) - pending = [] - if pending: - rows.append(pending) - return rows - - -def nav_row(back=None): - row = [] - if back: - row.append(InlineKeyboardButton("🔙 رجوع", callback_data=back)) - row.append(InlineKeyboardButton("🏠 القائمة الرئيسية", callback_data="main_menu")) - return row - - -def append_channel_hashtags(text, channel_id): - try: - tags = db.get_channel_hashtags(channel_id) if hasattr(db, "get_channel_hashtags") else [] - except Exception: - tags = [] - if not tags: - return text - tag_line = " ".join(tags) - return f"{text}\n\n{tag_line}" if text else tag_line - - -def compose_channel_post_text(news_text, channel_id, tail=""): - """يرتب المنشور دائماً: الخبر ثم الهاشتاك ثم التوقيع ثم الفاصل.""" - parts = [] - news_text = str(news_text or "").strip() - if news_text: - parts.append(news_text) - try: - tags = db.get_channel_hashtags(channel_id) if hasattr(db, "get_channel_hashtags") else [] - except Exception: - tags = [] - if tags: - parts.append(" ".join(tags).strip()) - tail = str(tail or "").strip() - if tail: - parts.append(tail) - if "---" not in tail and "—" not in tail: - parts.append("------------") - return "\n\n".join([p for p in parts if p]) - -def is_channel_quote_enabled(channel_id, content_type="text"): - try: - if hasattr(db, "get_channel_quote_type"): - return bool(db.get_channel_quote_type(channel_id, content_type, False)) - return bool(db.get_channel_quote_publish(channel_id, False)) if hasattr(db, "get_channel_quote_publish") else False - except Exception: - return False - - -def get_channel_quote_types_safe(channel_id): - try: - if hasattr(db, "get_channel_quote_types"): - return db.get_channel_quote_types(channel_id) - except Exception: - pass - enabled = is_channel_quote_enabled(channel_id, "text") - return {"text": enabled, "photo": enabled, "video": enabled, "album": enabled} - - - -def is_ignore_short_posts_enabled(): - try: - return bool(db.get_ignore_short_posts()) if hasattr(db, "get_ignore_short_posts") else False - except Exception: - return False - - -def is_short_post_text(text, min_words=5, min_chars=20): - cleaned = compact_text(text or "") - if not cleaned: - return False - word_count = len([w for w in cleaned.split() if w.strip()]) - char_count = len(cleaned) - return word_count < int(min_words) or char_count < int(min_chars) - - -def is_ignore_short_posts_enabled_for_channel(channel_id): - try: - if hasattr(db, "get_channel_ignore_short_posts"): - return bool(db.get_channel_ignore_short_posts(channel_id)) - except Exception: - pass - return is_ignore_short_posts_enabled() - - -def should_ignore_short_post(text): - """Legacy global helper kept for compatibility with older callers/tests.""" - return is_ignore_short_posts_enabled() and is_short_post_text(text) - - -def should_ignore_short_post_for_channel(text, channel_id): - return is_ignore_short_posts_enabled_for_channel(channel_id) and is_short_post_text(text) - - -def record_channel_success(channel_id): - try: - if hasattr(db, "record_channel_publish_success"): - db.record_channel_publish_success(channel_id) - except Exception: - pass - - -async def record_channel_failure_and_maybe_alert(channel_id, error): - count = None - try: - if hasattr(db, "record_channel_publish_failure"): - count = db.record_channel_publish_failure(channel_id, error, limit=5) - except Exception: - pass - if count and int(count) >= 5: - await notify_admins(f"تم إيقاف قناة النشر تلقائياً بعد {count} أخطاء متتالية:\n{channel_display_name(channel_id)}\n\nالخطأ: {error}") - -async def notify_admins(text): - try: - if hasattr(db, "add_last_error"): - db.add_last_error("admin_alert", text) - except Exception: - pass - for admin_id in config.ADMINS: - try: - await bot_client.send_message(admin_id, f"⚠️ {text}") - except Exception as e: - logger.error(f"فشل إشعار الأدمن {admin_id}: {e}") - -async def hydrate_peer(client, chat_id, label="peer"): - """يحاول تحميل الـ peer داخل جلسة Pyrogram حتى لا يظهر Peer id invalid.""" - try: - chat = await client.get_chat(int(chat_id)) - logger.info(f"✅ تم تحميل {label}: {getattr(chat, 'id', chat_id)}") - return chat - except Exception as first_error: - logger.warning(f"⚠️ فشل get_chat أول مرة لـ {label}={chat_id}: {first_error}. سيتم فحص الحوارات...") - - try: - async for dialog in client.get_dialogs(limit=300): - if dialog.chat and int(dialog.chat.id) == int(chat_id): - logger.info(f"✅ تم العثور على {label} داخل الحوارات: {chat_id}") - return dialog.chat - except Exception as dialog_error: - logger.warning(f"⚠️ فشل فحص الحوارات لـ {label}: {dialog_error}") - - chat = await client.get_chat(int(chat_id)) - return chat - - -async def hydrate_publish_channel(channel_id): - """تهيئة قناة نشر قبل الإرسال، خصوصاً بعد الاستيراد.""" - ch = db.get_channel(channel_id) if hasattr(db, "get_channel") else None - candidates = [] - if ch: - username = ch.get("username") or "" - link = ch.get("link") or "" - if username: - candidates.append("@" + username.lstrip("@")) - if link: - candidates.append(link) - candidates.append(int(channel_id)) - last_error = None - for item in candidates: - try: - return await bot_client.get_chat(item) - except Exception as e: - last_error = e - raise last_error or ValueError(f"تعذر تهيئة قناة النشر {channel_id}") - - -async def hydrate_source_channel(source_id): - meta = db.get_source_meta(source_id) if hasattr(db, "get_source_meta") else {} - candidates = [] - username = meta.get("username") or "" if isinstance(meta, dict) else "" - link = meta.get("link") or "" if isinstance(meta, dict) else "" - if username: - candidates.append("@" + username.lstrip("@")) - if link: - candidates.append(link) - candidates.append(int(source_id)) - last_error = None - for item in candidates: - try: - return await user_client.get_chat(item) - except Exception as e: - last_error = e - raise last_error or ValueError(f"تعذر تهيئة المصدر {source_id}") - - -async def safe_send_channel_message(target, text, parse_mode=None, entities=None): - """إرسال نص مع معالجة FloodWait وإعادة تهيئة peer عند الحاجة.""" - try: - await hydrate_publish_channel(target) - except Exception as e: - logger.warning(f"⚠️ فشل تهيئة قناة النشر {target} قبل الإرسال: {e}") - - disable_preview = db.get_channel_disable_preview(target) if hasattr(db, "get_channel_disable_preview") else False - - async def _send_once(): - if entities: - try: - return await bot_client.send_message(target, text, entities=entities, disable_web_page_preview=disable_preview) - except TypeError: - return await bot_client.send_message(target, _html_quote_text_for_channel(text, target), parse_mode=ParseMode.HTML, disable_web_page_preview=disable_preview) - return await bot_client.send_message(target, text, parse_mode=parse_mode, disable_web_page_preview=disable_preview) - - for attempt in range(3): - try: - return await _send_once() - except FloodWait as e: - wait = int(e.value) + 2 - logger.warning(f"⏳ FloodWait في الإرسال إلى {target}: انتظار {wait}s (محاولة {attempt+1}/3)") - await asyncio.sleep(wait) - except Exception as first_error: - if "Peer id invalid" in str(first_error) or "PEER_ID_INVALID" in str(first_error): - await hydrate_publish_channel(target) - return await _send_once() - raise - return await _send_once() - -async def warm_middle_peer(): - """تحميل القناة الوسيطة داخل جلسة الحساب والبوت قبل أي تحويل ميديا.""" - global _middle_peer_warmed - if _middle_peer_warmed: - return True - - ok = True - try: - await hydrate_peer(user_client, config.MIDDLE_CHANNEL, "القناة الوسيطة / user_client") - except Exception as e: - ok = False - logger.error(f"❌ user_client لا يستطيع الوصول للقناة الوسيطة {config.MIDDLE_CHANNEL}: {e}") - - try: - await hydrate_peer(bot_client, config.MIDDLE_CHANNEL, "القناة الوسيطة / bot_client") - except Exception as e: - ok = False - logger.error(f"❌ bot_client لا يستطيع الوصول للقناة الوسيطة {config.MIDDLE_CHANNEL}: {e}") - - _middle_peer_warmed = ok - return ok - -async def forward_to_middle(source_chat_id, message_ids): - """يحّول رسالة أو مجموعة رسائل إلى القناة الوسيطة مع إعادة محاولة عند Peer id invalid.""" - await warm_middle_peer() - try: - return await user_client.forward_messages( - chat_id=config.MIDDLE_CHANNEL, - from_chat_id=int(source_chat_id), - message_ids=message_ids - ) - except Exception as e: - logger.warning(f"⚠️ فشل التوجيه للوسيطة، سيتم إعادة تحميل الـ peer والمحاولة مرة ثانية: {e}") - global _middle_peer_warmed - _middle_peer_warmed = False - await warm_middle_peer() - return await user_client.forward_messages( - chat_id=config.MIDDLE_CHANNEL, - from_chat_id=int(source_chat_id), - message_ids=message_ids - ) - -async def copy_middle_messages_to_target(target, middle_messages, caption=None, parse_mode=None, content_type=None, caption_entities=None): - """ينسخ رسائل الوسيطة لقناة النشر. - - الصور/الفيديو المفرد: copy_message بدون تحميل. - - الألبومات: send_media_group كألبوم واحد بدون Forward. - """ - if not isinstance(middle_messages, (list, tuple)): - middle_messages = [middle_messages] - - middle_messages = sorted([m for m in middle_messages if m], key=lambda m: m.id) - if not middle_messages: - return False - - try: - await hydrate_publish_channel(target) - except Exception as e: - logger.warning(f"⚠️ فشل تهيئة قناة النشر {target} قبل النسخ: {e}") - - is_album = len(middle_messages) > 1 or bool(getattr(middle_messages[0], "media_group_id", None)) - - # الألبومات فقط: نرسلها كـ Media Group واحد حتى لا تتفكك إلى صور منفصلة. - # هذا المسار قد يحمّل ملفات الألبوم مؤقتاً ثم يحذفها، بينما الصور/الفيديو المفرد تبقى بدون تحميل. - if is_album and len(middle_messages) > 1: - temp_dir = tempfile.mkdtemp(prefix="album_clean_") - downloaded_files = [] - media_items = [] - try: - for idx, m in enumerate(middle_messages): - if not getattr(m, "photo", None) and not getattr(m, "video", None): - logger.warning(f"⚠️ تم تخطي عنصر غير مدعوم داخل الألبوم | message_id={getattr(m, 'id', None)}") - continue - - file_path = await m.download(file_name=os.path.join(temp_dir, "")) - if not file_path: - logger.warning(f"⚠️ فشل تنزيل عنصر من الألبوم | message_id={getattr(m, 'id', None)}") - continue - - downloaded_files.append((file_path, bool(getattr(m, "video", None)))) - item_caption = caption if idx == 0 and caption else None - - if getattr(m, "video", None): - media_items.append(InputMediaVideo( - media=file_path, - caption=item_caption, - parse_mode=None if (idx == 0 and item_caption and caption_entities) else (parse_mode if item_caption else None), - caption_entities=caption_entities if idx == 0 and item_caption and caption_entities else None - )) - else: - media_items.append(InputMediaPhoto( - media=file_path, - caption=item_caption, - parse_mode=None if (idx == 0 and item_caption and caption_entities) else (parse_mode if item_caption else None), - caption_entities=caption_entities if idx == 0 and item_caption and caption_entities else None - )) - - if not media_items: - logger.error(f"❌ فشل نشر الألبوم النظيف إلى {target}: لا توجد وسائط صالحة") - return False - - sent = None - pyrofork_topics_success = False - try: - sent = await _retry_on_flood(lambda: bot_client.send_media_group(chat_id=int(target), media=media_items), label=f"album→{target}") - except TypeError as e: - err_text = str(e) - if "topics" in err_text and "Messages.__init__" in err_text: - pyrofork_topics_success = True - logger.warning(f"⚠️ نُشر الألبوم غالباً لكن Pyrofork فشل بقراءة الرد بسبب topics؛ اعتُبر نجاحاً لمنع التكرار | target={target}") - else: - # بعض إصدارات Pyrogram/Pyrofork لا تقبل caption_entities داخل InputMedia. - media_items = [] - for idx, (file_path, is_video_file) in enumerate(downloaded_files): - item_caption = caption if idx == 0 and caption else None - if is_video_file: - media_items.append(InputMediaVideo(media=file_path, caption=item_caption, parse_mode=parse_mode if item_caption else None)) - else: - media_items.append(InputMediaPhoto(media=file_path, caption=item_caption, parse_mode=parse_mode if item_caption else None)) - try: - sent = await _retry_on_flood(lambda: bot_client.send_media_group(chat_id=int(target), media=media_items), label=f"album→{target}") - except TypeError as e2: - err_text2 = str(e2) - if "topics" in err_text2 and "Messages.__init__" in err_text2: - pyrofork_topics_success = True - logger.warning(f"⚠️ نُشر الألبوم غالباً لكن Pyrofork فشل بقراءة الرد بسبب topics؛ اعتُبر نجاحاً لمنع التكرار | target={target}") - else: - raise - except Exception as e: - err_text = str(e) - if "topics" in err_text and "Messages.__init__" in err_text: - pyrofork_topics_success = True - logger.warning(f"⚠️ نُشر الألبوم غالباً لكن Pyrofork فشل بقراءة الرد بسبب topics؛ اعتُبر نجاحاً لمنع التكرار | target={target}") - else: - raise - - sent_list = [] if sent is None else (sent if isinstance(sent, list) else [sent]) - sent_ids = [m.id for m in sent_list if getattr(m, "id", None)] - try: - if hasattr(db, "record_published_messages") and sent_ids: - db.record_published_messages(target, sent_ids, content_type or "album") - except Exception: - pass - - mark_runtime_activity("publish") - if pyrofork_topics_success: - logger.info(f"✅ نُشر ألبوم نظيف إلى {target} بدون Forward | Pyrofork topics handled") - else: - logger.info(f"✅ نُشر ألبوم نظيف إلى {target} بدون Forward | count={len(sent_ids)}") - return True - except Exception as e: - logger.error(f"❌ فشل نشر الألبوم النظيف إلى {target}: {e}") - try: - if hasattr(db, "add_last_error"): - db.add_last_error("clean_album", f"{target}: {e}") - except Exception: - pass - return False - finally: - shutil.rmtree(temp_dir, ignore_errors=True) - - # الصور والفيديوهات المفردة: تبقى بدون تحميل عبر copy_message. - sent_ids = [] - disable_preview = db.get_channel_disable_preview(target) if hasattr(db, "get_channel_disable_preview") else False - for idx, m in enumerate(middle_messages): - item_caption = caption if idx == 0 and caption else None - item_parse_mode = None if (idx == 0 and caption and caption_entities) else (parse_mode if idx == 0 and caption else None) - - kwargs = dict( - chat_id=int(target), - from_chat_id=config.MIDDLE_CHANNEL, - message_id=m.id, - caption=item_caption, - parse_mode=item_parse_mode, - disable_web_page_preview=disable_preview, - ) - if idx == 0 and caption and caption_entities: - kwargs["caption_entities"] = caption_entities - - async def _do_copy(kw=kwargs, ix=idx, cap=item_caption): - try: - return await bot_client.copy_message(**kw) - except TypeError: - kw.pop("caption_entities", None) - kw.pop("disable_web_page_preview", None) - if ix == 0 and cap and caption_entities: - kw["caption"] = _html_quote_text_for_channel(cap, target) - kw["parse_mode"] = ParseMode.HTML - else: - kw["parse_mode"] = parse_mode if ix == 0 and cap else None - return await bot_client.copy_message(**kw) - - try: - sent = await _retry_on_flood(_do_copy, label=f"copy→{target}") - if getattr(sent, "id", None): - sent_ids.append(sent.id) - except Exception as e: - logger.warning(f"⚠️ فشل نسخ رسالة وسيطة إلى {target} (idx={idx}): {e}") - - await asyncio.sleep(0.3) - - try: - if hasattr(db, "record_published_messages") and sent_ids: - db.record_published_messages(target, sent_ids, content_type or "photo") - except Exception: - pass - - if sent_ids: - mark_runtime_activity("publish") - logger.info(f"✅ نُشر {len(sent_ids)} رسالة إلى {target} بدون تحميل") - return True - - logger.warning(f"⚠️ فشل نشر جميع رسائل الوسيطة إلى {target}") - return False - -async def safe_edit(callback, text, reply_markup=None): - if len(text) > 4096: - text = text[:4093] + "..." - reply_markup = _compact_ui_markup(reply_markup) - try: - await callback.edit_message_text(text, reply_markup=reply_markup) - except MessageNotModified: - try: - await callback.answer() - except Exception: - pass - except Exception as e: - callback_data = getattr(callback, "data", "") - logger.error( - "فشل تعديل الرسالة: callback=%r error=%s: %s", - callback_data, - type(e).__name__, - e, - ) - try: - await callback.answer("حدث خطأ أثناء تحديث القائمة.", show_alert=True) - except Exception: - pass - -async def resolve_chat_id(raw, prefer_user=True): - chat = await resolve_chat_info(raw, prefer_user=prefer_user) - return int(chat.id) - - -def normalize_chat_input(raw): - raw = str(raw).strip() - match = re.match(r'(?:https?://)?t\.me/([A-Za-z0-9_]+)', raw) - if match: - raw = "@" + match.group(1) - return raw - - -def split_bulk_lines(text): - items = [] - for line in str(text or "").splitlines(): - line = line.strip() - if not line: - continue - if "," in line and "t.me/" not in line: - items.extend([p.strip() for p in line.split(",") if p.strip()]) - else: - items.append(line) - seen = set() - unique = [] - for item in items: - key = normalize_chat_input(item).lower() - if key not in seen: - seen.add(key) - unique.append(item) - return unique - - -def split_terms_lines(text): - """يقسم النص إلى عناصر جماعية: كل سطر عنصر، ويدعم الفواصل إذا ما بيها روابط.""" - items = [] - for line in str(text or "").splitlines(): - line = line.strip() - if not line: - continue - if "," in line and "http" not in line and "t.me/" not in line: - items.extend([p.strip() for p in line.split(",") if p.strip()]) - else: - items.append(line) - seen = set() - out = [] - for item in items: - key = item.lower() - if key not in seen: - seen.add(key) - out.append(item) - return out - - -async def resolve_chat_info_timeout(raw, prefer_user=True, timeout=8): - """يجلب معلومات القناة/المصدر بمهلة حتى لا يعلق البوت عند الإضافة.""" - return await asyncio.wait_for(resolve_chat_info(raw, prefer_user=prefer_user), timeout=timeout) - - -def apply_source_remove_terms(text, source_id): - """التنظيف الذكي: يحذف عناصر هذا المصدر فقط ولا يمنع المنشور.""" - if not text: - return text - try: - terms = db.get_source_remove_terms(source_id) if hasattr(db, "get_source_remove_terms") else [] - except Exception: - terms = [] - cleaned = str(text) - for term in terms: - cleaned = remove_term_variants(cleaned, term) - return compact_text(cleaned) - - -# ===== 🔗 إدارة الروابط — خاصة بكل قناة على حدة ===== -CHANNEL_LINK_TG_RE = re.compile(r"(?:https?://)?(?:www\.)?(?:t(?:elegram|lgrm)?\.me|t\.me)/[^\s]+", re.IGNORECASE) -CHANNEL_TG_USERNAME_RE = re.compile(r"(?= 3 or token.isdigit()] - if len(tokens) < 3: - return "" - return hashlib.sha1(" ".join(sorted(set(tokens))).encode("utf-8", errors="ignore")).hexdigest() - - -def make_url_fp(text): - urls = re.findall(r"https?://[^\s<>]+", str(text or ""), flags=re.IGNORECASE) - if not urls: - return "" - clean_urls = sorted({url.rstrip(".,،؛:!?)]}'\"") for url in urls}) - return hashlib.sha1("\n".join(clean_urls).encode("utf-8", errors="ignore")).hexdigest() - - -def _event_overlap(left, right): - left_tokens = set(str(left or "").split()) - right_tokens = set(str(right or "").split()) - if len(left_tokens) < 3 or len(right_tokens) < 3: - return 0.0 - return len(left_tokens & right_tokens) / max(1, len(left_tokens | right_tokens)) - - -def _event_numbers(text): - return set(re.findall(r"(?= threshold: - stored_event_fp = item.get("event_fp", "") - if event_fp and stored_event_fp and stored_event_fp != event_fp: - details_differ = True - continue - return True, "similarity", ratio - # 4. Event Overlap — لا يجوز حكماً منفرداً: يتطلب تطابق event_fp - stored_event_fp = item.get("event_fp", "") - if ( - 0.60 <= ratio < threshold - and _event_overlap(normalized, sample) >= 0.70 - and event_fp - and stored_event_fp - and stored_event_fp == event_fp - ): - return True, "event_overlap", ratio - # 5. المنطقة الرمادية → AI للحسم عند الحاجة فقط - if 0.60 <= ratio < threshold and event_fp and stored_event_fp and stored_event_fp == event_fp: - try: - if _ai_decide_gray_zone(normalized, sample): - return True, "ai_gray_zone", ratio - except Exception: - pass - return False, "event_details_differ" if details_differ else "none", best - -def check_section_claim(scope_id, event_fp): - """حجز ذري على مستوى القسم: أول وصول ي��وز، والباقي مرفوض Duplicate (TOCTOU protected).""" - if scope_id is None or event_fp is None: - return True - try: - if hasattr(db, "claim_section_event"): - return bool(db.claim_section_event(scope_id, event_fp)) - except Exception: - pass - return True - - -def is_smart_duplicate(text, threshold=0.75): - """Legacy similarity-only helper retained for media/caption compatibility.""" - fp, normalized = make_similarity_fp(text) - if not fp: - return False, 0.0 - try: - recent = db.get_recent_fingerprints() if hasattr(db, "get_recent_fingerprints") else [] - except Exception: - recent = [] - best = 0.0 - for item in recent[-300:]: - sample = item.get("sample", "") if isinstance(item, dict) else "" - if not sample: - continue - ratio = difflib.SequenceMatcher(None, normalized, sample).ratio() - best = max(best, ratio) - if ratio >= threshold: - return True, ratio - return False, best - - -def remember_published_text(text, source_id=None, message_id=None, scope_id=None, section_label=None): - fp, normalized = make_text_fp(text) - if fp and hasattr(db, "add_recent_fingerprint"): - try: - db.add_recent_fingerprint( - fp, - normalized, - source_id=source_id, - message_id=message_id, - event_fp=make_event_fp(text), - url_fp=make_url_fp(text), - scope_id=scope_id, - section_label=section_label, - ) - except Exception: - pass - - -def content_types_status_line(source_id): - labels = {"text": "نصوص", "photo": "صور", "video": "فيديو", "album": "ألبومات", "voice": "بصمات", "audio": "صوتيات", "document": "ملفات"} - try: - types = db.get_source_content_types(source_id) if hasattr(db, "get_source_content_types") else {} - except Exception: - types = {} - if not types: - types = {"text": True, "photo": True, "video": True, "album": True} - return " | ".join(("✅ " if types.get(k, True) else "❌ ") + v for k, v in labels.items()) - - - -async def resolve_chat_info(raw, prefer_user=True): - raw = normalize_chat_input(raw) - value = int(raw) if re.fullmatch(r"-?\d+", raw) else raw - clients = [user_client, bot_client] if prefer_user else [bot_client, user_client] - last_error = None - for c in [x for x in clients if x]: - try: - return await c.get_chat(value) - except Exception as e: - last_error = e - raise last_error or ValueError("تعذر جلب معلومات القناة") - - -def chat_title(chat, fallback=""): - return getattr(chat, "title", None) or getattr(chat, "first_name", None) or getattr(chat, "username", None) or str(fallback) - - -def chat_username(chat): - return getattr(chat, "username", None) or "" - - -def chat_link_from_username(username): - return f"https://t.me/{username}" if username else "" - - -def chat_type_name(chat): - return str(getattr(chat, "type", "")).replace("ChatType.", "") - - -def chat_meta(chat, fallback=""): - username = chat_username(chat) - title = chat_title(chat, fallback) - return { - "name": title, - "title": title, - "username": username, - "link": chat_link_from_username(username), - "chat_type": chat_type_name(chat), - } - - -def entity_name(record_or_id, is_source=False): - if isinstance(record_or_id, dict): - return record_or_id.get("title") or record_or_id.get("name") or record_or_id.get("username") or str(record_or_id.get("id", "")) - if is_source and hasattr(db, "get_source_meta"): - return entity_name(db.get_source_meta(record_or_id)) - return str(record_or_id) - - -def entity_details_line(item, is_source=False): - if is_source: - meta = db.get_source_meta(item) if not isinstance(item, dict) else item - item_id = meta.get("id", item) - else: - meta = item - item_id = item.get("id") - name = entity_name(meta) - username = meta.get("username") or "" - username_txt = f"@{username}" if username else "لا يوجد يوزر" - return f"• {name}\n {username_txt}\n ID: `{item_id}`" - - -async def show_public_sources_menu_from_message(message): - srcs = db.get_public_sources() - meta_list = db.get_all_public_sources_with_meta() if hasattr(db, "get_all_public_sources_with_meta") else [{"id": s, "name": str(s)} for s in srcs] - text = f"**المصادر العامة ({len(srcs)}):**\n➕ إضافة مصدر/مصادر: يضيف قنوات عامة جديدة يراقبها البوت (رابط/يوزر/ID).\n🗑 حذف مصدر عام: يحذف مصدراً عاماً من المراقبة." - text += "\n\n".join(entity_details_line(m, is_source=True) for m in meta_list) if srcs else "لا توجد." - buttons = [ - [InlineKeyboardButton("➕ إضافة مصدر/مصادر", callback_data="add_public_src")], - [InlineKeyboardButton("🗑 حذف مصدر عام", callback_data="del_public_src")], - nav_row("main_menu"), - ] - await message.reply(text, reply_markup=InlineKeyboardMarkup(buttons)) - - -async def show_blocked_words_menu_from_message(message): - words = db.get_blocked_words() - text = "**الكلمات المحظورة:**\n" + (", ".join(words) if words else "لا توجد.") + "\n➕ إضافة كلمة: يمنع نشر أي منشور يحتوي هذه الكلمة.\n🗑 حذف كلمة: يزيل كلمة من القائمة المحظورة." - buttons = [ - [InlineKeyboardButton("➕ إضافة كلمة", callback_data="add_blocked_word")], - [InlineKeyboardButton("🗑 حذف كلمة", callback_data="del_blocked_word")], - nav_row("main_menu"), - ] - await message.reply(text, reply_markup=InlineKeyboardMarkup(buttons)) - - -async def show_special_sources_menu_from_message(message, ch_id): - ch = db.get_channel(ch_id) - if not ch: - await message.reply("❌ القناة غير موجودة.") - return - sources = ch.get("special_sources", []) - text = "**المصادر المخصصة:**\n➕ إضافة: يضيف مصدراً خاصاً لهذه القناة فقط.\n🗑 حذف: يزيل مصدراً خاصاً." - text += "\n".join(f"• {entity_name(s, is_source=True)}\n ID: `{s}`" for s in sources) if sources else "لا توجد." - buttons = [ - [InlineKeyboardButton("➕ إضافة", callback_data=f"addspecsrc_{ch_id}")], - [InlineKeyboardButton("🗑 حذف", callback_data=f"delspecsrc_{ch_id}")], - nav_row(f"ch_{ch_id}"), - ] - await message.reply(text, reply_markup=InlineKeyboardMarkup(buttons)) - - -def format_bulk_report(title, results): - ok = [r for r in results if r[0] == "ok"] - exists = [r for r in results if r[0] == "exists"] - failed = [r for r in results if r[0] == "fail"] - lines = [title, "", f"✅ تمت الإضافة: {len(ok)}", f"⚠️ موجود مسبقاً: {len(exists)}", f"❌ فشل: {len(failed)}"] - if ok: - lines.append("\n**المضافة:**") - lines.extend([f"• {name}" for _, name, _ in ok[:30]]) - if exists: - lines.append("\n**الموجودة مسبقاً:**") - lines.extend([f"• {name}" for _, name, _ in exists[:20]]) - if failed: - lines.append("\n**الفاشلة:**") - lines.extend([f"• {raw}: {err}" for _, raw, err in failed[:20]]) - return "\n".join(lines) - -main_keyboard = InlineKeyboardMarkup([ - [ - InlineKeyboardButton("📢 إدارة قنوات النشر", callback_data="menu_channels"), - InlineKeyboardButton("🌐 إدارة المصادر العامة", callback_data="menu_public_src") - ], - [ - InlineKeyboardButton("💾 الاستيراد / التصدير", callback_data="backup_menu"), - InlineKeyboardButton("📊 الإحصائيات", callback_data="stats") - ], - [ - InlineKeyboardButton("🖥️ النظام", callback_data="system_menu"), - InlineKeyboardButton("🔐 الأسرار", callback_data="secrets_menu") - ], - [ - InlineKeyboardButton("🌐 Blogger Publisher", callback_data="blogger_menu") - ] -]) - - -async def backup_menu(client, callback): - text = "**💾 الاستيراد / التصدير**\n\n📥 تصدير البيانات: ينزّل ملفاً يحتوي كل إعدادات البوت.\n📤 استيراد البيانات: يرفع ملف إعدادات سابقاً لاستعادة البوت." - buttons = [ - [ - InlineKeyboardButton("📥 تصدير البيانات", callback_data="export_data"), - InlineKeyboardButton("📤 استيراد البيانات", callback_data="import_data") - ], - nav_row("main_menu") - ] - await safe_edit(callback, text, InlineKeyboardMarkup(buttons)) - - -async def start(client, message): - user_id = message.from_user.id - user_states.pop(user_id, None) - - logger.info(f"📥 /start من ID: {user_id}") - - if not is_admin(user_id): - await message.reply(f"❌ غير مصرح. معرفك: `{user_id}`") - return - - await message.reply("أهلاً بك في لوحة تحكم البوت:", reply_markup=main_keyboard) - -async def export_callback(client, callback): - try: - data_str = db.export_data() - - with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False, encoding='utf-8') as tmp: - tmp.write(data_str) - tmp_path = tmp.name - - await callback.message.reply_document(document=tmp_path, file_name=f"backup_{int(time.time())}.json") - os.unlink(tmp_path) - await callback.answer("تم إرسال ملف النسخة الاحتياطية.") - except Exception as e: - logger.error(f"فشل التصدير: {e}") - await callback.answer("فشل التصدير.", show_alert=True) - -async def import_callback(client, callback): - user_states[callback.from_user.id] = {"state": "waiting_import"} - await safe_edit(callback, "أرسل ملف JSON الذي تريد استيراده:") - -async def handle_import(client, message): - if not is_admin(message.from_user.id): - return - - uid = message.from_user.id - - if uid not in user_states or user_states[uid].get("state") != "waiting_import": - return - - if not message.document: - await message.reply("❌ أرفق ملف JSON صحيح.") - return - - try: - file_path = await message.download() - with open(file_path, "r", encoding="utf-8") as f: - data = f.read() - - result = db.import_data(data) - if isinstance(result, dict): - report = ( - "✅ تم استيراد الإعدادات بنجاح.\n\n" - f"• قنوات النشر: {result.get('channels', 0)}\n" - f"• المصادر العامة: {result.get('public_sources', 0)}\n" - f"• الكلمات المحظورة: {result.get('blocked_words', 0)}\n" - f"• قائمة الحذف العامة: {result.get('global_remove_terms', 0)}\n" - f"• إعدادات المصادر: {result.get('source_meta', 0)}\n" - f"• آخر رسائل المصادر: {result.get('last_source_messages', 0)}" - ) - else: - report = "✅ تم استيراد الإعدادات بنجاح." - await message.reply(report + "\n\n🔄 جارٍ تهيئة القنوات والمصادر بعد الاستيراد...", reply_markup=main_keyboard) - try: - await warm_all_peers_after_import() - await message.reply("✅ تمت تهيئة القنوات والمصادر بعد الاستيراد. المفروض النشر يشتغل بدون إعادة إضافة يدوية.") - except Exception as e: - logger.error(f"فشل تهيئة الاستيراد: {e}") - await message.reply(f"⚠️ تم الاستيراد، لكن فشلت التهيئة التلقائية: {e}") - except Exception as e: - await message.reply(f"❌ فشل الاستيراد: {e}") - finally: - user_states.pop(uid, None) - - -CONTENT_TYPE_LABELS = { - "text": "النصوص", - "photo": "الصور", - "video": "الفيديوهات", - "album": "الألبومات", -} - - -async def purge_published_prompt(client, callback): - try: - _, ch_id, kind = callback.data.split("|", 2) - except Exception: - await callback.answer("طلب غير صالح.", show_alert=True) - return - label = CONTENT_TYPE_LABELS.get(kind, kind) - ids = db.get_published_message_ids(ch_id, kind) if hasattr(db, "get_published_message_ids") else [] - ch = db.get_channel(ch_id) - text = ( - f"⚠️ **تأكيد حذف {label} من قناة النشر**\n\n" - f"{channel_display_name(ch_id)}\n\n" - f"عدد الرسائل المسجلة للحذف: {len(ids)}\n\n" - "سيحذف البوت فقط الرسائل التي يعرف IDs مالتهن والمنشورة بعد إضافة هذا النظام." - ) - buttons = [ - [InlineKeyboardButton(f"✅ نعم، احذف {label}", callback_data=f"confirmpurge|{ch_id}|{kind}")], - [InlineKeyboardButton("❌ إلغاء", callback_data=f"purgepage_{ch_id}")], - [InlineKeyboardButton("🏠 القائمة الرئيسية", callback_data="main_menu")], - ] - await safe_edit(callback, text, InlineKeyboardMarkup(buttons)) - - -async def _try_delete_with_bots(ch_id, mids): - """محاولة الحذف باستخدام البوت الأساسي ثم البوتات المخصصة للقناة.""" - deleted_ids = [] - failed_ids = [] - bots_to_try = [bot_client] - cfg = db.get_channel_config(ch_id) if hasattr(db, "get_channel_config") else {} - for bid in cfg.get("assigned_bots", []): - token = db.bot_manager.get_token(bid) if hasattr(db, "bot_manager") else (db.get_publishing_bot(bid) or {}).get("token", "") - bdata = db.get_publishing_bot(bid) if hasattr(db, "get_publishing_bot") else None - if token: - bots_to_try.append({"id": bid, "token": token, **(bdata or {})}) - remaining = list(mids) - for bot in bots_to_try: - if not remaining: - break - if isinstance(bot, dict): - token = db.bot_manager.get_token(bot.get("id", "")) if hasattr(db, "bot_manager") else bot.get("token", "") - try: - temp = Client(":memory:", api_id=config.API_ID, api_hash=config.API_HASH, bot_token=token, in_memory=True) - await temp.start() - for mid in remaining: - try: - await temp.delete_messages(int(ch_id), int(mid)) - deleted_ids.append(mid) - except Exception: - failed_ids.append(mid) - await asyncio.sleep(0.2) - await temp.stop() - except Exception: - failed_ids.extend(remaining) - remaining = [m for m in remaining if m not in deleted_ids] - else: - for i in range(0, len(remaining), 100): - chunk = remaining[i:i+100] - try: - await bot.delete_messages(int(ch_id), chunk) - deleted_ids.extend(chunk) - except Exception: - for mid in chunk: - try: - await bot.delete_messages(int(ch_id), int(mid)) - deleted_ids.append(mid) - except Exception as e: - failed_ids.append(mid) - await asyncio.sleep(0.3) - remaining = [m for m in remaining if m not in deleted_ids] - return deleted_ids, failed_ids - - -async def confirm_purge_published(client, callback): - try: - _, ch_id, kind = callback.data.split("|", 2) - except Exception: - await callback.answer("طلب غير صالح.", show_alert=True) - return - ids = db.get_published_message_ids(ch_id, kind) if hasattr(db, "get_published_message_ids") else [] - if not ids: - await callback.answer("لا توجد رسائل محفوظة من هذا النوع.", show_alert=True) - await post_settings_menu(client, callback) - return - - # تحقق فوري من صلاحية الحذف قبل التنفيذ - if not await _verify_channel_before_publish(ch_id): - await safe_edit(callback, f"⛔ **فشل التحقق من القناة**\n\nالبوت الرئيسي لا يمكنه الوصول إلى القناة `{ch_id}`.\nتأكد من أن البوت مشرف في القناة.", InlineKeyboardMarkup([nav_row(f"ch_{ch_id}")])) - return - - deleted_ids, failed_ids = await _try_delete_with_bots(ch_id, ids) - - if hasattr(db, "clear_published_message_ids"): - db.clear_published_message_ids(ch_id, kind, None if not failed_ids else deleted_ids) - - label = CONTENT_TYPE_LABELS.get(kind, kind) - deleted = len(deleted_ids) - failed = len(failed_ids) - await notify_admins(f"تم حذف {label} من {channel_display_name(ch_id)}\n✅ المحذوف: {deleted}\n❌ فشل: {failed}") - await safe_edit(callback, f"🧹 **نتيجة حذف {label}:**\n\n✅ تم حذف: {deleted}\n❌ فشل: {failed}\n\n💡 البوت لا يمكنه حذف رسائل أقدم من 48 ساعة (حد Telegram).", InlineKeyboardMarkup([ - [InlineKeyboardButton("🔙 رجوع لحذف المنشورات", callback_data=f"purgepage_{ch_id}"), InlineKeyboardButton("🏠 القائمة الرئيسية", callback_data="main_menu")] - ])) - - -def channel_buttons(ch_id): - buttons = [ - InlineKeyboardButton("📰 إعدادات المنشورات", callback_data=f"postset_{ch_id}"), - InlineKeyboardButton("📡 المصادر", callback_data=f"srcset_{ch_id}"), - InlineKeyboardButton("⚙️ الإعدادات العامة", callback_data=f"genset_{ch_id}"), - ] - rows = grid_buttons(buttons, 2) - rows.append([InlineKeyboardButton("🔙 رجوع", callback_data="menu_channels"), InlineKeyboardButton("🏠 القائمة الرئيسية", callback_data="main_menu")]) - return InlineKeyboardMarkup(rows) - - -# ============================================================ -# 📝 إعدادات المنشورات -# ============================================================ - -async def post_settings_menu(client, callback): - ch_id = callback.data.split("_", 1)[1] - ch = db.get_channel(ch_id) - if not ch: - await callback.answer("غير موجودة.") - return - bold = ch.get("bold_publish", True) - quote_types = get_channel_quote_types_safe(ch_id) - cfg = db.get_channel_config(ch_id) if hasattr(db, "get_channel_config") else {} - quote_count = sum(1 for v in quote_types.values() if v) + int(bool(cfg.get("title_quote"))) + int(bool(cfg.get("signature_quote"))) - disable_preview = db.get_channel_disable_preview(ch_id) if hasattr(db, "get_channel_disable_preview") else False - ignore_short = is_ignore_short_posts_enabled_for_channel(ch_id) - buttons = [ - InlineKeyboardButton(f"{'✅' if bold else '❌'} الخط السميك", callback_data=f"togglebold_{ch_id}"), - InlineKeyboardButton( - f"📝 النصوص القصيرة: {'✅ تشغيل' if ignore_short else '❌ إيقاف'}", - callback_data=f"toggle_short_posts|{ch_id}", - ), - InlineKeyboardButton(f"✍️ التوقيع", callback_data=f"tails_{ch_id}"), - InlineKeyboardButton(f"💬 الاقتباس ({quote_count}/6)", callback_data=f"quotemenu_{ch_id}"), - InlineKeyboardButton(f"#️⃣ الهاشتاكات", callback_data=f"hashtags_{ch_id}"), - InlineKeyboardButton(f"⏱ سرعة النشر", callback_data=f"speedmenu_{ch_id}"), - InlineKeyboardButton(f"🗑 حذف المنشورات", callback_data=f"purgepage_{ch_id}"), - InlineKeyboardButton(f"🚫 الكلمات المحظورة", callback_data=f"chwords|{ch_id}"), - InlineKeyboardButton(f"✂️ الكلمات المحذوفة", callback_data=f"chdelterms|{ch_id}"), - InlineKeyboardButton(f"🔗 إدارة الروابط", callback_data=f"chlinks|{ch_id}"), - InlineKeyboardButton(f"{'✅' if not disable_preview else '❌'} معاينة الروابط", callback_data=f"ch_preview_{ch_id}"), - InlineKeyboardButton("🧪 اختبار القناة", callback_data=f"testset_{ch_id}"), - ] - rows = grid_buttons(buttons, 2) - rows.append(nav_row(f"ch_{ch_id}")) - await safe_edit(callback, f"📰 **إعدادات المنشورات**\n{entity_name(ch)}\n📝 النصوص القصيرة: " + ("✅ الحظر مفعّل" if ignore_short else "❌ متوقف") + "\n💬 الاقتباسات: {quote_count}/6 أنواع مفعّلة\n" + "🔗 معاينة الروابط: " + ("✅ مفعّلة" if not disable_preview else "❌ معطّلة") + "\n" + "📌 الخط السميك: " + ("✅ مفعّل" if bold else "❌ معطّل") + "\n", InlineKeyboardMarkup(rows)) - - -async def purge_published_menu(client, callback): - ch_id = callback.data.split("_", 1)[1] - ch = db.get_channel(ch_id) - if not ch: - await callback.answer("غير موجودة.") - return - buttons = [ - InlineKeyboardButton("🧹 حذف النصوص", callback_data=f"purgepub|{ch_id}|text"), - InlineKeyboardButton("🧹 حذف الصور", callback_data=f"purgepub|{ch_id}|photo"), - InlineKeyboardButton("🧹 حذف الفيديوهات", callback_data=f"purgepub|{ch_id}|video"), - InlineKeyboardButton("🧹 حذف الألبومات", callback_data=f"purgepub|{ch_id}|album"), - ] - rows = grid_buttons(buttons, 2) - rows.append(nav_row(f"postset_{ch_id}")) - await safe_edit(callback, f"🗑 **حذف المنشورات**\n{channel_display_name(ch_id)}\n\nاختر نوع المنشورات المسجلة لحذفها من القناة:\n\n🧹 حذف النصوص/الصور/الفيديوهات/الألبومات: يحذف الرسائل المسجلة من هذا النوع في هذه القناة فقط.", InlineKeyboardMarkup(rows)) - - -# ============================================================ -# 📚 إعدادات المصادر -# ============================================================ - -async def source_settings_channel_menu(client, callback): - ch_id = callback.data.split("_", 1)[1] - ch = db.get_channel(ch_id) - if not ch: - await callback.answer("غير موجودة.") - return - sources = ch.get("special_sources", []) - buttons = [ - InlineKeyboardButton(f"📢 عرض المصادر ({len(sources)})", callback_data=f"specsrc_{ch_id}"), - InlineKeyboardButton("➕ إضافة مصدر", callback_data=f"addspecsrc_{ch_id}"), - InlineKeyboardButton("➖ حذف مصدر", callback_data=f"delspecsrc_{ch_id}"), - InlineKeyboardButton("📋 جميع المصادر", callback_data="menu_public_src"), - ] - rows = grid_buttons(buttons, 2) - rows.append(nav_row(f"ch_{ch_id}")) - text = ( - f"📚 **إعدادات المصادر**\n{entity_name(ch)}\n\n" - f"عدد المصادر المخصصة: {len(sources)}\n\n📢 عرض المصادر: يعرض قائمة المصادر المخصصة.\n➕ إضافة/➖ حذف: إدارة المصادر المخصصة." - ) - await safe_edit(callback, text, InlineKeyboardMarkup(rows)) - - -# ============================================================ -# 🔖 إعدادات التوقيع -# ============================================================ - -async def tail_settings_menu(client, callback): - ch_id = callback.data.split("_", 1)[1] - ch = db.get_channel(ch_id) - if not ch: - await callback.answer("غير موجودة.") - return - tail_enabled = db.get_channel_tail_enabled(ch_id) if hasattr(db, "get_channel_tail_enabled") else True - tail_min = db.get_channel_tail_min_words(ch_id) if hasattr(db, "get_channel_tail_min_words") else 20 - tail_pos = db.get_channel_tail_position(ch_id) if hasattr(db, "get_channel_tail_position") else "bottom" - current_tail = ch.get("tail", "") - min_active = tail_min >= 20 - pos_label = "⬆️ أعلى" if tail_pos == "top" else "⬇️ أسفل" - text = ( - f"🔖 **إعدادات التوقيع**\n{entity_name(ch)}\n\n🔖 التوقيع نص يُضاف لنهاية (أو بداية) المنشورات في هذه القناة.\n" - f"{'✅' if tail_enabled else '❌'} التوقيع" - + (f":\n`{current_tail}`" if current_tail and tail_enabled else "") - + ("\n\n📊 شرط {0} كلمة: {1}".format(tail_min, "✅ مفعل" if min_active else "❌ متوقف")) - + f"\n📍 المكان: {pos_label}" - ) - buttons = [ - InlineKeyboardButton(f"{'✅' if tail_enabled else '❌'} تشغيل التوقيع", callback_data=f"tailtoggle_{ch_id}"), - InlineKeyboardButton(f"✏️ تعديل النص", callback_data=f"edittail_{ch_id}"), - InlineKeyboardButton(f"{'✅' if min_active else '❌'} شرط {tail_min} كلمة", callback_data=f"tailmintoggle_{ch_id}"), - InlineKeyboardButton(f"{'⬆️' if tail_pos=='top' else '⬇️'} المكان: {pos_label}", callback_data=f"tailpos_{ch_id}"), - InlineKeyboardButton("🗑 حذف التوقيع", callback_data=f"deltail_{ch_id}"), - ] - rows = grid_buttons(buttons, 2) - rows.append(nav_row(f"postset_{ch_id}")) - await safe_edit(callback, text, InlineKeyboardMarkup(rows)) - - -async def tail_toggle_handler(client, callback): - ch_id = callback.data.split("_", 1)[1] - cur = db.get_channel_tail_enabled(ch_id) if hasattr(db, "get_channel_tail_enabled") else True - if hasattr(db, "set_channel_tail_enabled"): - db.set_channel_tail_enabled(ch_id, not cur) - await callback.answer("تم التحديث.") - callback.data = f"tails_{ch_id}" - await tail_settings_menu(client, callback) - - -async def tail_min_toggle_handler(client, callback): - ch_id = callback.data.split("_", 1)[1] - cur = db.get_channel_tail_min_words(ch_id) if hasattr(db, "get_channel_tail_min_words") else 20 - new_val = 0 if cur >= 20 else 20 - if hasattr(db, "set_channel_tail_min_words"): - db.set_channel_tail_min_words(ch_id, new_val) - await callback.answer(f"تم التحديث إلى {'20' if new_val else 'بدون'} كلمة.") - callback.data = f"tails_{ch_id}" - await tail_settings_menu(client, callback) - - -async def tail_position_toggle_handler(client, callback): - ch_id = callback.data.split("_", 1)[1] - cur = db.get_channel_tail_position(ch_id) if hasattr(db, "get_channel_tail_position") else "bottom" - new_pos = "top" if cur == "bottom" else "bottom" - if hasattr(db, "set_channel_tail_position"): - db.set_channel_tail_position(ch_id, new_pos) - await callback.answer(f"تم تغيير المكان إلى {'أعلى' if new_pos == 'top' else 'أسفل'}.") - callback.data = f"tails_{ch_id}" - await tail_settings_menu(client, callback) - - -# ============================================================ -# 🧪 إعدادات الاختبار -# ============================================================ - -async def test_settings_menu(client, callback): - ch_id = callback.data.split("_", 1)[1] - ch = db.get_channel(ch_id) - if not ch: - await callback.answer("غير موجودة.") - return - buttons = [ - InlineKeyboardButton("🧪 اختبار نشر", callback_data=f"testpub_{ch_id}"), - InlineKeyboardButton("👁 معاينة المنشور", callback_data=f"preview_{ch_id}"), - ] - rows = grid_buttons(buttons, 2) - rows.append(nav_row(f"postset_{ch_id}")) - text = f"🧪 **إعدادات الاختبار**\n{entity_name(ch)}\n\n🧪 اختبار نشر: يرسل رسالة اختبار حقيقية للقناة.\n👁 معاينة المنشور: يعرض كيف سيبدو المنشور مع الإعدادات الحالية." - await safe_edit(callback, text, InlineKeyboardMarkup(rows)) - - -async def preview_post(client, callback): - ch_id = callback.data.split("_", 1)[1] - ch = db.get_channel(ch_id) - if not ch: - await callback.answer("غير موجودة.") - return - await callback.answer("جاري تحضير المعاينة...") - try: - sources = ch.get("special_sources", []) - if not sources: - srcs = db.get_public_sources() - if srcs: - sources = [srcs[0].get("id") if isinstance(srcs[0], dict) else srcs[0]] - if not sources: - await safe_edit(callback, "❌ لا توجد مصادر لهذه القناة.\nأضف مصدراً أولاً.", InlineKeyboardMarkup([nav_row(f"testset_{ch_id}")])) - return - preview_text = f"""📄 معاينة المنشور لقناة {entity_name(ch)} - -━━━━━━━━━━━━━━━━ -نص تجريبي لتطبيق الإعدادات على المنشور قبل النشر. - -يتم تطبيق جميع إعدادات القناة الحالية على هذا النص. -━━━━━━━━━━━━━━━━""" - - bold = ch.get("bold_publish", True) - tail = ch.get("tail", "") - tail_enabled = db.get_channel_tail_enabled(ch_id) if hasattr(db, "get_channel_tail_enabled") else True - tail_min = db.get_channel_tail_min_words(ch_id) if hasattr(db, "get_channel_tail_min_words") else 20 - tail_pos = db.get_channel_tail_position(ch_id) if hasattr(db, "get_channel_tail_position") else "bottom" - tags = db.get_channel_hashtags(ch_id) if hasattr(db, "get_channel_hashtags") else [] - delay = ch.get("publish_delay") - - word_count = len(preview_text.split()) - tail_applied = tail_enabled and bool(tail) and (tail_min == 0 or word_count >= tail_min) - - report_lines = ["📄 **تقرير المعالجة**\n"] - report_lines.append(f"{'✅' if bold else '❌'} تم {'تطبيق' if bold else 'إلغاء'} الخط السميك") - if tail_applied: - report_lines.append(f"✅ تم إضافة التوقيع") - report_lines.append(f"📍 مكان التوقيع:\n{'⬆️ أعلى' if tail_pos == 'top' else '⬇️ أسفل'} المنشور") - else: - if not tail_enabled: - report_lines.append(f"❌ التوقيع متوقف") - elif not tail: - report_lines.append(f"❌ لا يوجد نص توقيع") - else: - report_lines.append(f"❌ تم تجاهل التوقيع (أقل من {tail_min} كلمة)") - report_lines.append(f"📊 عدد الكلمات: {word_count}") - report_lines.append(f"🏷 عدد الهاشتاكات: {len(tags)}") - if delay: - report_lines.append(f"⏱ تأخير النشر: {delay} ثانية") - else: - report_lines.append(f"⏱ سرعة النشر: افتراضي") - - text = "\n".join(report_lines) - buttons = [ - nav_row(f"testset_{ch_id}"), - ] - await safe_edit(callback, text, InlineKeyboardMarkup(buttons)) - except Exception as e: - logger.error(f"خطأ في المعاينة: {e}") - await safe_edit(callback, f"❌ فشلت المعاينة:\n{e}", InlineKeyboardMarkup([nav_row(f"testset_{ch_id}")])) - - -async def toggle_channel_bold(client, callback): - ch_id = callback.data.split("_", 1)[1] - ch = db.get_channel(ch_id) - if not ch: - await callback.answer("القناة غير موجودة.") - return - current = bool(ch.get("bold_publish", True)) - if hasattr(db, "set_channel_bold_publish"): - db.set_channel_bold_publish(ch_id, not current) - else: - db.update_channel(ch_id, "bold_publish", not current) - await callback.answer("تم تحديث خيار الخط السميك.") - callback.data = f"postset_{ch_id}" - await post_settings_menu(client, callback) - -async def menu_channels(client, callback): - channels = db.get_all_channels() - - if not channels: - text = "لا توجد قنوات منشورة بعد. اضغط ➕ لإضافتها." - rows = [ - [InlineKeyboardButton("➕ إضافة قناة/قنوات", callback_data="add_channel")], - nav_row("main_menu") - ] - else: - text = f"**قنوات النشر ({len(channels)}):**\n⚙️ اسم القناة: يفتح إعدادات القناة.\n➕ إضافة قناة/قنوات: يسجل قنوات نشر جديدة (رابط/يوزر/ID)." - btns = [] - for ch in channels: - paused = " ⏸️" if ch.get('paused') else "" - name = entity_name(ch) - username = ch.get("username") or "" - username_txt = f"@{username}" if username else "لا يوجد يوزر" - text += f"\n✅ {name}{paused}\n {username_txt}\n ID: `{ch['id']}`\n" - btns.append(InlineKeyboardButton(f"⚙️ {name}", callback_data=f"ch_{ch['id']}")) - rows = grid_buttons(btns, 2 if len(btns) <= 8 else 3) - rows.append([InlineKeyboardButton("➕ إضافة قناة/قنوات", callback_data="add_channel")]) - rows.append(nav_row("main_menu")) - await safe_edit(callback, text, InlineKeyboardMarkup(rows)) - -async def show_channels_menu_from_message(message): - channels = db.get_all_channels() - if not channels: - text = "لا توجد قنوات منشورة بعد. اضغط ➕ لإضافتها." - rows = [ - [InlineKeyboardButton("➕ إضافة قناة/قنوات", callback_data="add_channel")], - nav_row("main_menu") - ] - else: - text = f"**قنوات النشر ({len(channels)}):**\n⚙️ اسم القناة: يفتح إعدادات القناة.\n➕ إضافة قناة/قنوات: يسجل قنوات نشر جديدة (رابط/يوزر/ID)." - btns = [] - for ch in channels: - paused = " ⏸️" if ch.get('paused') else "" - name = entity_name(ch) - username = ch.get("username") or "" - username_txt = f"@{username}" if username else "لا يوجد يوزر" - text += f"\n✅ {name}{paused}\n {username_txt}\n ID: `{ch['id']}`\n" - btns.append(InlineKeyboardButton(f"⚙️ {name}", callback_data=f"ch_{ch['id']}")) - rows = grid_buttons(btns, 2 if len(btns) <= 8 else 3) - rows.append([InlineKeyboardButton("➕ إضافة قناة/قنوات", callback_data="add_channel")]) - rows.append(nav_row("main_menu")) - await message.reply(text, reply_markup=InlineKeyboardMarkup(rows)) - -async def channel_settings(client, callback): - ch_id = callback.data.split("_", 1)[1] - ch = db.get_channel(ch_id) - if not ch: - await callback.answer("غير موجودة.") - return - name = entity_name(ch) - username = ch.get("username") or "" - username_txt = f"@{username}" if username else "لا يوجد يوزر" - if hasattr(db, "mapper"): - assigned_bots = db.mapper.get_bots_for_channel(ch_id) - else: - cfg = db.get_channel_config(ch_id) if hasattr(db, "get_channel_config") else {} - assigned_bots = cfg.get("assigned_bots", []) - bot_count = len(assigned_bots) - verifications = verifier.get_cached_verifications_for_channel(ch_id) - ok_count = sum(1 for v in verifications.values() if v.get("verified") and v.get("can_post")) - ch_sources = ch.get("special_sources", []) - src_count = len(ch_sources) - if ch.get("bot_admin") is None: - bot_line = "🤖 ❓ لم يُفحص بعد" - else: - bot_line = "🤖 ✅ البوت مشرف" if ch.get("bot_admin") else "🤖 ❌ البوت ليس مشرفاً" - text = ( - f"📢 **{name}**\n\n" - f"👤 {username_txt}\n" - f"🆔 `{ch_id}`\n" - f"{'⏸️ موقوفة' if ch.get('paused') else '✅ شغالة'}" - f" | 📄 {ch.get('posts_count', 0)}" - f" | ⚠️ {ch.get('fail_count', 0)}\n" - f"{bot_line}\n" - f"🤖 {bot_count} بوت ({ok_count} جاهز)" - f" | 📚 {src_count} مصدر" - ) - await safe_edit(callback, text, channel_buttons(ch_id)) - -async def add_channel_prompt(client, callback): - user_states.pop(callback.from_user.id, None) - user_states[callback.from_user.id] = {"state": "waiting_channel_id"} - await safe_edit(callback, "أرسل قناة واحدة أو عدة قنوات نشر.\nكل رابط/يوزر/ID بسطر مستقل:") - -async def toggle_pause(client, callback): - ch_id = callback.data.split("_", 1)[1] - ch = db.get_channel(ch_id) - - if not ch: - await callback.answer("غير موجودة.") - return - - db.update_channel(ch_id, "paused", not ch.get("paused")) - await callback.answer("تم التحديث.") - await channel_settings(client, callback) - -async def delete_channel_prompt(client, callback): - channels = db.get_all_channels() - if not channels: - await callback.answer("لا توجد قنوات.") - return - ch_id = callback.data.split("_", 1)[1] - buttons = [] - for ch in channels: - name = entity_name(ch) - buttons.append([InlineKeyboardButton(f"🗑 حذف {name}", callback_data=f"confirm_del_{ch['id']}")]) - buttons.append(nav_row(f"genset_{ch_id}")) - await safe_edit(callback, "اختر القناة التي تريد حذفها:", InlineKeyboardMarkup(buttons)) - -async def confirm_delete_channel(client, callback): - ch_id = callback.data.split("_", 2)[2] - db.delete_channel(ch_id) - await callback.answer("تم الحذف.") - await menu_channels(client, callback) - -async def tail_menu(client, callback): - ch_id = callback.data.split("_", 1)[1] - ch = db.get_channel(ch_id) - - if not ch: - await callback.answer("غير موجودة.") - return - - current = ch.get("tail", "") - text = (f"التوقيع الحالي:\n`{current}`" if current else "لا يوجد توقيع.") + "\n\n✏️ تعديل/إضافة: يحدّث نص التوقيع.\n🗑 حذف التوقيع: يمسح التوقيع الحالي." - - buttons = [ - [InlineKeyboardButton("✏️ تعديل/إضافة", callback_data=f"edittail_{ch_id}")], - [InlineKeyboardButton("🗑 حذف التوقيع", callback_data=f"deltail_{ch_id}")], - nav_row(f"tails_{ch_id}") - ] - await safe_edit(callback, text, InlineKeyboardMarkup(buttons)) - -async def edit_tail_prompt(client, callback): - ch_id = callback.data.split("_", 1)[1] - user_states.pop(callback.from_user.id, None) - user_states[callback.from_user.id] = {"state": "waiting_tail", "ch_id": ch_id} - await safe_edit(callback, "أرسل نص التوقيع الجديد:", InlineKeyboardMarkup([nav_row(f"tails_{ch_id}")])) - -async def delete_tail(client, callback): - ch_id = callback.data.split("_", 1)[1] - db.update_channel(ch_id, "tail", "") - await callback.answer("تم حذف التوقيع.") - await tail_menu(client, callback) - -async def menu_public_sources(client, callback): - srcs = db.get_public_sources() - meta_list = db.get_all_public_sources_with_meta() if hasattr(db, "get_all_public_sources_with_meta") else [{"id": s, "name": str(s)} for s in srcs] - text = f"**المصادر العامة ({len(srcs)}):**\n➕ إضافة مصدر/مصادر: يضيف قنوات عامة جديدة يراقبها البوت (رابط/يوزر/ID).\n🗑 حذف مصدر عام: يحذف مصدراً عاماً من المراقبة." - text += "\n\n".join(entity_details_line(m, is_source=True) for m in meta_list) if srcs else "لا توجد." - btns = [] - for meta in meta_list: - sid = meta.get("id") - paused = " ⏸️" if is_source_paused(sid) else "" - btns.append(InlineKeyboardButton(f"⚙️ {entity_name(meta)}{paused}", callback_data=f"srcset|{sid}|menu_public_src")) - rows = grid_buttons(btns, 2 if len(btns) <= 8 else 3) - rows.append([InlineKeyboardButton("➕ إضافة مصدر/مصادر", callback_data="add_public_src"), InlineKeyboardButton("🗑 حذف مصدر عام", callback_data="del_public_src")]) - rows.append(nav_row("main_menu")) - await safe_edit(callback, text, InlineKeyboardMarkup(rows)) - -async def add_public_src_prompt(client, callback): - user_states.pop(callback.from_user.id, None) - user_states[callback.from_user.id] = {"state": "waiting_public_source"} - await safe_edit(callback, "أرسل مصدر واحد أو عدة مصادر عامة.\nكل رابط/يوزر/ID بسطر مستقل:", InlineKeyboardMarkup([nav_row("menu_public_src")])) - -async def del_public_src_prompt(client, callback): - srcs = db.get_public_sources() - if not srcs: - await callback.answer("لا توجد مصادر.") - return - buttons = [] - for idx, src in enumerate(srcs): - name = entity_name(src, is_source=True) - buttons.append([InlineKeyboardButton(f"🗑 حذف {name}", callback_data=f"del_pub_idx_{idx}")]) - buttons.append(nav_row("menu_public_src")) - await safe_edit(callback, "اختر المصدر للحذف:", InlineKeyboardMarkup(buttons)) - -async def confirm_del_public_src(client, callback): - idx = int(callback.data.split("_")[-1]) - srcs = db.get_public_sources() - - if idx < 0 or idx >= len(srcs): - await callback.answer("المصدر غير موجود.") - return - - db.remove_public_source(srcs[idx]) - await callback.answer("تم الحذف.") - await menu_public_sources(client, callback) - -async def menu_blocked_words(client, callback): - words = db.get_blocked_words() - - text = "**الكلمات المحظورة:**\n" - text += (", ".join(words) if words else "لا توجد.") + "\n➕ إضافة كلمة: يمنع نشر أي منشور يحتويها.\n🗑 حذف كلمة: يزيل كلمة من القائمة." - - buttons = [ - [InlineKeyboardButton("➕ إضافة كلمة", callback_data="add_blocked_word")], - [InlineKeyboardButton("🗑 حذف كلمة", callback_data="del_blocked_word")], - nav_row("main_menu") - ] - - await safe_edit(callback, text, InlineKeyboardMarkup(buttons)) - -async def add_blocked_word_prompt(client, callback): - await callback.answer() - logger.info(f"add_blocked_word_prompt: user {callback.from_user.id}") - user_states.pop(callback.from_user.id, None) - user_states[callback.from_user.id] = {"state": "waiting_blocked_word"} - await safe_edit(callback, "أرسل كلمة أو عدة كلمات محظورة.\nكل كلمة/عبارة بسطر مستقل:", InlineKeyboardMarkup([nav_row("menu_blocked")])) - -async def del_blocked_word_prompt(client, callback): - await callback.answer() - logger.info(f"del_blocked_word_prompt: user {callback.from_user.id}") - words = db.get_blocked_words() - - if not words: - await callback.answer("لا توجد كلمات محظورة.") - return - - buttons = [] - - for idx, word in enumerate(words): - buttons.append([InlineKeyboardButton(f"حذف {word}", callback_data=f"delword_idx_{idx}")]) - - buttons.append(nav_row("menu_blocked")) - await safe_edit(callback, "اختر الكلمة:", InlineKeyboardMarkup(buttons)) - -async def confirm_del_blocked_word(client, callback): - idx = int(callback.data.split("_")[-1]) - words = db.get_blocked_words() - - if idx < 0 or idx >= len(words): - await callback.answer("الكلمة غير موجودة.") - return - - db.remove_blocked_word(words[idx]) - await callback.answer("تم الحذف.") - await menu_blocked_words(client, callback) - - -# ============================================================ -# 🚫 الكلمات المحظورة — خاصة بكل قناة على حدة -# ============================================================ - -async def channel_blocked_words_menu(client, callback): - ch_id = callback.data.split("|", 1)[1] - ch = db.get_channel(ch_id) - if not ch: - await callback.answer("القناة غير موجودة.") - return - words = db.get_channel_blocked_words(ch_id) - text = f"🚫 **الكلمات المحظورة — {entity_name(ch)}**\n\n" - text += "أي منشور يحتوي إحدى هذي الكلمات لن يُنشر بهذه القناة فقط (باقي القنوات غير متأثرة).\n\n" - text += (", ".join(words) if words else "لا توجد كلمات محظورة لهذه القناة.") + "\n\n➕ إضافة كلمة / 🗑 حذف كلمة: إدارة الكلمات المحظورة لهذه القناة فقط." - buttons = [ - [InlineKeyboardButton("➕ إضافة كلمة", callback_data=f"chwordadd|{ch_id}")], - [InlineKeyboardButton("🗑 حذف كلمة", callback_data=f"chworddel|{ch_id}")], - nav_row(f"postset_{ch_id}"), - ] - await safe_edit(callback, text, InlineKeyboardMarkup(buttons)) - - -async def channel_add_blocked_word_prompt(client, callback): - await callback.answer() - logger.info(f"channel_add_blocked_word_prompt: user {callback.from_user.id}") - ch_id = callback.data.split("|", 1)[1] - if not db.get_channel(ch_id): - await callback.answer("القناة غير موجودة.") - return - user_states.pop(callback.from_user.id, None) - user_states[callback.from_user.id] = {"state": "waiting_channel_blocked_word", "ch_id": ch_id} - await safe_edit(callback, "أرسل كلمة أو عدة كلمات محظورة لهذه القناة فقط.\nكل كلمة/عبارة بسطر مستقل:", InlineKeyboardMarkup([nav_row(f"chwords|{ch_id}")])) - - -async def channel_del_blocked_word_prompt(client, callback): - ch_id = callback.data.split("|", 1)[1] - words = db.get_channel_blocked_words(ch_id) - if not words: - await callback.answer("لا توجد كلمات محظورة لهذه القناة.") - return - buttons = [] - for idx, word in enumerate(words): - buttons.append([InlineKeyboardButton(f"حذف {word}", callback_data=f"chworddelidx|{ch_id}|{idx}")]) - buttons.append(nav_row(f"chwords|{ch_id}")) - await safe_edit(callback, "اختر الكلمة:", InlineKeyboardMarkup(buttons)) - - -async def confirm_channel_del_blocked_word(client, callback): - try: - _, ch_id, idx_str = callback.data.split("|", 2) - except ValueError: - await callback.answer("طلب غير صالح.") - return - words = db.get_channel_blocked_words(ch_id) - try: - idx = int(idx_str) - except ValueError: - await callback.answer("خطأ في البيانات.") - return - if idx < 0 or idx >= len(words): - await callback.answer("الكلمة غير موجودة.") - return - db.remove_channel_blocked_word(ch_id, words[idx]) - await callback.answer("تم الحذف.") - callback.data = f"chwords|{ch_id}" - await channel_blocked_words_menu(client, callback) - - -# ============================================================ -# 🔗 إدارة الروابط — خاصة بكل قناة على حدة -# ============================================================ - -CHANNEL_LINK_FILTER_KEYS = { - "chlinktg|": "link_remove_tg", - "chlinktguser|": "link_remove_tg_user", - "chlinkweb|": "link_remove_web", -} - - -async def channel_links_menu(client, callback): - ch_id = callback.data.split("|", 1)[1] - ch = db.get_channel(ch_id) - if not ch: - await callback.answer("القناة غير موجودة.") - return - link_tg = bool(ch.get("link_remove_tg")) - link_tg_user = bool(ch.get("link_remove_tg_user")) - link_web = bool(ch.get("link_remove_web")) - text = ( - f"🔗 **إدارة الروابط — {entity_name(ch)}**\n\n" - "التحكم بروابط المنشورات المُرسلة لهذه القناة فقط (باقي القنوات غير متأثرة).\n\n" - f"{'✅' if link_tg else '❌'} حذف روابط تيليجرام (t.me)\n" - f"{'✅' if link_tg_user else '❌'} حذف يوزرات تيليجرام (@username)\n" - f"{'✅' if link_web else '❌'} حذف روابط المواقع (ما عدا تيليجرام)\n\nاضغط على الزر لتفعيل/إيقاف حذف نوع الروابط في منشورات هذه القناة فقط." - ) - buttons = [ - [InlineKeyboardButton(f"{'✅' if link_tg else '❌'} حذف روابط تيليجرام", callback_data=f"chlinktg|{ch_id}")], - [InlineKeyboardButton(f"{'✅' if link_tg_user else '❌'} حذف يوزرات @", callback_data=f"chlinktguser|{ch_id}")], - [InlineKeyboardButton(f"{'✅' if link_web else '❌'} حذف روابط المواقع", callback_data=f"chlinkweb|{ch_id}")], - nav_row(f"postset_{ch_id}"), - ] - await safe_edit(callback, text, InlineKeyboardMarkup(buttons)) - - -async def toggle_channel_link_filter(client, callback): - prefix, ch_id = callback.data.split("|", 1) - key = CHANNEL_LINK_FILTER_KEYS.get(prefix + "|") - if not key: - await callback.answer("أمر غير معروف.") - return - ch = db.get_channel(ch_id) - if not ch: - await callback.answer("القناة غير موجودة.") - return - db.update_channel(ch_id, key, not bool(ch.get(key))) - await callback.answer("تم التحديث.") - callback.data = f"chlinks|{ch_id}" - await channel_links_menu(client, callback) - - -# ============================================================ -# 🤖 التحقق من البوت — فحص حقيقي لحالة البوت داخل القناة -# ============================================================ - -def format_bot_check_result(status_value, privileges=None, error=""): - """يبني نص نتيجة الفحص الحقيقي لحالة البوت داخل القناة.""" - if error: - return f"❌ تعذر الوصول إلى القناة.\nالسبب الحقيقي: {error}" - status_value = status_value or "" - if is_bot_admin_status(status_value): - perm_lines = "" - if privileges: - perm_lines = "\n".join( - f"• {label}: {'✅' if bool(getattr(privileges, key, True)) else '❌'}" - for label, key in ( - ("نشر الرسائل", "can_post_messages"), - ("تعديل الرسائل", "can_edit_messages"), - ("حذف الرسائل", "can_delete_messages"), - ) - ) - text = f"✅ البوت مشرف في القناة\nالحالة: `{status_value}`" - if perm_lines: - text += f"\nالصلاحيات:\n{perm_lines}" - return text - if status_value in ("member", "restricted"): - return f"❌ البوت ليس مشرفاً في القناة\nالحالة الفعلية: `{status_value}`" - if status_value in ("left",): - return f"❌ البوت غير موجود داخل القناة.\nالحالة الفعلية: `{status_value}`" - if status_value in ("banned", "kicked"): - return f"❌ البوت محظور داخل القناة.\nالحالة الفعلية: `{status_value}`" - return f"❌ البوت ليس مشرفاً في القناة\nالحالة الفعلية: `{status_value}`" - - -async def channel_bot_check(client, callback): - """فحص حقيقي (Real Check) عبر Telegram API بحالة البوت داخل القناة — بدون أي نتيجة محفوظة.""" - ch_id = callback.data.split("|", 1)[1] - ch = db.get_channel(ch_id) - if not ch: - await callback.answer("القناة غير موجودة.") - return - await callback.answer("جاري الفحص الحقيقي...") - bot_status = "unknown" - bot_admin = False - result_text = "" - try: - bot_member = await bot_client.get_chat_member(int(ch_id), (await bot_client.get_me()).id) - bot_status = member_status_value(getattr(bot_member, "status", None)) - bot_admin = is_bot_admin_status(bot_status) - result_text = format_bot_check_result(bot_status, getattr(bot_member, "privileges", None)) - except Exception as e: - result_text = format_bot_check_result("", error=str(e)[:200]) - db.update_channel(ch_id, "bot_admin", bot_admin) - db.update_channel(ch_id, "bot_status", bot_status) - text = ( - f"🤖 **التحقق من البوت — {entity_name(ch)}**\n" - f"🆔 `{ch_id}`\n\n" - f"{result_text}" - ) - buttons = [[InlineKeyboardButton("🔙 رجوع لإعدادات القناة", callback_data=f"ch_{ch_id}")]] - await safe_edit(callback, text, InlineKeyboardMarkup(buttons)) - -async def manage_special_sources(client, callback): - ch_id = callback.data.split("_", 1)[1] - ch = db.get_channel(ch_id) - if not ch: - await callback.answer("غير موجودة.") - return - sources = ch.get("special_sources", []) - text = "**المصادر المخصصة:**\n➕ إضافة: يضيف مصدراً خاصاً لهذه القناة فقط.\n🗑 حذف: يزيل مصدراً خاصاً." - text += "\n".join(f"• {entity_name(s, is_source=True)}\n ID: `{s}`" for s in sources) if sources else "لا توجد." - btns = [] - for s in sources: - paused = " ⏸️" if is_source_paused(s) else "" - btns.append(InlineKeyboardButton(f"⚙️ {entity_name(s, is_source=True)}{paused}", callback_data=f"srcset|{s}|specsrc_{ch_id}")) - rows = grid_buttons(btns, 2 if len(btns) <= 8 else 3) - rows.append([InlineKeyboardButton("➕ إضافة", callback_data=f"addspecsrc_{ch_id}"), InlineKeyboardButton("🗑 حذف", callback_data=f"delspecsrc_{ch_id}")]) - rows.append(nav_row(f"srcset_{ch_id}")) - await safe_edit(callback, text, InlineKeyboardMarkup(rows)) - -async def source_cleanup_menu(client, callback): - parts = callback.data.split("|", 2) - source_id = parts[1] - back = parts[2] if len(parts) > 2 else "menu_public_src" - terms = db.get_source_remove_terms(source_id) if hasattr(db, "get_source_remove_terms") else [] - meta = db.get_source_meta(source_id) if hasattr(db, "get_source_meta") else {"name": str(source_id)} - name = entity_name(meta) - text = f"🧹 **التنظيف الذكي للمصدر:**\n{name}\nID: `{source_id}`\n\n➕ إضافة عناصر جماعياً: تُحذف منشورات المصدر التي تحتوي هذه العناصر.\n🗑 حذف عناصر جماعياً: يزيل عناصر من قائمة التنظيف.\n\n" - text += "العناصر الحالية:\n" + ("\n".join(f"• `{t}`" for t in terms) if terms else "لا توجد عناصر.") - buttons = [ - [InlineKeyboardButton("➕ إضافة عناصر جماعياً", callback_data=f"addclean|{source_id}|{back}")], - [InlineKeyboardButton("🗑 حذف عناصر جماعياً", callback_data=f"delclean|{source_id}|{back}")], - [InlineKeyboardButton("🔙 رجوع", callback_data=f"srcset|{source_id}|{back}")], - [InlineKeyboardButton("🏠 القائمة الرئيسية", callback_data="main_menu")], - ] - await safe_edit(callback, text, InlineKeyboardMarkup(buttons)) - - -async def add_source_cleanup_prompt(client, callback): - _, source_id, back = callback.data.split("|", 2) - user_states.pop(callback.from_user.id, None) - user_states[callback.from_user.id] = {"state": "waiting_source_cleanup_add", "source_id": source_id, "back": back} - await safe_edit(callback, "أرسل عناصر التنظيف الذكي لهذا المصدر.\nكل كلمة/هاشتاك/يوزر بسطر مستقل:", InlineKeyboardMarkup([nav_row(f"srcset|{source_id}|{back}")])) - - -async def del_source_cleanup_prompt(client, callback): - _, source_id, back = callback.data.split("|", 2) - user_states.pop(callback.from_user.id, None) - user_states[callback.from_user.id] = {"state": "waiting_source_cleanup_del", "source_id": source_id, "back": back} - terms = db.get_source_remove_terms(source_id) if hasattr(db, "get_source_remove_terms") else [] - current = "\n".join(f"• {t}" for t in terms) if terms else "لا توجد عناصر حالياً." - await safe_edit(callback, "أرسل العناصر التي تريد حذفها من التنظيف الذكي.\nكل عنصر بسطر مستقل.\n\nالحالي:\n" + current, InlineKeyboardMarkup([nav_row(f"srcset|{source_id}|{back}")])) - - -async def source_settings_menu(client, callback): - _, source_id, back = callback.data.split("|", 2) - meta = db.get_source_meta(source_id) if hasattr(db, "get_source_meta") else {"name": str(source_id)} - name = entity_name(meta) - paused = is_source_paused(source_id) - remove_emoji = db.get_source_remove_emoji(source_id) if hasattr(db, "get_source_remove_emoji") else False - terms = db.get_source_remove_terms(source_id) if hasattr(db, "get_source_remove_terms") else [] - text = ( - f"⚙️ **إعدادات المصدر:**\n{name}\n\n" - f"🆔 ID: `{source_id}`\n" - f"📊 الحالة: {'⏸️ موقوف' if paused else '✅ شغال'}\n" - f"😀 حذف الإيموجي: {'✅ مفعل' if remove_emoji else '❌ متوقف'}\n" - f"📦 أنواع المحتوى:\n{content_types_status_line(source_id)}\n" - f"🧹 عناصر التنظيف الذكي: {len(terms)}\n\n⏯️ تشغيل/إيقاف: يوقف فحص هذا المصدر دون حذفه.\n😀 الإيموجي: حذف الإيموجي من المنشورات.\n📦 المحتوى: اختيار أنواع المحتوى المسموحة.\n🧹 التنظيف: عناصر تحذف المنشورات المحتوية لها.\n📊 الإحصائيات / 📋 السجل / 🧪 اختبار: فحص حالة المصدر.\n📑 نسخ / 📋 لصق الإعدادات: نقل إعدادات مصدر لمصادر أخرى." - ) - btns = [ - InlineKeyboardButton("⏯️ تشغيل/إيقاف", callback_data=f"togglesource|{source_id}|{back}"), - InlineKeyboardButton("😀 الإيموجي", callback_data=f"toggleemoji|{source_id}|{back}"), - InlineKeyboardButton("📦 المحتوى", callback_data=f"contentmenu|{source_id}|{back}"), - InlineKeyboardButton("🧹 التنظيف", callback_data=f"clean_src|{source_id}|{back}"), - InlineKeyboardButton("📊 الإحصائيات", callback_data=f"srcstats|{source_id}|{back}"), - InlineKeyboardButton("📋 السجل", callback_data=f"srclog|{source_id}|{back}"), - InlineKeyboardButton("🧪 اختبار", callback_data=f"testsrc|{source_id}|{back}"), - InlineKeyboardButton("📑 نسخ الإعدادات", callback_data=f"copysrc|{source_id}|{back}"), - InlineKeyboardButton("📋 لصق الإعدادات", callback_data=f"pastesrc|{source_id}|{back}"), - ] - rows = grid_buttons(btns, 2) - rows.append(nav_row(back)) - await safe_edit(callback, text, InlineKeyboardMarkup(rows)) - -async def toggle_source_enabled(client, callback): - _, source_id, back = callback.data.split("|", 2) - new_state = not is_source_paused(source_id) - if hasattr(db, "set_source_paused"): - db.set_source_paused(source_id, new_state) - await callback.answer("تم إيقاف المصدر." if new_state else "تم تشغيل المصدر.") - callback.data = f"srcset|{source_id}|{back}" - await source_settings_menu(client, callback) - - -async def toggle_source_emoji(client, callback): - _, source_id, back = callback.data.split("|", 2) - current = db.get_source_remove_emoji(source_id) if hasattr(db, "get_source_remove_emoji") else False - if hasattr(db, "set_source_remove_emoji"): - db.set_source_remove_emoji(source_id, not current) - await callback.answer("تم التحديث.") - callback.data = f"srcset|{source_id}|{back}" - await source_settings_menu(client, callback) - - -async def source_content_menu(client, callback): - _, source_id, back = callback.data.split("|", 2) - types = db.get_source_content_types(source_id) if hasattr(db, "get_source_content_types") else {"text": True, "photo": True, "video": True, "album": True, "voice": False, "audio": False, "document": False} - labels = {"text": "نصوص", "photo": "صور", "video": "فيديو", "album": "ألبومات", "voice": "بصمات", "audio": "صوتيات", "document": "ملفات"} - meta = db.get_source_meta(source_id) if hasattr(db, "get_source_meta") else {"name": str(source_id)} - text = f"📦 **فلتر أنواع المحتوى:**\n{entity_name(meta)}\n\nاختر نوع أو أكثر. الافتراضي هو الكل.\n\nيحدد أنواع المنشورات التي يستقبلها هذا المصدر (نصوص، صور، فيديو...)." - btns = [] - for key, label in labels.items(): - mark = "✅" if types.get(key, True) else "❌" - btns.append(InlineKeyboardButton(f"{mark} {label}", callback_data=f"togglectype|{source_id}|{key}|{back}")) - rows = grid_buttons(btns, 2) - rows.append(nav_row(f"srcset|{source_id}|{back}")) - await safe_edit(callback, text, InlineKeyboardMarkup(rows)) - -async def toggle_content_type(client, callback): - _, source_id, ctype, back = callback.data.split("|", 3) - types = db.get_source_content_types(source_id) if hasattr(db, "get_source_content_types") else {"text": True, "photo": True, "video": True, "album": True, "voice": False, "audio": False, "document": False} - new_value = not bool(types.get(ctype, True)) - ok = True - if hasattr(db, "set_source_content_type"): - ok = db.set_source_content_type(source_id, ctype, new_value) - if not ok: - await callback.answer("لا يمكن إيقاف كل الأنواع. لازم يبقى نوع واحد على الأقل.", show_alert=True) - else: - await callback.answer("تم التحديث.") - callback.data = f"contentmenu|{source_id}|{back}" - await source_content_menu(client, callback) - - -async def global_cleanup_menu(client, callback): - terms = db.get_global_remove_terms() if hasattr(db, "get_global_remove_terms") else [] - text = "🧹 **قائمة الحذف العامة:**\n\n" - text += "تنطبق على كل المصادر قبل النشر.\n\n➕ إضافة جماعية / 🗑 حذف جماعي: إدارة العناصر المحذوفة من جميع المصادر.\n\n" - text += "العناصر الحالية:\n" + ("\n".join(f"• `{t}`" for t in terms) if terms else "لا توجد عناصر.") - rows = [ - [InlineKeyboardButton("➕ إضافة جماعية", callback_data="addglobalclean"), InlineKeyboardButton("🗑 حذف جماعي", callback_data="delglobalclean")], - nav_row("main_menu") - ] - await safe_edit(callback, text, InlineKeyboardMarkup(rows)) - -async def add_global_cleanup_prompt(client, callback): - user_states.pop(callback.from_user.id, None) - user_states[callback.from_user.id] = {"state": "waiting_global_cleanup_add"} - await safe_edit(callback, "أرسل كلمات/هاشتاكات/أسماء للحذف العام.\nكل عنصر بسطر مستقل:", InlineKeyboardMarkup([nav_row("global_clean")])) - - -async def del_global_cleanup_prompt(client, callback): - user_states.pop(callback.from_user.id, None) - user_states[callback.from_user.id] = {"state": "waiting_global_cleanup_del"} - terms = db.get_global_remove_terms() if hasattr(db, "get_global_remove_terms") else [] - current = "\n".join(f"• {t}" for t in terms) if terms else "لا توجد عناصر حالياً." - await safe_edit(callback, "أرسل العناصر التي تريد حذفها من قائمة الحذف العامة.\nكل عنصر بسطر مستقل.\n\nالحالي:\n" + current, InlineKeyboardMarkup([nav_row("global_clean")])) - - -# ============================================================ -# 🧹 قائمة الكلمات المحذوفة — خاصة بكل قناة على حدة -# ============================================================ - -async def channel_delete_terms_menu(client, callback): - ch_id = callback.data.split("|", 1)[1] - ch = db.get_channel(ch_id) - if not ch: - await callback.answer("القناة غير موجودة.") - return - terms = db.get_channel_delete_terms(ch_id) - text = f"🧹 **قائمة الكلمات المحذوفة — {entity_name(ch)}**\n\n" - text += "تُحذف هذي العناصر من النص قبل نشره بهذه القناة فقط (باقي القنوات غير متأثرة).\n\n" - text += "العناصر الحالية:\n" + ("\n".join(f"• {t}" for t in terms) if terms else "لا توجد عناصر.") + "\n\n➕ إضافة جماعية / 🗑 حذف جماعي: إدارة الكلمات المحذوفة من منشورات هذه القناة فقط." - rows = [ - [InlineKeyboardButton("➕ إضافة جماعية", callback_data=f"chdeltermadd|{ch_id}"), InlineKeyboardButton("🗑 حذف جماعي", callback_data=f"chdeltermdel|{ch_id}")], - nav_row(f"postset_{ch_id}"), - ] - await safe_edit(callback, text, InlineKeyboardMarkup(rows)) - - -async def channel_add_delete_term_prompt(client, callback): - await callback.answer() - logger.info(f"channel_add_delete_term_prompt: user {callback.from_user.id}") - ch_id = callback.data.split("|", 1)[1] - if not db.get_channel(ch_id): - await callback.answer("القناة غير موجودة.") - return - user_states.pop(callback.from_user.id, None) - user_states[callback.from_user.id] = {"state": "waiting_channel_delete_term_add", "ch_id": ch_id} - await safe_edit(callback, "أرسل كلمات/هاشتاكات/أسماء لحذفها من منشورات هذه القناة فقط.\nكل عنصر بسطر مستقل:", InlineKeyboardMarkup([nav_row(f"chdelterms|{ch_id}")])) - - -async def channel_del_delete_term_prompt(client, callback): - await callback.answer() - logger.info(f"channel_del_delete_term_prompt: user {callback.from_user.id}") - ch_id = callback.data.split("|", 1)[1] - if not db.get_channel(ch_id): - await callback.answer("القناة غير موجودة.") - return - user_states.pop(callback.from_user.id, None) - user_states[callback.from_user.id] = {"state": "waiting_channel_delete_term_del", "ch_id": ch_id} - terms = db.get_channel_delete_terms(ch_id) - current = "\n".join(f"• {t}" for t in terms) if terms else "لا توجد عناصر حالياً." - await safe_edit(callback, "أرسل العناصر التي تريد حذفها.\nكل عنصر بسطر مستقل.\n\nالحالي:\n" + current, InlineKeyboardMarkup([nav_row(f"chdelterms|{ch_id}")])) - - - -async def channel_speed_menu(client, callback): - ch_id = callback.data.split("_", 1)[1] - ch = db.get_channel(ch_id) - if not ch: - await callback.answer("القناة غير موجودة.") - return - current = ch.get("publish_delay", None) - text = ( - f"⏱ **سرعة النشر للقناة:**\n{entity_name(ch)}\n\n⏱ الفترة بالثانية بين المنشورات في هذه القناة.\n" - f"الحالية: {'افتراضي' if current in (None, '') else str(current) + ' ثانية'}\n\n" - "اختر قيمة سريعة أو اضغط تخصيص. 0 يعني رجوع للوضع الافتراضي." - ) - buttons = [ - [InlineKeyboardButton("افتراضي", callback_data=f"setspeed|{ch_id}|default")], - [InlineKeyboardButton("1 ثانية", callback_data=f"setspeed|{ch_id}|1"), InlineKeyboardButton("3 ثواني", callback_data=f"setspeed|{ch_id}|3")], - [InlineKeyboardButton("5 ثواني", callback_data=f"setspeed|{ch_id}|5"), InlineKeyboardButton("10 ثواني", callback_data=f"setspeed|{ch_id}|10")], - [InlineKeyboardButton("✏️ تخصيص رقم", callback_data=f"customspeed_{ch_id}")], - [InlineKeyboardButton("🔙 رجوع", callback_data=f"postset_{ch_id}")], - [InlineKeyboardButton("🏠 القائمة الرئيسية", callback_data="main_menu")], - ] - await safe_edit(callback, text, InlineKeyboardMarkup(buttons)) - - -async def set_channel_speed(client, callback): - _, ch_id, value = callback.data.split("|", 2) - if value == "default": - db.set_channel_publish_delay(ch_id, None) if hasattr(db, "set_channel_publish_delay") else None - await callback.answer("تم ضبطها على الافتراضي.") - else: - db.set_channel_publish_delay(ch_id, float(value)) if hasattr(db, "set_channel_publish_delay") else None - await callback.answer(f"تم ضبطها على {value} ثانية.") - callback.data = f"speedmenu_{ch_id}" - await channel_speed_menu(client, callback) - - -async def custom_channel_speed_prompt(client, callback): - ch_id = callback.data.split("_", 1)[1] - user_states.pop(callback.from_user.id, None) - user_states[callback.from_user.id] = {"state": "waiting_channel_speed", "ch_id": ch_id} - await safe_edit(callback, "أرسل عدد الثواني لهذه القناة.\nمثال: 1 أو 2.5 أو 10\nأرسل 0 للرجوع للوضع الافتراضي:", InlineKeyboardMarkup([nav_row(f"speedmenu_{ch_id}")])) - - -def _format_ts(ts): - if not ts: - return "لا يوجد" - try: - return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(int(ts))) - except Exception: - return str(ts) - - -async def source_stats_menu(client, callback): - _, source_id, back = callback.data.split("|", 2) - meta = db.get_source_meta(source_id) if hasattr(db, "get_source_meta") else {"name": str(source_id)} - stats = db.get_source_stats(source_id) if hasattr(db, "get_source_stats") else {} - text = ( - f"📊 **إحصائيات المصدر:**\n{entity_name(meta)}\nID: `{source_id}`\n\n" - f"• المستلمة: {stats.get('received', 0)}\n" - f"• المنشورة: {stats.get('published', 0)}\n" - f"• المرفوضة: {stats.get('rejected', 0)}\n" - f"• المتجاهلة: {stats.get('ignored', 0)}\n" - f"• المكررة: {stats.get('duplicates', 0)}\n" - f"• الأخطاء: {stats.get('errors', 0)}\n" - f"• آخر رسالة: {stats.get('last_message_id') or 'لا يوجد'}\n" - f"• آخر حدث: {stats.get('last_event') or 'لا يوجد'}\n" - f"• السبب: {stats.get('last_reason') or 'لا يوجد'}\n" - f"• الوقت: {_format_ts(stats.get('last_ts'))}" - ) - await safe_edit(callback, text, InlineKeyboardMarkup([ - [InlineKeyboardButton("🔙 رجوع", callback_data=f"srcset|{source_id}|{back}")], - [InlineKeyboardButton("🏠 القائمة الرئيسية", callback_data="main_menu")], - ])) - - -async def source_log_menu(client, callback): - _, source_id, back = callback.data.split("|", 2) - meta = db.get_source_meta(source_id) if hasattr(db, "get_source_meta") else {"name": str(source_id)} - logs = db.get_source_logs(source_id, 20) if hasattr(db, "get_source_logs") else [] - text = f"📋 **سجل المصدر:**\n{entity_name(meta)}\nID: `{source_id}`\n\n" - if not logs: - text += "لا توجد عمليات مسجلة." - else: - rows = [] - for item in reversed(logs[-20:]): - rows.append(f"• {_format_ts(item.get('ts'))} | {item.get('event')} | msg={item.get('message_id')} | {item.get('reason') or ''}") - text += "\n".join(rows[:20]) - await safe_edit(callback, text[:3900], InlineKeyboardMarkup([ - [InlineKeyboardButton("🔙 رجوع", callback_data=f"srcset|{source_id}|{back}")], - [InlineKeyboardButton("🏠 القائمة الرئيسية", callback_data="main_menu")], - ])) - - -async def test_source(client, callback): - _, source_id, back = callback.data.split("|", 2) - source_id_int = int(source_id) - meta = db.get_source_meta(source_id) if hasattr(db, "get_source_meta") else {"name": source_id} - kind = source_kind_label(source_id_int) - targets = sorted(get_targets_for_source(source_id_int)) - last_seen = db.get_last_source_message(source_id_int) if hasattr(db, "get_last_source_message") else None - try: - last_msg = None - async for msg in user_client.get_chat_history(source_id_int, limit=1): - last_msg = msg - break - access = "✅ متاح" - last_id = getattr(last_msg, "id", None) if last_msg else "لا يوجد" - preview = (getattr(last_msg, "text", None) or getattr(last_msg, "caption", None) or "")[:120] if last_msg else "" - except Exception as e: - access = f"❌ فشل: {e}" - last_id = "غير معروف" - preview = "" - text = ( - f"🧪 **اختبار المصدر:**\n{entity_name(meta)}\nID: `{source_id}`\n\n" - f"• الوصول: {access}\n" - f"• النوع: {kind}\n" - f"• آخر ID محفوظ: {last_seen or 'لا يوجد'}\n" - f"• آخر رسالة بالقناة: {last_id}\n" - f"• القنوات المستهدفة: {targets if targets else 'لا توجد'}\n" - f"• معاينة: {preview or 'لا يوجد'}" - ) - await safe_edit(callback, text, InlineKeyboardMarkup([ - [InlineKeyboardButton("🔙 رجوع", callback_data=f"srcset|{source_id}|{back}")], - [InlineKeyboardButton("🏠 القائمة الرئيسية", callback_data="main_menu")], - ])) - - -async def copy_source_settings_prompt(client, callback): - _, source_id, back = callback.data.split("|", 2) - user_states[callback.from_user.id] = {"state": "source_settings_copied", "source_id": source_id, "back": back} - meta = db.get_source_meta(source_id) if hasattr(db, "get_source_meta") else {"name": str(source_id)} - text = ( - f"✅ تم نسخ إعدادات المصدر:\n{entity_name(meta)}\n\n" - "الآن يمكنك:\n" - "• الذهاب لأي مصدر آخر والضغط على 📋 لصق الإعدادات (واحد بواحد)\n" - "• أو اضغط الزر أدناه للصق لعدة مصادر دفعة واحدة" - ) - buttons = [ - [InlineKeyboardButton("📋 لصق لعدة مصادر دفعة واحدة", callback_data=f"bulkpastesrc|{source_id}|{back}")], - [InlineKeyboardButton("🔙 رجوع للمصدر", callback_data=f"srcset|{source_id}|{back}")], - [InlineKeyboardButton("🏠 القائمة الرئيسية", callback_data="main_menu")], - ] - await safe_edit(callback, text, InlineKeyboardMarkup(buttons)) - - -async def paste_source_settings(client, callback): - _, target_source_id, back = callback.data.split("|", 2) - saved = user_states.get(callback.from_user.id, {}) - from_source_id = saved.get("source_id") if saved.get("state") == "source_settings_copied" else None - if not from_source_id: - await callback.answer("ماكو إعدادات منسوخة. اضغط أولاً نسخ الإعدادات من مصدر آخر.", show_alert=True) - return - if str(from_source_id) == str(target_source_id): - await callback.answer("هذا نفس المصدر المنسوخ.", show_alert=True) - return - copied = db.copy_source_settings(from_source_id, [target_source_id]) if hasattr(db, "copy_source_settings") else 0 - await callback.answer("تم لصق الإعدادات." if copied else "فشل لصق الإعدادات.", show_alert=True) - callback.data = f"srcset|{target_source_id}|{back}" - await source_settings_menu(client, callback) - - -async def bulk_paste_source_prompt(client, callback): - """يفتح مربع إدخال نصي للصق إعدادات مصدر لعدة مصادر دفعة واحدة.""" - _, source_id, back = callback.data.split("|", 2) - saved = user_states.get(callback.from_user.id, {}) - from_source = saved.get("source_id") if saved.get("state") == "source_settings_copied" else None - if not from_source: - await callback.answer("ماكو إعدادات منسوخة. ارجع واضغط نسخ الإعدادات أولاً.", show_alert=True) - return - user_states[callback.from_user.id] = {"state": "waiting_copy_source_settings", "source_id": from_source, "back": back} - await safe_edit( - callback, - "أرسل المصادر التي تريد نسخ الإعدادات إليها.\nكل رابط/يوزر/ID بسطر مستقل:", - InlineKeyboardMarkup([nav_row(f"srcset|{source_id}|{back}")]) - ) - -async def system_menu(client, callback): - """القائمة الرئيسية لقسم النظام (خارج القنوات).""" - buttons = [ - InlineKeyboardButton("🧪 الفحص الشامل", callback_data="full_check"), - InlineKeyboardButton("🟢 حالة النظام", callback_data="system_status"), - InlineKeyboardButton("🛠 إدارة التشغيل", callback_data="ops_menu"), - InlineKeyboardButton("📝 إدارة السجل", callback_data="log_menu"), - InlineKeyboardButton("🔔 التنبيهات", callback_data="notifications_menu"), - ] - rows = grid_buttons(buttons, 2) - rows.append(nav_row("main_menu")) - await safe_edit(callback, "🖥 **النظام**\n\n🧪 الفحص الشامل: يفحص الجلسة والبوت والقنوات والمصادر.\n🟢 حالة النظام: يعرض اتصال البوت والجلسة وأعداد القنوات.\n🛠 إدارة التشغيل: الصيانة واختبار كل القنوات وآخر الأخطاء.\n📝 إدارة السجل: عرض وحجم ومسح سجل البوت.\n🔔 التنبيهات: تشغيل/إيقاف إشعارات الفحوصات.", InlineKeyboardMarkup(rows)) - - -async def system_status_menu(client, callback): - ch_id = None - if "|" in callback.data: - ch_id = callback.data.split("|", 1)[1] - channels = db.get_all_channels() - public_count = len(db.get_public_sources()) - special_sources = set() - for ch in channels: - for src in ch.get("special_sources") or []: - try: - special_sources.add(int(src)) - except Exception: - pass - last_error = db.get_system_value("last_error", "لا يوجد") if hasattr(db, "get_system_value") else "لا يوجد" - text = ( - "🟢 **حالة النظام**\n\n" - f"• البوت: {'✅ متصل' if bot_client else '❌ غير معروف'}\n" - f"• الجلسة: {'✅ متصلة' if user_client else '❌ غير معروف'}\n" - f"• الوسيطة: `{config.MIDDLE_CHANNEL}`\n" - f"• قنوات النشر: {len(channels)}\n" - f"• المصادر العامة: {public_count}\n" - f"• المصادر الخاصة: {len(special_sources)}\n" - f"• مصادر الفحص الكلية: {len(get_polling_sources())}\n" - f"• آخر خطأ: {last_error}\n\n🧪 فحص شامل: يعيد فحص كل مكونات النظام مباشرة." - ) - full_btn = f"full_check|{ch_id}" if ch_id else "full_check" - back_btn = f"sysset_{ch_id}" if ch_id else "system_menu" - await safe_edit(callback, text, InlineKeyboardMarkup([ - [InlineKeyboardButton("🧪 فحص شامل", callback_data=full_btn)], - nav_row(back_btn), - ])) - - -async def full_check_menu(client, callback): - ch_id = None - if "|" in callback.data: - ch_id = callback.data.split("|", 1)[1] - lines = ["🧪 **الفحص الشامل**", ""] - try: - await user_client.get_me() - lines.append("✅ الجلسة تعمل") - except Exception as e: - lines.append(f"❌ الجلسة: {e}") - try: - await bot_client.get_me() - lines.append("✅ البوت يعمل") - except Exception as e: - lines.append(f"❌ البوت: {e}") - try: - await warm_middle_peer() - lines.append("✅ الوسيطة تم فحصها") - except Exception as e: - lines.append(f"❌ الوسيطة: {e}") - channels = db.get_all_channels() - ok_ch = 0 - fail_ch = [] - for ch in channels[:80]: - try: - await hydrate_publish_channel(int(ch["id"])) - ok_ch += 1 - except Exception as e: - fail_ch.append(f"{entity_name(ch)}: {e}") - lines.append(f"✅ قنوات النشر المتاحة: {ok_ch}/{len(channels)}") - if fail_ch: - lines.append("⚠️ قنوات فشلت:") - lines.extend(["• " + x[:150] for x in fail_ch[:10]]) - sources = get_polling_sources() - ok_src = 0 - fail_src = [] - for src in sources[:120]: - try: - await hydrate_source_channel(int(src)) - ok_src += 1 - except Exception as e: - fail_src.append(f"{src}: {e}") - lines.append(f"✅ المصادر المتاحة: {ok_src}/{len(sources)}") - if fail_src: - lines.append("⚠️ مصادر فشلت:") - lines.extend(["• " + x[:150] for x in fail_src[:10]]) - await safe_edit(callback, "\n".join(lines)[:3900], InlineKeyboardMarkup([ - [InlineKeyboardButton("🟢 حالة النظام", callback_data=f"system_status|{ch_id}" if ch_id else "system_status")], - nav_row(f"system_status|{ch_id}" if ch_id else "system_status"), - ])) - - -async def warm_all_peers_after_import(): - await warm_middle_peer() - for ch in db.get_all_channels(): - try: - await hydrate_publish_channel(int(ch["id"])) - except Exception as e: - logger.warning(f"فشل تهيئة قناة نشر بعد الاستيراد {ch.get('id')}: {e}") - for src in get_polling_sources(): - try: - await hydrate_source_channel(int(src)) - except Exception as e: - logger.warning(f"فشل تهيئة مصدر بعد الاستيراد {src}: {e}") - - - -async def channel_quote_menu(client, callback): - ch_id = callback.data.split("_", 1)[1] - ch = db.get_channel(ch_id) - if not ch: - await callback.answer("القناة غير موجودة.") - return - qt = get_channel_quote_types_safe(ch_id) - cfg = db.get_channel_config(ch_id) if hasattr(db, "get_channel_config") else {} - title_quote = bool(cfg.get("title_quote")) - sig_quote = bool(cfg.get("signature_quote")) - text = ( - f"💬 **إعدادات الاقتباس لقناة:**\n{channel_display_name(ch_id)}\n\n" - "اختر الأنواع التي تريد نشرها كاقتباس رسمي:\n\n" - f"• العنوان: {'✅ مفعل' if title_quote else '❌ متوقف'}\n" - f"• النصوص: {'✅ مفعل' if qt.get('text') else '❌ متوقف'}\n" - f"• الصور: {'✅ مفعل' if qt.get('photo') else '❌ متوقف'}\n" - f"• الفيديو: {'✅ مفعل' if qt.get('video') else '❌ متوقف'}\n" - f"• الألبومات: {'✅ مفعل' if qt.get('album') else '❌ متوقف'}\n" - f"• التوقيع: {'✅ مفعل' if sig_quote else '❌ متوقف'}\n\nاضغط النوع لتفعيله أو إيقافه كاقتباس رسمي في هذه القناة." - ) - labels = {"text": "النصوص", "photo": "الصور", "video": "الفيديو", "album": "الألبومات"} - buttons = [ - InlineKeyboardButton(f"{'✅' if title_quote else '❌'} العنوان", callback_data=f"ch_titlequote_{ch_id}"), - ] - for key in ["text", "photo", "video", "album"]: - mark = "✅" if qt.get(key) else "❌" - buttons.append(InlineKeyboardButton(f"{mark} {labels[key]}", callback_data=f"toggleqtype|{ch_id}|{key}")) - buttons.append(InlineKeyboardButton(f"{'✅' if sig_quote else '❌'} التوقيع", callback_data=f"ch_sigquote_{ch_id}")) - rows = grid_buttons(buttons, 2) - rows.append([InlineKeyboardButton("🔙 رجوع", callback_data=f"postset_{ch_id}"), InlineKeyboardButton("🏠 القائمة الرئيسية", callback_data="main_menu")]) - await safe_edit(callback, text, InlineKeyboardMarkup(rows)) - - -async def toggle_channel_quote_type(client, callback): - try: - _, ch_id, qtype = callback.data.split("|", 2) - except Exception: - await callback.answer("طلب غير صالح.", show_alert=True) - return - ch = db.get_channel(ch_id) - if not ch: - await callback.answer("القناة غير موجودة.") - return - current = get_channel_quote_types_safe(ch_id).get(qtype, False) - if hasattr(db, "set_channel_quote_type"): - db.set_channel_quote_type(ch_id, qtype, not current) - else: - db.update_channel(ch_id, "quote_publish", not current) - await callback.answer("تم تحديث خيار الاقتباس.") - callback.data = f"quotemenu_{ch_id}" - await channel_quote_menu(client, callback) - - -async def toggle_channel_quote(client, callback): - # توافق قديم: إذا وصل callback قديم، افتح قائمة الأنواع بدل تبديل عام. - callback.data = callback.data.replace("togglequote_", "quotemenu_", 1) - await channel_quote_menu(client, callback) - - -async def channel_hashtags_menu(client, callback): - ch_id = callback.data.split("_", 1)[1] - ch = db.get_channel(ch_id) - if not ch: - await callback.answer("القناة غير موجودة.") - return - tags = db.get_channel_hashtags(ch_id) if hasattr(db, "get_channel_hashtags") else ch.get("hashtags", []) - shown = tags[:50] - display = "\n".join(f"• `{t}`" for t in shown) - if len(tags) > len(shown): - display += f"\n… و{len(tags) - len(shown)} هاشتاك إضافي (يُعرض أول 50)." - text = ( - f"🏷 **هاشتاكات قناة النشر:**\n{channel_display_name(ch)}\n\n" - "تضاف تلقائياً أسفل المنشور لهذه القناة فقط.\n\n" - "الحالية:\n" + (display if tags else "لا توجد.") + "\n\n➕ إضافة جماعية: تضيف هاشتاكات للقناة (كل واحد بسطر).\n🗑 حذف جماعي: يزيل هاشتاكات موجودة." - ) - buttons = [ - InlineKeyboardButton("➕ إضافة جماعية", callback_data=f"addhashtags_{ch_id}"), - InlineKeyboardButton("🗑 حذف جماعي", callback_data=f"delhashtags_{ch_id}"), - ] - rows = grid_buttons(buttons, 2) - rows.append([InlineKeyboardButton("🔙 رجوع", callback_data=f"postset_{ch_id}"), InlineKeyboardButton("🏠 القائمة الرئيسية", callback_data="main_menu")]) - await safe_edit(callback, text, InlineKeyboardMarkup(rows)) - - -async def add_channel_hashtags_prompt(client, callback): - ch_id = callback.data.split("_", 1)[1] - user_states.pop(callback.from_user.id, None) - user_states[callback.from_user.id] = {"state": "waiting_channel_hashtags_add", "ch_id": ch_id} - await safe_edit(callback, "أرسل هاشتاك أو مجموعة هاشتاكات.\nكل هاشتاك بسطر مستقل:", InlineKeyboardMarkup([nav_row(f"hashtags_{ch_id}")])) - - -async def del_channel_hashtags_prompt(client, callback): - ch_id = callback.data.split("_", 1)[1] - user_states.pop(callback.from_user.id, None) - user_states[callback.from_user.id] = {"state": "waiting_channel_hashtags_del", "ch_id": ch_id} - tags = db.get_channel_hashtags(ch_id) if hasattr(db, "get_channel_hashtags") else [] - shown = tags[:50] - current = "\n".join(f"• {t}" for t in shown) if tags else "لا توجد هاشتاكات حالياً." - if len(tags) > len(shown): - current += f"\n… و{len(tags) - len(shown)} هاشتاك إضافي." - await safe_edit(callback, "أرسل الهاشتاكات التي تريد حذفها.\nكل هاشتاك بسطر مستقل.\n\nالحالي:\n" + current, InlineKeyboardMarkup([nav_row(f"hashtags_{ch_id}")])) - - -async def test_publish_channel(client, callback): - ch_id = callback.data.split("_", 1)[1] - ch = db.get_channel(ch_id) - if not ch: - await callback.answer("القناة غير موجودة.") - return - try: - text = compose_channel_post_text(f"✅ اختبار نشر ناجح\n{entity_name(ch)}", ch_id, ch.get("tail", "")) - formatted, parse_mode, entities = format_outgoing_payload_for_channel(text, ch_id, "text") - await safe_send_channel_message(int(ch_id), formatted, parse_mode=parse_mode, entities=entities) - record_channel_success(ch_id) - await callback.answer("تم إرسال اختبار النشر.") - await notify_admins(f"تم اختبار النشر بنجاح إلى:\n{channel_display_name(ch)}") - except Exception as e: - await record_channel_failure_and_maybe_alert(ch_id, e) - await callback.answer("فشل اختبار النشر.", show_alert=True) - await notify_admins(f"فشل اختبار النشر إلى:\n{channel_display_name(ch)}\n\n{e}") - - -async def operations_menu(client, callback): - ch_id = None - if "|" in callback.data: - ch_id = callback.data.split("|", 1)[1] - maintenance = db.is_maintenance_mode() if hasattr(db, "is_maintenance_mode") else False - text = ( - "🛠 **إدارة التشغيل**\n\n" - f"وضع الصيانة: {'✅ مفعل' if maintenance else '❌ متوقف'}\n\n" - "وضع الصيانة يوقف النشر، لكن يبقي فحص المصادر مستمر حتى لا تتراكم الأخبار.\n\n🛠 تبديل الصيانة: يوقف النشر ويبقي فحص المصادر مستمراً.\n🧪 اختبار كل القنوات: يفحص إمكانية النشر في كل القنوات.\n⚠️ آخر الأخطاء: يعرض آخر 20 خطأ مسجلاً." - ) - buttons = [ - InlineKeyboardButton("🛠 تبديل الصيانة", callback_data=f"toggle_maintenance|{ch_id}" if ch_id else "toggle_maintenance"), - InlineKeyboardButton("🧪 اختبار كل القنوات", callback_data=f"test_all_channels|{ch_id}" if ch_id else "test_all_channels"), - InlineKeyboardButton("⚠️ آخر الأخطاء", callback_data=f"errors_menu|{ch_id}" if ch_id else "errors_menu"), - ] - rows = grid_buttons(buttons, 2) - rows.append(nav_row(f"sysset_{ch_id}" if ch_id else "system_menu")) - await safe_edit(callback, text, InlineKeyboardMarkup(rows)) - - -async def toggle_maintenance_mode(client, callback): - ch_id = None - if "|" in callback.data: - ch_id = callback.data.split("|", 1)[1] - current = db.is_maintenance_mode() if hasattr(db, "is_maintenance_mode") else False - if hasattr(db, "set_maintenance_mode"): - db.set_maintenance_mode(not current) - await callback.answer("تم تشغيل وضع الصيانة." if not current else "تم إيقاف وضع الصيانة.") - callback.data = f"ops_menu|{ch_id}" if ch_id else "ops_menu" - await operations_menu(client, callback) - - - -async def toggle_short_posts_filter(client, callback): - ch_id = None - if "|" in callback.data: - ch_id = callback.data.split("|", 1)[1] - if ch_id and hasattr(db, "get_channel_ignore_short_posts"): - current = bool(db.get_channel_ignore_short_posts(ch_id)) - if hasattr(db, "set_channel_ignore_short_posts"): - db.set_channel_ignore_short_posts(ch_id, not current) - else: - current = is_ignore_short_posts_enabled() - if hasattr(db, "set_ignore_short_posts"): - db.set_ignore_short_posts(not current) - await callback.answer("تم تحديث فلتر المنشورات القصيرة للقناة." if ch_id else "تم تحديث فلتر المنشورات القصيرة العام.") - callback.data = f"postset_{ch_id}" if ch_id else "ops_menu" - await (post_settings_menu(client, callback) if ch_id else operations_menu(client, callback)) - - -async def errors_menu(client, callback): - ch_id = None - if "|" in callback.data: - ch_id = callback.data.split("|", 1)[1] - errors = db.get_last_errors(20) if hasattr(db, "get_last_errors") else [] - text = "⚠️ **آخر الأخطاء:**\n\n⚙️ عرض آخر 20 خطأ مسجل في البوت.\n\n" - if not errors: - text += "لا توجد أخطاء مسجلة." - else: - rows_txt = [] - for e in reversed(errors[-20:]): - rows_txt.append(f"• {_format_ts(e.get('ts'))}\n {e.get('context')}\n `{str(e.get('error'))[:180]}`") - text += "\n\n".join(rows_txt) - buttons = [ - [InlineKeyboardButton("🗑 مسح الأخطاء", callback_data=f"clear_errors|{ch_id}" if ch_id else "clear_errors")], - [InlineKeyboardButton("🔙 رجوع", callback_data=f"ops_menu|{ch_id}" if ch_id else "ops_menu"), InlineKeyboardButton("🏠 القائمة الرئيسية", callback_data="main_menu")] - ] - await safe_edit(callback, text[:3900], InlineKeyboardMarkup(buttons)) - - -async def clear_errors(client, callback): - ch_id = None - if "|" in callback.data: - ch_id = callback.data.split("|", 1)[1] - if hasattr(db, "clear_last_errors"): - db.clear_last_errors() - await callback.answer("تم مسح لوحة الأخطاء.") - callback.data = f"errors_menu|{ch_id}" if ch_id else "errors_menu" - await errors_menu(client, callback) - - -def _read_log_tail_lines(path, max_lines=500, chunk_size=8192): - """يقرأ آخر max_lines سطر فقط من الملف دون تحميله كاملاً في الذاكرة.""" - try: - with open(path, "rb") as f: - f.seek(0, os.SEEK_END) - file_size = f.tell() - data = b"" - lines_found = 0 - pos = file_size - while pos > 0 and lines_found <= max_lines: - read_size = min(chunk_size, pos) - pos -= read_size - f.seek(pos) - chunk = f.read(read_size) - data = chunk + data - lines_found = data.count(b"\n") - text = data.decode("utf-8", errors="ignore") - lines = text.splitlines() - return lines[-max_lines:] - except FileNotFoundError: - return [] - except Exception as e: - logger.error(f"فشل قراءة ملف السجل: {e}") - return [] - - -async def log_management_menu(client, callback): - ch_id = None - if "|" in callback.data: - ch_id = callback.data.split("|", 1)[1] - text = "📝 **إدارة السجل**\n\n👁️ عرض آخر 500 سطر: يقرأ آخر أسطر سجل البوت.\n🔄 تحديث السجل: يعيد قراءة السجل.\n📦 عرض حجم السجل: يعرض حجم ملف السجل بالميغابايت.\n🗑️ مسح السجل: يفرّغ ملف السجل فقط (البوت يستمر بالعمل)." - buttons = [ - [InlineKeyboardButton("👁️ عرض آخر 500 سطر", callback_data=f"log_view|{ch_id}" if ch_id else "log_view")], - [InlineKeyboardButton("🔄 تحديث السجل", callback_data=f"log_refresh|{ch_id}" if ch_id else "log_refresh")], - [InlineKeyboardButton("📦 عرض حجم السجل", callback_data=f"log_size|{ch_id}" if ch_id else "log_size")], - [InlineKeyboardButton("🗑️ مسح السجل", callback_data=f"log_clear_prompt|{ch_id}" if ch_id else "log_clear_prompt")], - nav_row(f"sysset_{ch_id}" if ch_id else "system_menu"), - ] - await safe_edit(callback, text, InlineKeyboardMarkup(buttons)) - - -async def view_bot_log(client, callback): - ch_id = None - if "|" in callback.data: - ch_id = callback.data.split("|", 1)[1] - lines = _read_log_tail_lines(BOT_LOG_FILE, 500) - if not lines: - body = "لا يوجد سجل حالياً." - else: - body = "\n".join(lines) - text = f"👁️ **آخر {len(lines)} سطر من السجل:**\n\n`{body}`" - if len(text) > 3900: - text = text[-3900:] - text = f"👁️ **آخر أسطر السجل (مقتصّة لطول الرسالة):**\n\n`{text}`" - buttons = [ - [InlineKeyboardButton("🔄 تحديث", callback_data=f"log_view|{ch_id}" if ch_id else "log_view")], - [InlineKeyboardButton("🔙 رجوع", callback_data=f"log_menu|{ch_id}" if ch_id else "log_menu"), InlineKeyboardButton("🏠 القائمة الرئيسية", callback_data="main_menu")], - ] - await safe_edit(callback, text, InlineKeyboardMarkup(buttons)) - - -async def show_log_size(client, callback): - ch_id = None - if "|" in callback.data: - ch_id = callback.data.split("|", 1)[1] - try: - size_bytes = os.path.getsize(BOT_LOG_FILE) - size_mb = size_bytes / (1024 * 1024) - text = f"📦 **حجم ملف السجل:**\n\n{size_mb:.3f} MB" - except FileNotFoundError: - text = "📦 لا يوجد ملف سجل حالياً." - except Exception as e: - text = f"❌ تعذر قراءة حجم السجل: {e}" - buttons = [ - [InlineKeyboardButton("🔙 رجوع", callback_data=f"log_menu|{ch_id}" if ch_id else "log_menu"), InlineKeyboardButton("🏠 القائمة الرئيسية", callback_data="main_menu")], - ] - await safe_edit(callback, text, InlineKeyboardMarkup(buttons)) - - -async def clear_log_prompt(client, callback): - ch_id = None - if "|" in callback.data: - ch_id = callback.data.split("|", 1)[1] - text = ( - "⚠️ **تأكيد مسح السجل**\n\n" - "سيتم تفريغ ملف السجل فقط. البوت سيستمر بالعمل والكتابة بنفس الملف بدون أي إعادة تشغيل.\n\n" - "هل تريد المتابعة؟" - ) - buttons = [ - [InlineKeyboardButton("✅ نعم، امسح السجل", callback_data=f"log_clear_confirm|{ch_id}" if ch_id else "log_clear_confirm")], - nav_row(f"log_menu|{ch_id}" if ch_id else "main_menu"), - ] - await safe_edit(callback, text, InlineKeyboardMarkup(buttons)) - - -async def confirm_clear_log(client, callback): - ch_id = None - if "|" in callback.data: - ch_id = callback.data.split("|", 1)[1] - try: - with open(BOT_LOG_FILE, "w", encoding="utf-8"): - pass - await callback.answer("تم مسح السجل.") - except Exception as e: - logger.error(f"فشل مسح ملف السجل: {e}") - await callback.answer("تعذر مسح السجل.", show_alert=True) - callback.data = f"log_menu|{ch_id}" if ch_id else "log_menu" - await log_management_menu(client, callback) - - -async def named_backup_prompt(client, callback): - user_states.pop(callback.from_user.id, None) - user_states[callback.from_user.id] = {"state": "waiting_named_backup"} - await safe_edit(callback, "أرسل اسم النسخة الاحتياطية.\nمثال:\nstable_before_platform", InlineKeyboardMarkup([nav_row("main_menu")])) - - -async def test_all_channels(client, callback): - ch_id = None - if "|" in callback.data: - ch_id = callback.data.split("|", 1)[1] - channels = db.get_all_channels() - ok = 0 - fail = 0 - for ch in channels: - if ch.get("paused"): - continue - try: - ch_id_int = int(ch["id"]) - text = compose_channel_post_text(f"✅ اختبار نشر جماعي ناجح\n{entity_name(ch)}", ch_id_int, ch.get("tail", "")) - formatted, parse_mode, entities = format_outgoing_payload_for_channel(text, ch_id_int, "text") - await safe_send_channel_message(ch_id_int, formatted, parse_mode=parse_mode, entities=entities) - record_channel_success(ch_id_int) - ok += 1 - await asyncio.sleep(get_publish_delay_for_channel(ch_id_int)) - except Exception as e: - fail += 1 - await record_channel_failure_and_maybe_alert(ch.get("id"), e) - await safe_edit(callback, f"🧪 **نتيجة اختبار كل القنوات:**\n\n✅ نجح: {ok}\n❌ فشل: {fail}", InlineKeyboardMarkup([ - [InlineKeyboardButton("🔙 رجوع", callback_data=f"ops_menu|{ch_id}" if ch_id else "ops_menu"), InlineKeyboardButton("🏠 القائمة الرئيسية", callback_data="main_menu")] - ])) -async def add_special_source_prompt(client, callback): - ch_id = callback.data.split("_", 1)[1] - user_states.pop(callback.from_user.id, None) - user_states[callback.from_user.id] = { - "state": "waiting_special_source", - "ch_id": ch_id - } - await safe_edit(callback, "أرسل مصدر خاص واحد أو عدة مصادر خاصة.\nكل رابط/يوزر/ID بسطر مستقل:", InlineKeyboardMarkup([nav_row(f"srcset_{ch_id}")])) - -async def delete_special_source_prompt(client, callback): - ch_id = callback.data.split("_", 1)[1] - ch = db.get_channel(ch_id) - if not ch: - await callback.answer("غير موجودة.") - return - sources = ch.get("special_sources", []) - if not sources: - await callback.answer("لا توجد مصادر.") - return - buttons = [] - for idx, src in enumerate(sources): - name = entity_name(src, is_source=True) - buttons.append([InlineKeyboardButton(f"🗑 حذف {name}", callback_data=f"confdelspec_{ch_id}_{idx}")]) - buttons.append(nav_row(f"specsrc_{ch_id}")) - await safe_edit(callback, "اختر المصدر لحذفه:", InlineKeyboardMarkup(buttons)) - -async def confirm_delete_special_source(client, callback): - parts = callback.data.split("_") - ch_id = parts[1] - idx = int(parts[2]) - - ch = db.get_channel(ch_id) - - if not ch: - await callback.answer("غير موجودة.") - return - - sources = ch.get("special_sources", []) - - if idx < 0 or idx >= len(sources): - await callback.answer("المصدر غير موجود.") - return - - sources.pop(idx) - db.update_channel(ch_id, "special_sources", sources) - - await callback.answer("تم الحذف.") - await manage_special_sources(client, callback) - -async def show_stats(client, callback): - channels = db.get_all_channels() - total_posts = sum(ch.get("posts_count", 0) for ch in channels) - pub_src = len(db.get_public_sources()) - words = len(db.get_blocked_words()) - - text = "📊 **الإحصائيات:**\n\n" - text += f"• إجمالي المنشورات: **{total_posts}**\n" - text += f"• قنوات النشر: {len(channels)}\n" - text += f"• المصادر العامة: {pub_src}\n" - text += f"• الكلمات المحظورة: {words}\n\n" - - if channels: - text += "**تفصيل المنشورات حسب القناة:**\n" - for ch in channels: - text += f"• {entity_name(ch)}\n ID: `{ch['id']}`\n المنشورات: **{ch.get('posts_count', 0)}**\n" - - await safe_edit( - callback, - text, - InlineKeyboardMarkup([nav_row("main_menu")]) - ) - -async def handle_text_input(client, message): - if not is_admin(message.from_user.id): - return - uid = message.from_user.id - if uid not in user_states: - return - state = user_states[uid].get("state") - text = message.text.strip() - - if state and state.startswith("blogger_"): - await handle_blogger_text_input(client, message) - return - - try: - if state == "waiting_channel_id": - results = [] - for raw in split_bulk_lines(text): - try: - chat = await resolve_chat_info_timeout(raw, prefer_user=False) - bot_status = "" - bot_admin = False - check_note = "" - try: - bot_member = await bot_client.get_chat_member(int(chat.id), (await bot_client.get_me()).id) - bot_status = member_status_value(getattr(bot_member, "status", None)) - bot_admin = is_bot_admin_status(bot_status) - check_note = "✅ البوت مشرف" if bot_admin else "❌ البوت ليس مشرفاً" - except Exception as verr: - bot_status = "unknown" - check_note = f"تعذر التحقق من البوت: {str(verr)[:60]}" - meta = chat_meta(chat, raw) - created = db.add_channel(chat.id, **meta) - db.update_channel(chat.id, "bot_admin", bot_admin) - db.update_channel(chat.id, "bot_status", bot_status) - results.append(("ok" if created else "exists", f"{meta['title']} | {check_note}", raw)) - except Exception as e: - results.append(("fail", raw, "انتهت المهلة أو تعذر جلب المعلومات" if isinstance(e, asyncio.TimeoutError) else str(e))) - await message.reply(format_bulk_report("📢 نتيجة إضافة قنوات النشر", results)) - await show_channels_menu_from_message(message) - elif state == "waiting_tail": - ch_id = user_states[uid]["ch_id"] - db.update_channel(ch_id, "tail", text) - await message.reply("✅ تم حفظ التوقيع.") - ch = db.get_channel(ch_id) - if ch: - await message.reply(f"إعدادات القناة: {entity_name(ch)}", reply_markup=channel_buttons(ch_id)) - elif state == "waiting_public_source": - results = [] - for raw in split_bulk_lines(text): - try: - chat = await resolve_chat_info_timeout(raw, prefer_user=True) - meta = chat_meta(chat, raw) - created = db.add_public_source(chat.id, **meta) - results.append(("ok" if created else "exists", meta["title"], raw)) - except Exception as e: - results.append(("fail", raw, "انتهت المهلة أو تعذر جلب المعلومات" if isinstance(e, asyncio.TimeoutError) else str(e))) - await message.reply(format_bulk_report("🌐 نتيجة إضافة المصادر العامة", results)) - await show_public_sources_menu_from_message(message) - elif state == "waiting_blocked_word": - logger.info(f"handle_text_input: waiting_blocked_word from user {uid}") - added = 0 - exists = 0 - for word in split_bulk_lines(text): - if db.add_blocked_word(word): - added += 1 - else: - exists += 1 - logger.info(f"waiting_blocked_word: added={added}, exists={exists}") - await message.reply(f"✅ تمت إضافة {added} كلمة.\n⚠️ موجودة مسبقاً/فارغة: {exists}") - await show_blocked_words_menu_from_message(message) - elif state == "waiting_channel_blocked_word": - logger.info(f"handle_text_input: waiting_channel_blocked_word from user {uid}") - ch_id = user_states[uid]["ch_id"] - added = 0 - exists = 0 - for word in split_bulk_lines(text): - if db.add_channel_blocked_word(ch_id, word): - added += 1 - else: - exists += 1 - logger.info(f"waiting_channel_blocked_word: channel={ch_id}, added={added}, exists={exists}") - ch = db.get_channel(ch_id) - await message.reply( - f"✅ تمت إضافة {added} كلمة لقناة {entity_name(ch) if ch else ch_id}.\n⚠️ موجودة مسبقاً/فارغة: {exists}", - reply_markup=InlineKeyboardMarkup([ - [InlineKeyboardButton("🚫 رجوع للكلمات المحظورة", callback_data=f"chwords|{ch_id}")], - [InlineKeyboardButton("🏠 القائمة الرئيسية", callback_data="main_menu")], - ]) - ) - elif state == "waiting_special_source": - ch_id = user_states[uid]["ch_id"] - ch = db.get_channel(ch_id) - if not ch: - await message.reply("❌ القناة غير موجودة.") - return - sources = ch.get("special_sources", []) - results = [] - for raw in split_bulk_lines(text): - try: - chat = await resolve_chat_info_timeout(raw, prefer_user=True) - meta = chat_meta(chat, raw) - db.update_source_meta(chat.id, **meta) if hasattr(db, "update_source_meta") else None - if int(chat.id) not in [int(x) for x in sources]: - sources.append(int(chat.id)) - results.append(("ok", meta["title"], raw)) - else: - results.append(("exists", meta["title"], raw)) - except Exception as e: - results.append(("fail", raw, "انتهت المهلة أو تعذر جلب المعلومات" if isinstance(e, asyncio.TimeoutError) else str(e))) - db.update_channel(ch_id, "special_sources", sources) - await message.reply(format_bulk_report("🔍 نتيجة إضافة المصادر المخصصة", results)) - await show_special_sources_menu_from_message(message, ch_id) - elif state == "waiting_source_cleanup_add": - source_id = user_states[uid]["source_id"] - back = user_states[uid].get("back", "menu_public_src") - terms = split_terms_lines(text) - result = db.add_source_remove_terms(source_id, terms) if hasattr(db, "add_source_remove_terms") else {"added": 0, "exists": 0} - await message.reply( - f"✅ تمت إضافة {result.get('added', 0)} عنصر للتنظيف الذكي.\n⚠️ موجود مسبقاً/فارغ: {result.get('exists', 0)}", - reply_markup=InlineKeyboardMarkup([ - [InlineKeyboardButton("🧹 رجوع للتنظيف الذكي", callback_data=f"clean_src|{source_id}|{back}")], - [InlineKeyboardButton("⚙️ إعدادات المصدر", callback_data=f"srcset|{source_id}|{back}")], - [InlineKeyboardButton("🏠 القائمة الرئيسية", callback_data="main_menu")], - ]) - ) - elif state == "waiting_source_cleanup_del": - source_id = user_states[uid]["source_id"] - back = user_states[uid].get("back", "menu_public_src") - terms = split_terms_lines(text) - result = db.remove_source_remove_terms(source_id, terms) if hasattr(db, "remove_source_remove_terms") else {"removed": 0, "missing": 0} - await message.reply( - f"✅ تم حذف {result.get('removed', 0)} عنصر من التنظيف الذكي.\n⚠️ غير موجود: {result.get('missing', 0)}", - reply_markup=InlineKeyboardMarkup([ - [InlineKeyboardButton("🧹 رجوع للتنظيف الذكي", callback_data=f"clean_src|{source_id}|{back}")], - [InlineKeyboardButton("⚙️ إعدادات المصدر", callback_data=f"srcset|{source_id}|{back}")], - [InlineKeyboardButton("🏠 القائمة الرئيسية", callback_data="main_menu")], - ]) - ) - elif state == "waiting_global_cleanup_add": - terms = split_terms_lines(text) - result = db.add_global_remove_terms(terms) if hasattr(db, "add_global_remove_terms") else {"added": 0, "exists": 0} - await message.reply( - f"✅ تمت إضافة {result.get('added', 0)} عنصر لقائمة الحذف العامة.\n⚠️ موجود مسبقاً/فارغ: {result.get('exists', 0)}", - reply_markup=InlineKeyboardMarkup([ - [InlineKeyboardButton("🧹 رجوع لقائمة الحذف العامة", callback_data="global_clean")], - [InlineKeyboardButton("🏠 القائمة الرئيسية", callback_data="main_menu")], - ]) - ) - elif state == "waiting_global_cleanup_del": - terms = split_terms_lines(text) - result = db.remove_global_remove_terms(terms) if hasattr(db, "remove_global_remove_terms") else {"removed": 0, "missing": 0} - await message.reply( - f"✅ تم حذف {result.get('removed', 0)} عنصر من قائمة الحذف العامة.\n⚠️ غير موجود: {result.get('missing', 0)}", - reply_markup=InlineKeyboardMarkup([ - [InlineKeyboardButton("🧹 رجوع لقائمة الحذف العامة", callback_data="global_clean")], - [InlineKeyboardButton("🏠 القائمة الرئيسية", callback_data="main_menu")], - ]) - ) - elif state == "waiting_channel_delete_term_add": - logger.info(f"handle_text_input: waiting_channel_delete_term_add from user {uid}") - ch_id = user_states[uid]["ch_id"] - terms = split_terms_lines(text) - result = db.add_channel_delete_terms(ch_id, terms) - logger.info(f"waiting_channel_delete_term_add: channel={ch_id}, result={result}") - ch = db.get_channel(ch_id) - await message.reply( - f"✅ تمت إضافة {result.get('added', 0)} عنصر لقناة {entity_name(ch) if ch else ch_id}.\n⚠️ موجود مسبقاً/فارغ: {result.get('exists', 0)}", - reply_markup=InlineKeyboardMarkup([ - [InlineKeyboardButton("🧹 رجوع لقائمة الكلمات المحذوفة", callback_data=f"chdelterms|{ch_id}")], - [InlineKeyboardButton("🏠 القائمة الرئيسية", callback_data="main_menu")], - ]) - ) - elif state == "waiting_channel_delete_term_del": - ch_id = user_states[uid]["ch_id"] - terms = split_terms_lines(text) - result = db.remove_channel_delete_terms(ch_id, terms) - ch = db.get_channel(ch_id) - await message.reply( - f"✅ تم حذف {result.get('removed', 0)} عنصر من قناة {entity_name(ch) if ch else ch_id}.\n⚠️ غير موجود: {result.get('missing', 0)}", - reply_markup=InlineKeyboardMarkup([ - [InlineKeyboardButton("🧹 رجوع لقائمة الكلمات المحذوفة", callback_data=f"chdelterms|{ch_id}")], - [InlineKeyboardButton("🏠 القائمة الرئيسية", callback_data="main_menu")], - ]) - ) - elif state == "waiting_channel_speed": - ch_id = user_states[uid]["ch_id"] - value = text.strip() - if value in {"0", "افتراضي", "default", "DEFAULT", ""}: - ok = db.set_channel_publish_delay(ch_id, None) if hasattr(db, "set_channel_publish_delay") else False - msg = "✅ تم إرجاع سرعة القناة للوضع الافتراضي." - else: - try: - delay = max(0.0, float(value)) - ok = db.set_channel_publish_delay(ch_id, delay) if hasattr(db, "set_channel_publish_delay") else False - msg = f"✅ تم حفظ سرعة القناة: {delay} ثانية." - except Exception: - ok = False - msg = "❌ أرسل رقم بالثواني، أو 0 للوضع الافتراضي." - await message.reply(msg, reply_markup=InlineKeyboardMarkup([ - [InlineKeyboardButton("⏱ رجوع لسرعة النشر", callback_data=f"speedmenu_{ch_id}")], - [InlineKeyboardButton("⚙️ إعدادات القناة", callback_data=f"ch_{ch_id}")], - [InlineKeyboardButton("🏠 القائمة الرئيسية", callback_data="main_menu")], - ])) - elif state == "waiting_copy_source_settings": - from_source = user_states[uid]["source_id"] - back = user_states[uid].get("back", "menu_public_src") - targets = [] - failed = [] - for raw in split_bulk_lines(text): - try: - chat = await resolve_chat_info_timeout(raw, prefer_user=True) - meta = chat_meta(chat, raw) - db.update_source_meta(chat.id, **meta) if hasattr(db, "update_source_meta") else None - targets.append(int(chat.id)) - except Exception as e: - try: - targets.append(int(raw.strip())) - except Exception: - failed.append(f"{raw}: {e}") - copied = db.copy_source_settings(from_source, targets) if hasattr(db, "copy_source_settings") else 0 - msg = f"✅ تم نسخ إعدادات المصدر إلى {copied} مصدر." - if failed: - msg += "\n⚠️ فشل:\n" + "\n".join(failed[:10]) - await message.reply(msg, reply_markup=InlineKeyboardMarkup([ - [InlineKeyboardButton("⚙️ إعدادات المصدر", callback_data=f"srcset|{from_source}|{back}")], - [InlineKeyboardButton("🏠 القائمة الرئيسية", callback_data="main_menu")], - ])) - - elif state == "waiting_channel_hashtags_add": - ch_id = user_states[uid]["ch_id"] - terms = split_terms_lines(text) - result = db.add_channel_hashtags(ch_id, terms) if hasattr(db, "add_channel_hashtags") else {"added": 0, "exists": 0} - await message.reply( - f"✅ تمت إضافة {result.get('added', 0)} هاشتاك.\n⚠️ موجود مسبقاً/فارغ: {result.get('exists', 0)}", - reply_markup=InlineKeyboardMarkup([ - [InlineKeyboardButton("🏷 رجوع للهاشتاكات", callback_data=f"hashtags_{ch_id}")], - [InlineKeyboardButton("⚙️ إعدادات القناة", callback_data=f"ch_{ch_id}")], - [InlineKeyboardButton("🏠 القائمة الرئيسية", callback_data="main_menu")], - ]) - ) - elif state == "waiting_channel_hashtags_del": - ch_id = user_states[uid]["ch_id"] - terms = split_terms_lines(text) - result = db.remove_channel_hashtags(ch_id, terms) if hasattr(db, "remove_channel_hashtags") else {"removed": 0, "missing": 0} - await message.reply( - f"✅ تم حذف {result.get('removed', 0)} هاشتاك.\n⚠️ غير موجود: {result.get('missing', 0)}", - reply_markup=InlineKeyboardMarkup([ - [InlineKeyboardButton("🏷 رجوع للهاشتاكات", callback_data=f"hashtags_{ch_id}")], - [InlineKeyboardButton("⚙️ إعدادات القناة", callback_data=f"ch_{ch_id}")], - [InlineKeyboardButton("🏠 القائمة الرئيسية", callback_data="main_menu")], - ]) - ) - elif state == "waiting_named_backup": - name = text.strip() - path = db.create_named_backup(name) if hasattr(db, "create_named_backup") else "" - await message.reply( - f"✅ تم إنشاء Backup باسم:\n`{name}`\n\nالملف:\n`{path}`", - reply_markup=InlineKeyboardMarkup([ - [InlineKeyboardButton("🛠 رجوع لإدارة التشغيل", callback_data="ops_menu")], - [InlineKeyboardButton("🏠 القائمة الرئيسية", callback_data="main_menu")], - ]) - ) - # Multi-Bot Expansion: Add Session - elif state == "waiting_session_add": - parts = [p.strip() for p in text.split("|", 2)] - if len(parts) < 3: - await message.reply("❌ التنسيق خطأ. استخدم:\n`API_ID | API_HASH | SESSION_STRING`") - return - api_id, api_hash, session_string = parts[0], parts[1], parts[2] - if not api_id.isdigit(): - await message.reply("❌ API_ID يجب أن يكون رقماً.") - return - ok = db.add_session(api_id, api_hash, session_string) - if ok: - await message.reply("✅ تمت إضافة الجلسة.", reply_markup=InlineKeyboardMarkup([ - [InlineKeyboardButton("📱 إدارة الجلسات", callback_data="sessions_list")], - [InlineKeyboardButton("🏠 القائمة الرئيسية", callback_data="main_menu")], - ])) - else: - await message.reply("❌ فشل إضافة الجلسة. قد يكون المعرف موجوداً مسبقاً.") - # Multi-Bot Expansion: Add AI Key - elif state == "waiting_ai_key_add": - parts = [p.strip() for p in text.split("|", 1)] - if len(parts) < 2: - await message.reply("❌ التنسيق خطأ. استخدم:\n`المزود | المفتاح`\nمثال: `gemini | AIzaSy...`") - return - provider, api_key = parts[0], parts[1] - ok = db.add_ai_key(provider, api_key) - if ok: - await message.reply("✅ تمت إضافة مفتاح AI.", reply_markup=InlineKeyboardMarkup([ - [InlineKeyboardButton("🧠 إدارة المفاتيح", callback_data="ai_list")], - [InlineKeyboardButton("🏠 القائمة الرئيسية", callback_data="main_menu")], - ])) - else: - await message.reply("❌ فشل إضافة المفتاح.") - # Multi-Bot Expansion: Add Publishing Bot - elif state == "waiting_bot_add": - token = text.strip() - if not db.bot_manager.validate_token_format(token): - await message.reply("❌ التوكن غير صالح. يجب أن يحتوي على `:`") - return - # تحقق فوري من صحة التوكن عبر Telegram API - ver = await verifier.validate_token(token) - if not ver.get("valid"): - await message.reply(f"❌ التوكن غير صالح: {ver.get('error', 'فشل الاتصال')}") - return - bot_username = ver.get("username", "") - bot_name = ver.get("first_name", "") - ok = db.bot_manager.add_bot(token, username=bot_username, name=bot_name) if hasattr(db, "bot_manager") else db.add_publishing_bot(token, username=bot_username, name=bot_name) - if ok: - # مسح الكاش لهذا التوكن لأنه جديد - if hasattr(db, "cache_invalidate"): - db.cache_invalidate(f"token_valid|{token[:10]}") - await message.reply("✅ تمت إضافة بوت النشر.", reply_markup=InlineKeyboardMarkup([ - [InlineKeyboardButton("🤖 إدارة البوتات", callback_data="bots_list")], - [InlineKeyboardButton("🏠 القائمة الرئيسية", callback_data="main_menu")], - ])) - else: - await message.reply("❌ فشل إضافة البوت.") - elif state == "waiting_bot_rename": - bid = user_states[uid].get("bot_id", "") - new_name = text.strip() - if new_name: - db.update_publishing_bot(bid, name=new_name) - await message.reply("✅ تم تحديث الاسم.", reply_markup=InlineKeyboardMarkup([ - [InlineKeyboardButton("🤖 رجوع", callback_data=f"bot_show_{bid}")], - [InlineKeyboardButton("🏠 القائمة الرئيسية", callback_data="main_menu")], - ])) - else: - await message.reply("❌ الاسم فارغ.") - # Multi-Bot Expansion: Add Website - elif state == "waiting_website_add": - parts = [p.strip() for p in text.split("|", 1)] - url = parts[0] - selector = parts[1] if len(parts) > 1 else "body" - if not url.startswith("http"): - await message.reply("❌ الرابط يجب أن يبدأ بـ http:// أو https://") - return - ok = db.add_website(url, selector=selector) - if ok: - await message.reply("✅ تمت إضافة الموقع.", reply_markup=InlineKeyboardMarkup([ - [InlineKeyboardButton("🌐 إدارة المواقع", callback_data="web_list")], - [InlineKeyboardButton("🏠 القائمة الرئيسية", callback_data="main_menu")], - ])) - else: - await message.reply("❌ فشل إضافة الموقع.") - # Multi-Bot Expansion: Edit Website - elif state == "waiting_website_edit": - wid = user_states[uid].get("website_id", "") - parts = [p.strip() for p in text.split("|", 1)] - url = parts[0] - selector = parts[1] if len(parts) > 1 else "body" - if not url.startswith("http"): - await message.reply("❌ الرابط يجب أن يبدأ بـ http:// أو https://") - return - ok = db.update_website(wid, url=url, selector=selector) - if ok: - await message.reply("✅ تم تحديث الموقع.", reply_markup=InlineKeyboardMarkup([ - [InlineKeyboardButton("🌐 رجوع للموقع", callback_data=f"web_show_{wid}")], - [InlineKeyboardButton("🏠 القائمة الرئيسية", callback_data="main_menu")], - ])) - else: - await message.reply("❌ فشل تحديث الموقع.") - except Exception as e: - logger.error(f"خطأ في إدخال المستخدم: {e}") - await message.reply(f"❌ فشل التنفيذ: {e}") - finally: - user_states.pop(uid, None) - -def _to_int_set(values): - result = set() - for value in values or []: - try: - result.add(int(value)) - except Exception: - pass - return result - - -def get_special_source_map(): - """يرجع خريطة المصادر الخاصة: source_id -> set(channel_ids).""" - mapping = {} - for ch in db.get_all_channels(): - if ch.get("paused"): - continue - try: - ch_id = int(ch["id"]) - except Exception: - continue - for src in ch.get("special_sources") or []: - try: - src_id = int(src) - except Exception: - continue - mapping.setdefault(src_id, set()).add(ch_id) - return mapping - - -def get_polling_sources(): - """يجمع مصادر الفحص: المصادر العامة + المصادر الخاصة، بدون تكرار.""" - sources = set() - for src in db.get_public_sources() or []: - try: - sources.add(int(src)) - except Exception: - pass - for src in get_special_source_map().keys(): - sources.add(int(src)) - return sorted(sources) - - -def source_kind_label(source_id): - """نوع المصدر للّوج: خاص يغلب على العام إذا مضاف بالمكانين.""" - source_id = int(source_id) - if source_id in get_special_source_map(): - return "خاص" - if source_id in _to_int_set(db.get_public_sources()): - return "عام" - return "غير معروف" - - -def get_targets_for_source(source_id): - """يرجع قنوات النشر التي يجب أن تستقبل منشوراً من هذا المصدر. - - القاعدة: - - إذا المصدر مضاف كمصدر خاص لأي قناة: ينشر فقط للقنوات التي أضافته. - - إذا المصدر عام فقط: ينشر فقط للقنوات العامة التي لا تحتوي مصادر خاصة. - - إذا المصدر موجود بالعام والخاص بالغلط: الخاص يغلب. - """ - source_id = int(source_id) - all_channels = db.get_all_channels() - public_sources = _to_int_set(db.get_public_sources()) - special_map = get_special_source_map() - target_ids = set() - - # الخاص يغلب على العام - if source_id in special_map: - target_ids.update(special_map.get(source_id, set())) - logger.info(f"🟢 المصدر {source_id} خاص؛ القنوات المستهدفة: {sorted(target_ids)}") - return target_ids - - # مصدر عام: فقط القنوات التي لا تمتلك مصادر خاصة - if source_id in public_sources: - for ch in all_channels: - if ch.get("paused"): - continue - specials = ch.get("special_sources") or [] - if specials: - continue - try: - target_ids.add(int(ch["id"])) - except Exception as e: - logger.error(f"تعذر تحويل قناة النشر إلى رقم: {ch.get('id')} | {e}") - return target_ids - - return target_ids - - -def blocked_word_in_text(text): - if not text: - return None - for word in db.get_blocked_words(): - if word and word in text: - return word - return None - - -def section_scope_for_channel(channel_id): - """معرف قسم/قناة النشر المستخدم لنطاق Dedup المشترك. القسم الجديد ينشأ تلقائياً.""" - return f"ch_{int(channel_id)}" - - -def section_label_for_channel(channel_id): - """تسمية القسم لأغراض اللوج (اسم القناة)، بدون أي Secrets.""" - try: - ch = db.get_channel(channel_id) - if ch and ch.get("name"): - return str(ch["name"]).strip()[:40] - except Exception: - pass - return str(channel_id) - - -def scopes_for_source(source_id): - """نطاقات Dedup التي ينشر إليها المصدر (واحد لكل قناة نشر).""" - return [section_scope_for_channel(t) for t in sorted(get_targets_for_source(source_id))] - - -def check_global_blocked_words_raw(raw_text): - """Early reject: فحص الكلمات المحظورة على النص الخام قبل أي تنظيف أو معالجة. - يدمج القائمة العامة مع اتحاد كلمات القنوات المحظورة لأن الرفض المبكر يمنع - معالجة أي منشور ستحظره أي قناة. يرجع الكلمة المحظورة أو None.""" - if not raw_text: - return None - for word in (db.get_blocked_words() if hasattr(db, "get_blocked_words") else []): - if word and word in raw_text: - return word - try: - if hasattr(db, "get_all_channels"): - for ch in db.get_all_channels() or []: - ch_id = ch.get("id") if isinstance(ch, dict) else None - if ch_id is None: - continue - for word in (db.get_channel_blocked_words(ch_id) if hasattr(db, "get_channel_blocked_words") else []): - if word and word in raw_text: - return word - except Exception: - pass - return None - - -def unsupported_media_reason(message): - """يرجع سبب التجاهل إذا كان المنشور يحتوي ملصق أو صورة متحركة/GIF.""" - if getattr(message, "sticker", None): - return "ملصق" - if getattr(message, "animation", None): - return "صورة متحركة / GIF" - return None - - -def mark_middle_auto_forward_window(seconds=4): - """يمنع middle_channel_handler من نشر النسخة التي وصلت للوسيطة تلقائياً حتى لا تتكرر.""" - global _middle_auto_ignore_until - _middle_auto_ignore_until = max(_middle_auto_ignore_until, time.monotonic() + seconds) - - -def is_middle_auto_forward_window_active(): - return time.monotonic() <= _middle_auto_ignore_until - - -async def publish_source_message(message, source_id, origin="updates"): - raw_text = message.text or message.caption or "" - source_id = int(source_id) - content_type = content_type_for_message(message) - message_id = getattr(message, "id", None) - - # حجز ذري دائم (source_id:message_id) قبل أي فحص أو نشر — يُستخدم من مساري - # updates وpolling معاً حتى لا تمر نفس الرسالة مرتين. داخل قفل قاعدة البيانات - # فلا يوجد سباق TOCTOU بين المسارين. - if message_id is not None and hasattr(db, "claim_source_event"): - try: - claimed_now = db.claim_source_event(source_id, message_id) - except Exception: - claimed_now = True - if not claimed_now: - logger.info(f"🔒 [{origin}] الحدث محجوز مسبقاً من مسار آخر: source={source_id} message_id={message_id} (تم تمريره للفحص الداخلي لعدم فقدانه)") - - if hasattr(db, "is_maintenance_mode") and db.is_maintenance_mode(): - logger.info(f"🛠️ [{origin}] وضع الصيانة مفعل؛ تم تجاهل النشر وتحديث آخر رسالة للمصدر {source_id}") - if hasattr(db, "record_source_event"): - db.record_source_event(source_id, "ignored", "maintenance mode", getattr(message, "id", None)) - return True - - if is_source_paused(source_id): - logger.info(f"⏸️ [{origin}] مصدر موقوف تم تجاهله: {source_id}") - if hasattr(db, "record_source_event"): - db.record_source_event(source_id, "ignored", "source paused", getattr(message, "id", None)) - return True - - if not is_content_allowed_for_source(source_id, content_type): - logger.info(f"⛔ [{origin}] تم تجاهل منشور من {source_id} بسبب فلتر نوع المحتوى: {content_type}") - if hasattr(db, "record_source_event"): - db.record_source_event(source_id, "ignored", f"content type blocked: {content_type}", getattr(message, "id", None)) - return True - - # Early reject: فحص الكلمات المحظورة على النص الخام قبل أي تنظيف أو معالجة - # (لا تنظيف، لا normalization، لا AI، لا Dedup). لا استهلاك رموز AI لمنشور مرفوض. - raw_blocked_word = check_global_blocked_words_raw(message.text or message.caption or "") - if raw_blocked_word: - logger.info(f"⛔ Blocked before processing: blocked_word='{raw_blocked_word}' source={source_id} message_id={message_id}") - if hasattr(db, "record_source_event"): - db.record_source_event(source_id, "rejected", f"blocked before processing: {raw_blocked_word}", message_id) - return True - - raw_text = full_clean_text(raw_text, source_id=source_id) - - if message_id is not None and hasattr(db, "has_recent_source_event"): - try: - if db.has_recent_source_event(source_id, message_id): - logger.info(f"🔁 [{origin}] نفس الحدث من المصدر {source_id} تمت معالجته سابقاً: message_id={message_id}") - if hasattr(db, "record_source_event"): - db.record_source_event(source_id, "duplicate", "same source event", message_id) - return True - except Exception: - pass - - logger.info(f"📨 [{origin}] رسالة من {source_id} | type={content_type} | message_id={message_id} | نص: {str(raw_text)[:80]}...") - - target_ids = get_targets_for_source(source_id) - if not target_ids: - logger.info("🟡 لا توجد قنوات مستهدفة لهذا المصدر، تم اعتبار المنشور معالجاً حتى لا يتكرر.") - return True - - # ملاحظة: فحص الكلمة المحظورة الخاص بكل قناة ما زال يتم على حدة داخل حلقة - # الإرسال أدناه (عبر clean_text_for_source)، لأن كل قناة لها قائمتها المستقلة. - - unsupported_reason = unsupported_media_reason(message) - if unsupported_reason: - logger.info(f"⛔ تم تجاهل المنشور لأنه يحتوي {unsupported_reason}") - if hasattr(db, "record_source_event"): - db.record_source_event(source_id, "ignored", f"unsupported: {unsupported_reason}", getattr(message, "id", None)) - return True - - if raw_text: - if message.media: - duplicated, duplicate_score = is_smart_duplicate(raw_text, threshold=0.75) - duplicate_reason = "similarity" - else: - # Dedup مشترك حسب القسم: المقارنة مع نطاق كل قناة نشر مستهدفة. - duplicated = False - duplicate_reason = "none" - duplicate_score = 0.0 - for target in sorted(target_ids): - scope_id = section_scope_for_channel(target) - duplicated, duplicate_reason, duplicate_score = is_hybrid_news_duplicate( - raw_text, source_id=source_id, message_id=message_id, threshold=0.75, scope_id=scope_id - ) - if duplicated: - # حجز ذري على مستوى القسم: أول وصول يفوز والثاني يُرفض. - event_fp = make_event_fp(raw_text) - claim_won = check_section_claim(scope_id, event_fp or f"fp:{duplicate_reason}") - logger.info(f"🔁 Duplicate detected: scope={section_label_for_channel(target)} reason={duplicate_reason} source={source_id} message_id={message_id} claim_won={claim_won}") - if hasattr(db, "record_source_event"): - db.record_source_event(source_id, "duplicate", f"section:{duplicate_reason}", message_id) - return True - # حجز ذري وقائي للنص الجديد في هذا القسم (يمنع سباق المصادر المختلفة). - event_fp = make_event_fp(raw_text) - if not check_section_claim(scope_id, event_fp or f"fp:new:{message_id}"): - logger.info(f"🔁 [{origin}] الخبر محجوز مسبقاً في قسم {section_label_for_channel(target)} من مصدر آخر: source={source_id} message_id={message_id}") - duplicated = True - break - if duplicated: - return True - logger.info(f"✅ New event accepted: scopes={[section_label_for_channel(t) for t in target_ids]} source={source_id} message_id={message_id}") - - # تسجيل الحدث "مستلم" بعد اجتياز فحوصات التكرار — - # تسجيله قبل الفحص كان يجعل أول منشور نصي يُكتشف كمكرر لنفسه. - if hasattr(db, "record_source_event"): - db.record_source_event(source_id, "received", content_type, message_id) - - if message.media: - try: - mark_middle_auto_forward_window() - fwd = await forward_to_middle(message.chat.id, message.id) - mark_middle_auto_forward_window() - await asyncio.sleep(0.5) - middle_messages = fwd if isinstance(fwd, list) else [fwd] - middle_messages = [m for m in middle_messages if m] - logger.info(f"✅ تم تمرير منشور ميديا إلى الوسيطة | ids={[m.id for m in middle_messages]}") - except Exception as e: - logger.error(f"فشل التوجيه للوسيطة من المصدر {source_id}: {e}") - await notify_admins(f"فشل توجيه رسالة من {source_id} إلى الوسيطة: {e}") - return False - - any_success = False - any_logical_rejection = False - for target in target_ids: - try: - ch = db.get_channel(target) - if not ch: - logger.warning(f"القناة {target} غير موجودة في قاعدة البيانات") - continue - - if not await _verify_channel_before_publish(target): - logger.warning(f"⛔ القناة {target} غير قابلة للوصول، تم تخطيها") - continue - - cleaned = clean_text_for_source(raw_text, source_id, channel_id=target) - if cleaned is None: - logger.info("⛔ النص بعد التنظيف مرفوض لكلمة محظورة") - any_logical_rejection = True - continue - final = compose_channel_post_text(cleaned, target, ch.get("tail", "")) - formatted_caption, parse_mode, caption_entities = format_outgoing_payload_for_channel(final, target, content_type, news_text=cleaned, tail=ch.get("tail", "")) if final else (None, None, None) - ok = await copy_middle_messages_to_target(target, middle_messages, formatted_caption, parse_mode=parse_mode, content_type=content_type, caption_entities=caption_entities) - if ok: - db.increment_post_count(target) - record_channel_success(target) - any_success = True - await asyncio.sleep(get_publish_delay_for_channel(target)) - - except Exception as e: - logger.error(f"نشر فاشل إلى {target}: {e}") - await record_channel_failure_and_maybe_alert(target, e) - await notify_admins(f"فشل النشر إلى {target}: {e}") - - if any_success and raw_text: - for target in target_ids: - remember_published_text( - raw_text, source_id=source_id, message_id=message_id, - scope_id=section_scope_for_channel(target), section_label=section_label_for_channel(target), - ) - processed = any_success or any_logical_rejection - if hasattr(db, "record_source_event"): - db.record_source_event(source_id, "published" if any_success else "rejected" if any_logical_rejection else "error", "media" if any_success else "media publish failed" if not any_logical_rejection else "media rejected", getattr(message, "id", None), len(target_ids)) - if any_logical_rejection and not any_success: - logger.info("✅ تم رفض المنشور منطقياً في كل القنوات، تم اعتباره معالجاً لتحديث last_seen.") - return processed - - else: - any_success = False - any_logical_rejection = False - for target in target_ids: - try: - ch = db.get_channel(target) - if not ch: - logger.warning(f"القناة {target} غير موجودة في قاعدة البيانات") - continue - - if not await _verify_channel_before_publish(target): - logger.warning(f"⛔ القناة {target} غير قابلة للوصول، تم تخطيها") - continue - - cleaned = clean_text_for_source(raw_text, source_id, channel_id=target) - if cleaned is None: - logger.info("⛔ النص بعد التنظيف مرفوض لكلمة محظورة") - any_logical_rejection = True - continue - if raw_text and should_ignore_short_post_for_channel(cleaned, target): - logger.info(f"⛔ منشور قصير مرفوض للقناة {target}") - any_logical_rejection = True - continue - - final = compose_channel_post_text(cleaned, target, ch.get("tail", "")) - - if final: - try: - await hydrate_publish_channel(target) - except Exception as e: - logger.warning(f"⚠️ فشل تهيئة قناة النشر {target} قبل النص: {e}") - formatted, parse_mode, entities = format_outgoing_payload_for_channel(final, target, "text", news_text=cleaned, tail=ch.get("tail", "")) - sent_msg = await safe_send_channel_message(target, formatted, parse_mode=parse_mode, entities=entities) - if hasattr(db, "record_published_message") and getattr(sent_msg, "id", None): - db.record_published_message(target, sent_msg.id, "text") - else: - logger.info("🟡 منشور نصي فارغ بعد التنظيف؛ لن يتم إرساله.") - continue - - db.increment_post_count(target) - record_channel_success(target) - any_success = True - logger.info(f"✅ نُشر نص إلى {target}") - await asyncio.sleep(get_publish_delay_for_channel(target)) - - except Exception as e: - logger.error(f"إرسال فاشل إلى {target}: {e}") - await record_channel_failure_and_maybe_alert(target, e) - await notify_admins(f"فشل الإرسال إلى {target}: {e}") - - if any_success and raw_text: - for target in target_ids: - remember_published_text( - raw_text, source_id=source_id, message_id=message_id, - scope_id=section_scope_for_channel(target), section_label=section_label_for_channel(target), - ) - processed = any_success or any_logical_rejection - if hasattr(db, "record_source_event"): - db.record_source_event(source_id, "published" if any_success else "rejected" if any_logical_rejection else "error", "text" if any_success else "text publish failed" if not any_logical_rejection else "text rejected", getattr(message, "id", None), len(target_ids)) - if any_logical_rejection and not any_success: - logger.info("✅ تم رفض النص منطقياً في كل القنوات، تم اعتباره معالجاً لتحديث last_seen.") - return processed - - -async def publish_source_album(messages, source_id, origin="polling"): - """ينشر مجموعة ميديا من المصدر إلى الوسيطة ثم إلى قنوات النشر بنفس ترتيب الرسائل.""" - if not messages: - return True - - messages = sorted(messages, key=lambda m: m.id) - first = messages[0] - raw_text = first.caption or first.text or "" - source_id = int(source_id) - content_type = "album" - - if hasattr(db, "is_maintenance_mode") and db.is_maintenance_mode(): - logger.info(f"🛠️ [{origin}] وضع الصيانة مفعل؛ تم تجاهل الألبوم وتحديث آخر رسالة للمصدر {source_id}") - if hasattr(db, "record_source_event"): - db.record_source_event(source_id, "ignored", "maintenance mode", getattr(first, "id", None)) - return True - - if is_source_paused(source_id): - logger.info(f"⏸️ [{origin}] مصدر موقوف تم تجاهله: {source_id}") - if hasattr(db, "record_source_event"): - db.record_source_event(source_id, "ignored", "source paused", ids[-1] if 'ids' in locals() and ids else None) - return True - - if not is_content_allowed_for_source(source_id, content_type): - logger.info(f"⛔ [{origin}] تم تجاهل ألبوم من {source_id} بسبب فلتر نوع المحتوى") - if hasattr(db, "record_source_event"): - db.record_source_event(source_id, "ignored", "content type blocked: album", ids[-1] if 'ids' in locals() and ids else None) - return True - - # Early reject: فحص الكلمات المحظورة على النص الخام قبل أي معالجة (مثل النصوص). - raw_album_blocked = check_global_blocked_words_raw(raw_text) - if raw_album_blocked: - logger.info(f"⛔ Blocked before processing: blocked_word='{raw_album_blocked}' source={source_id} album") - if hasattr(db, "record_source_event"): - db.record_source_event(source_id, "rejected", f"blocked before processing: {raw_album_blocked}", getattr(first, "id", None)) - return True - - raw_text = full_clean_text(raw_text, source_id=source_id) - ids = [m.id for m in messages] - - album_event_id = ids[-1] if ids else None - - # حجز ذري دائم للألبوم (source_id:last_message_id) قبل أي فحص أو نشر — - # يغلق تكرار الألبوم بين مسار updates وpolling ومنع سباق TOCTOU. - if album_event_id is not None and hasattr(db, "claim_source_event"): - try: - album_claimed_now = db.claim_source_event(source_id, album_event_id) - except Exception: - album_claimed_now = True - if not album_claimed_now: - logger.info(f"🔒 [{origin}] الألبوم محجوز مسبقاً من مسار آخر: source={source_id} message_id={album_event_id}") - if hasattr(db, "record_source_event"): - db.record_source_event(source_id, "duplicate", "album event already claimed", album_event_id) - return True - - if album_event_id is not None and hasattr(db, "has_recent_source_event"): - try: - if db.has_recent_source_event(source_id, album_event_id): - logger.info(f"🔁 [{origin}] نفس الألبوم من المصدر {source_id} تمت معالجته سابقاً: message_id={album_event_id}") - if hasattr(db, "record_source_event"): - db.record_source_event(source_id, "duplicate", "same source album event", album_event_id) - return True - except Exception: - pass - - logger.info(f"📨 [{origin}] ألبوم من {source_id} | message_ids={ids} | نص: {str(raw_text)[:80]}...") - - target_ids = get_targets_for_source(source_id) - if not target_ids: - logger.info("🟡 لا توجد قنوات مستهدفة لهذا الألبوم، تم اعتباره معالجاً حتى لا يتكرر.") - return True - - # فحص الكلمة المحظورة الخاص بكل قناة ما زال يتم على حدة داخل حلقة النشر أدناه. - - for item in messages: - unsupported_reason = unsupported_media_reason(item) - if unsupported_reason: - logger.info(f"⛔ تم تجاهل الألبوم لأنه يحتوي {unsupported_reason}") - return True - - if raw_text: - # Dedup مشترك حسب القسم: المقارنة مع نطاق كل قناة نشر مستهدفة (48 ساعة). - target_ids_for_dedup = get_targets_for_source(source_id) - duplicated = False - for target in sorted(target_ids_for_dedup): - scope_id = section_scope_for_channel(target) - duplicated, dup_reason, dup_score = is_hybrid_news_duplicate( - raw_text, source_id=source_id, message_id=album_event_id, threshold=0.75, scope_id=scope_id - ) - if duplicated: - event_fp = make_event_fp(raw_text) - claim_won = check_section_claim(scope_id, event_fp or f"fp:{dup_reason}") - logger.info(f"🔁 Duplicate detected: scope={section_label_for_channel(target)} reason={dup_reason} source={source_id} album claim_won={claim_won}") - if hasattr(db, "record_source_event"): - db.record_source_event(source_id, "duplicate", f"section:{dup_reason}", album_event_id) - return True - event_fp = make_event_fp(raw_text) - if not check_section_claim(scope_id, event_fp or f"fp:new:{album_event_id}"): - logger.info(f"🔁 [{origin}] ألبوم محجوز مسبقاً في قسم {section_label_for_channel(target)} من مصدر آخر: source={source_id}") - duplicated = True - break - if duplicated: - return True - logger.info(f"✅ New album accepted: scopes={[section_label_for_channel(t) for t in target_ids_for_dedup]} source={source_id}") - - # تسجيل الألبوم "مستلم" بعد اجتياز فحوصات التكرار. - if hasattr(db, "record_source_event"): - db.record_source_event(source_id, "received", "album", album_event_id) - - try: - mark_middle_auto_forward_window() - fwd = await forward_to_middle(source_id, ids) - mark_middle_auto_forward_window() - await asyncio.sleep(0.8) - middle_messages = fwd if isinstance(fwd, list) else [fwd] - middle_messages = sorted([m for m in middle_messages if m], key=lambda m: m.id) - logger.info(f"✅ تم تمرير الألبوم إلى الوسيطة | middle_ids={[m.id for m in middle_messages]}") - except Exception as e: - logger.error(f"فشل توجيه الألبوم للوسيطة من المصدر {source_id}: {e}") - await notify_admins(f"فشل توجيه ألبوم من {source_id} إلى الوسيطة: {e}") - return False - - any_success = False - for target in target_ids: - try: - ch = db.get_channel(target) - if not ch: - logger.warning(f"القناة {target} غير موجودة في قاعدة البيانات") - continue - - if not await _verify_channel_before_publish(target): - logger.warning(f"⛔ القناة {target} غير قابلة للوصول، تم تخطي الألبوم") - continue - - cleaned = clean_text_for_source(raw_text, source_id, channel_id=target) - if cleaned is None: - logger.info("⛔ النص بعد التنظيف مرفوض لكلمة محظورة") - continue - final = compose_channel_post_text(cleaned, target, ch.get("tail", "")) - formatted_caption, parse_mode, caption_entities = format_outgoing_payload_for_channel(final, target, "album", news_text=cleaned, tail=ch.get("tail", "")) if final else (None, None, None) - ok = await copy_middle_messages_to_target(target, middle_messages, formatted_caption, parse_mode=parse_mode, content_type=content_type, caption_entities=caption_entities) - if ok: - db.increment_post_count(target) - record_channel_success(target) - any_success = True - await asyncio.sleep(get_publish_delay_for_channel(target)) - - except Exception as e: - logger.error(f"نشر الألبوم فاشل إلى {target}: {e}") - await record_channel_failure_and_maybe_alert(target, e) - await notify_admins(f"فشل نشر ألبوم إلى {target}: {e}") - - if any_success and raw_text: - for target in target_ids: - remember_published_text( - raw_text, source_id=source_id, message_id=album_event_id, - scope_id=section_scope_for_channel(target), section_label=section_label_for_channel(target), - ) - if hasattr(db, "record_source_event"): - db.record_source_event(source_id, "published" if any_success else "error", "album" if any_success else "album publish failed", album_event_id, len(target_ids)) - if not any_success: - logger.warning(f"⚠️ تم تمرير الألبوم للوسيطة لكن فشل نشره للقنوات. سيتم تحديث last_seen حتى لا يتكرر بالوسيطة: {getattr(first, 'media_group_id', '')}") - return True - - -async def user_message_handler(client, message): - pass # log muted: user update received - - if not message.chat or message.chat.type not in ("channel", "supergroup"): - return - - await publish_source_message(message, int(message.chat.id), origin="updates") - - -async def poll_public_sources_loop(): - """يفحص المصادر العامة والخاصة دورياً، لأن Updates لا تصل أحياناً على Hugging Face.""" - await asyncio.sleep(8) - logger.info("🔁 بدأ نظام الفحص الدوري للمصادر العامة والخاصة Polling") - - while True: - try: - mark_runtime_activity("poll") - poll_sources = get_polling_sources() - if not poll_sources: - await asyncio.sleep(25) - continue - - for source in poll_sources: - try: - source_id = int(source) - except Exception: - logger.warning(f"مصدر غير رقمي تم تجاهله: {source}") - continue - - kind = source_kind_label(source_id) - - if is_source_paused(source_id): - continue - - try: - last_seen = db.get_last_source_message(source_id) - messages = [] - - async for msg in user_client.get_chat_history(source_id, limit=8): - if not msg or not getattr(msg, "id", None): - continue - messages.append(msg) - mark_runtime_activity("poll") - - if not messages: - continue - - newest_id = max(m.id for m in messages) - - if last_seen is None: - db.set_last_source_message(source_id, newest_id) - logger.info(f"🔁 [{kind}] المصدر {source_id}: أول تشغيل، تم حفظ آخر رسالة {newest_id} بدون نشر القديم.") - continue - - new_messages = [m for m in messages if m.id > int(last_seen)] - new_messages.sort(key=lambda m: m.id) - - if not new_messages: - continue - - logger.info(f"🔁 [{kind}] المصدر {source_id}: تم العثور على {len(new_messages)} منشور جديد.") - - processed_ids = set() - i = 0 - while i < len(new_messages): - msg = new_messages[i] - if msg.id in processed_ids: - i += 1 - continue - - media_group_id = getattr(msg, "media_group_id", None) - origin = f"polling][{kind}" - - if media_group_id: - group = [m for m in new_messages if getattr(m, "media_group_id", None) == media_group_id] - group.sort(key=lambda m: m.id) - ok = await publish_source_album(group, source_id, origin=origin) - if ok: - for gm in group: - processed_ids.add(gm.id) - db.set_last_source_message(source_id, max(gm.id for gm in group)) - else: - logger.warning(f"⚠️ لم يتم تحديث last_seen للألبوم {media_group_id} حتى يعاد المحاولة لاحقاً") - await asyncio.sleep(1) - else: - ok = await publish_source_message(msg, source_id, origin=origin) - if ok: - processed_ids.add(msg.id) - db.set_last_source_message(source_id, msg.id) - else: - logger.warning(f"⚠️ لم يتم تحديث last_seen للرسالة {msg.id} حتى تعاد المحاولة لاحقاً") - await asyncio.sleep(1) - i += 1 - - except Exception as e: - logger.exception(f"خطأ أثناء فحص المصدر [{kind}] {source}: {e}") - if is_transient_network_error(e): - await reconnect_user_client_if_needed(e) - await asyncio.sleep(10) - else: - await notify_admins(f"فشل فحص المصدر [{kind}] {source}: {e}") - - await asyncio.sleep(25) - - except Exception as e: - logger.exception(f"خطأ عام في حلقة Polling: {e}") - await asyncio.sleep(30) - - -async def publish_middle_messages_to_all(middle_messages): - """ينشر منشوراً أو ألبوماً منشوراً يدوياً في الوسيطة إلى كل قنوات النشر.""" - if not isinstance(middle_messages, (list, tuple)): - middle_messages = [middle_messages] - middle_messages = sorted([m for m in middle_messages if m], key=lambda m: m.id) - if not middle_messages: - return - - first = middle_messages[0] - raw_text = full_clean_text(first.text or first.caption or "") - - if hasattr(db, "is_maintenance_mode") and db.is_maintenance_mode(): - logger.info("🛠️ وضع الصيان�� مفعل؛ تم تجاهل النشر اليدوي من الوسيطة.") - return - - for item in middle_messages: - unsupported_reason = unsupported_media_reason(item) - if unsupported_reason: - logger.info(f"⛔ تم تجاهل رسالة الوسيطة لأنها تحتوي {unsupported_reason}") - return - - # فحص الكلمة المحظورة صار لكل قناة على حدة داخل الحلقة أدناه (clean_text(channel_id=target)). - - all_channels = db.get_all_channels() - is_album = len(middle_messages) > 1 - - for ch in all_channels: - if ch.get("paused"): - continue - - target = int(ch["id"]) - - if not await _verify_channel_before_publish(target): - logger.warning(f"⛔ القناة {target} غير قابلة للوصول، تم تخطي النشر اليدوي") - continue - - try: - cleaned = clean_text(raw_text, channel_id=target) if raw_text else "" - if cleaned is None: - continue - if raw_text and not is_album and not first.media and should_ignore_short_post_for_channel(cleaned, target): - logger.info(f"⛔ منشور وسيطة قصير مرفوض للقناة {target}") - continue - - final = compose_channel_post_text(cleaned, target, ch.get("tail", "")) - - if is_album: - formatted_caption, parse_mode, caption_entities = format_outgoing_payload_for_channel(final, target, "album", news_text=cleaned, tail=ch.get("tail", "")) if final else (None, None, None) - ok = await copy_middle_messages_to_target(target, middle_messages, formatted_caption, parse_mode=parse_mode, content_type="album", caption_entities=caption_entities) - if not ok: - continue - elif first.media: - try: - await hydrate_publish_channel(target) - except Exception as e: - logger.warning(f"⚠️ فشل تهيئة قناة النشر {target} قبل نسخة الوسيطة: {e}") - middle_ctype = published_content_type_for_message(first, False) - formatted_caption, parse_mode, caption_entities = format_outgoing_payload_for_channel(final, target, middle_ctype, news_text=cleaned, tail=ch.get("tail", "")) if final else (None, None, None) - copy_kwargs = dict( - chat_id=target, - from_chat_id=config.MIDDLE_CHANNEL, - message_id=first.id, - caption=formatted_caption if formatted_caption else None, - parse_mode=None if caption_entities else parse_mode - ) - if caption_entities: - copy_kwargs["caption_entities"] = caption_entities - try: - sent = await _retry_on_flood(lambda: bot_client.copy_message(**copy_kwargs), label=f"middle→{target}") - except TypeError: - copy_kwargs.pop("caption_entities", None) - copy_kwargs["parse_mode"] = parse_mode - sent = await bot_client.copy_message(**copy_kwargs) - sent_msg = sent - if hasattr(db, "record_published_message") and getattr(sent_msg, "id", None): - db.record_published_message(target, sent_msg.id, published_content_type_for_message(first, False)) - else: - try: - await hydrate_publish_channel(target) - except Exception as e: - logger.warning(f"⚠️ فشل تهيئة قناة النشر {target} قبل نص الوسيطة: {e}") - formatted, parse_mode, entities = format_outgoing_payload_for_channel(final if final else ".", target, "text", news_text=cleaned, tail=ch.get("tail", "")) - sent_msg = await safe_send_channel_message(target, formatted, parse_mode=parse_mode, entities=entities) - if hasattr(db, "record_published_message") and getattr(sent_msg, "id", None): - db.record_published_message(target, sent_msg.id, "text") - - db.increment_post_count(target) - record_channel_success(target) - mark_runtime_activity("publish") - logger.info(f"✅ نُشر من الوسيطة إلى {target}") - await asyncio.sleep(get_publish_delay_for_channel(target)) - - except Exception as e: - logger.error(f"نشر فاشل من الوسيطة إلى {target}: {e}") - await record_channel_failure_and_maybe_alert(target, e) - await notify_admins(f"فشل النشر من الوسيطة إلى {target}: {e}") - - -async def flush_middle_album(media_group_id): - """يجمع ألبوم الوسيطة اليدوي ثم ينشره كألبوم واحد.""" - await asyncio.sleep(2.0) - messages = _middle_album_buffers.pop(media_group_id, []) - _middle_album_tasks.pop(media_group_id, None) - if not messages: - return - logger.info(f"📥 ألبوم في الوسيطة، جارٍ النشر كألبوم واحد | count={len(messages)}") - await publish_middle_messages_to_all(messages) - - -async def middle_channel_handler(client, message): - if int(message.chat.id) != int(config.MIDDLE_CHANNEL): - return - - if is_middle_auto_forward_window_active(): - logger.info(f"🟡 تم تجاهل رسالة وسيطة تلقائية لمنع التكرار | message_id={message.id}") - return - - media_group_id = getattr(message, "media_group_id", None) - if media_group_id: - key = str(media_group_id) - _middle_album_buffers.setdefault(key, []).append(message) - if key not in _middle_album_tasks: - _middle_album_tasks[key] = asyncio.create_task(flush_middle_album(key)) - return - - logger.info("📥 رسالة في الوسيطة، جارٍ النشر...") - await publish_middle_messages_to_all([message]) - -# ============================================================ -# Multi-Bot Expansion: Secrets Management UI -# ============================================================ - -async def secrets_menu(client, callback): - text = "🔐 **الأسرار والإعدادات**\n\n📱 الجلسات: حسابات Telegram المستخدمة للقراءة.\n🤖 بوتات النشر: البوتات التي تنشر في القنوات.\n🧠 مفاتيح AI: مزودو الذكاء الاصطناعي ومفاتيحهم.\n🌐 مواقع الويب: مصادر الجلب عبر الروابط.\n🗑 سلة المحذوفات: استعادة أو حذف العناصر نهائياً." - buttons = [ - [InlineKeyboardButton("📱 الجلسات", callback_data="sessions_list"), InlineKeyboardButton("🤖 بوتات النشر", callback_data="bots_list")], - [InlineKeyboardButton("🧠 مفاتيح AI", callback_data="ai_list"), InlineKeyboardButton("🌐 مواقع الويب", callback_data="web_list")], - [InlineKeyboardButton("🗑 سلة المحذوفات", callback_data="trash_menu")], - nav_row("main_menu"), - ] - await safe_edit(callback, text, InlineKeyboardMarkup(buttons)) - - -async def _show_secrets_list(client, callback, items, title, add_cb, item_cb_prefix, toggle_prefix, del_prefix, test_prefix, kind=""): - if not items: - text = f"{title}\n\nلا توجد عناصر." - buttons = [[InlineKeyboardButton("➕ إضافة", callback_data=add_cb)], nav_row("secrets_menu")] - else: - text = f"{title} ({len(items)}):\n\n✅ العناصر المفعلة تعمل في النشر أو الجلب.\n❌ العناصر المعطلة محفوظة لكنها لا تعمل حالياً.\n" - btns = [] - for item in items: - name = item.get("name", item.get("id", "")) - enabled = "✅" if item.get("enabled", True) else "❌" - btns.append(InlineKeyboardButton(f"{enabled} {name}", callback_data=f"{item_cb_prefix}{item.get('id')}")) - rows = grid_buttons(btns, 2) - rows.append([InlineKeyboardButton("➕ إضافة", callback_data=add_cb)]) - rows.append(nav_row("secrets_menu")) - await safe_edit(callback, text, InlineKeyboardMarkup(rows)) - return - await safe_edit(callback, text, InlineKeyboardMarkup(buttons)) - - -# --- Sessions --- - -async def sessions_list_menu(client, callback): - items = db.get_all_sessions() - text = "📱 **الجلسات**\n\n✅ الجلسة المفعلة متاحة لقراءة المصادر.\n❌ الجلسة المعطلة لا تُستخدم حتى تعيد تشغيلها.\n\n" - if not items: - text += "لا توجد جلسات." - buttons = [[InlineKeyboardButton("➕ إضافة جلسة", callback_data="session_add")], nav_row("secrets_menu")] - await safe_edit(callback, text, InlineKeyboardMarkup(buttons)) - return - btns = [] - for s in items: - status = "✅" if s.get("enabled", True) else "❌" - btns.append(InlineKeyboardButton(f"{status} {s.get('name', s['id'])}", callback_data=f"session_show_{s['id']}")) - rows = grid_buttons(btns, 2) - rows.append([InlineKeyboardButton("➕ إضافة جلسة", callback_data="session_add")]) - rows.append(nav_row("secrets_menu")) - text += f"العدد: {len(items)}" - await safe_edit(callback, text, InlineKeyboardMarkup(rows)) - - -async def add_session_prompt(client, callback): - uid = callback.from_user.id - user_states.pop(uid, None) - user_states[uid] = {"state": "waiting_session_add"} - await safe_edit(callback, "✏️ أرسل بيانات الجلسة بهذا التنسيق:\n\n`API_ID | API_HASH | SESSION_STRING`\n\nفي سطر واحد.", InlineKeyboardMarkup([nav_row("sessions_list")])) - - -async def show_session_settings(client, callback): - sid = callback.data.split("_", 2)[2] - s = db.get_session(sid) - if not s: - await callback.answer("الجلسة غير موجود��.") - return - deps = db.get_dependencies_for("session", sid) - deps_text = "\n".join(f"• {entity_name(d)}" for d in deps[:10]) if deps else "لا توجد" - stats = db.get_session_stats(sid) - text = ( - f"📱 **{s.get('name', sid)}**\n" - f"🆔 `{sid}`\n" - f"🔢 API ID: `{'***' if s.get('api_id') else '?'}`\n" - f"🔑 الحالة: {'✅ مفعل' if s.get('enabled', True) else '❌ معطل'}\n" - f"📊 الاستخدام: {stats.get('usage_count', 0)}\n" - f"❌ الأخطاء: {stats.get('error_count', 0)}\n" - f"🕐 آخر استخدام: {_format_ts(stats.get('last_used'))}\n" - f"📡 الحالة: {s.get('status', 'idle')}\n\n⏯️ تشغيل/إيقاف: تفعيل أو تعطيل استخدام الجلسة.\n🧪 اختبار: التحقق من اتصال الجلسة دون تغيير إعداداتها.\n🗑 حذف: نقل الجلسة إلى سلة المحذوفات.\n\n" - f"**القنوات المستخدمة:**\n{deps_text}" - ) - buttons = [ - [InlineKeyboardButton("⏯️ تشغيل/إيقاف", callback_data=f"session_toggle_{sid}")], - [InlineKeyboardButton("🧪 اختبار", callback_data=f"session_test_{sid}")], - [InlineKeyboardButton("🗑 حذف", callback_data=f"session_delete_{sid}")], - [InlineKeyboardButton("🔙 رجوع", callback_data="sessions_list"), InlineKeyboardButton("🏠 القائمة الرئيسية", callback_data="main_menu")], - ] - await safe_edit(callback, text, InlineKeyboardMarkup(buttons)) - - -async def toggle_session(client, callback): - sid = callback.data.split("_", 2)[2] - s = db.get_session(sid) - if not s: - await callback.answer("غير موجودة.") - return - db.set_session_enabled(sid, not s.get("enabled", True)) - await callback.answer("تم التحديث.") - callback.data = f"session_show_{sid}" - await show_session_settings(client, callback) - - -async def delete_session_with_deps(client, callback): - sid = callback.data.split("_", 2)[2] - s = db.get_session(sid) - if not s: - await callback.answer("غير موجودة.") - return - deps = db.get_dependencies_for("session", sid) - if deps: - dep_text = "\n".join(f"• {entity_name(d)}" for d in deps[:10]) - text = ( - f"⚠️ **لا يمكن حذف {s.get('name', sid)}**\n\n" - f"هذه الجلسة مستخدمة من قبل:\n{dep_text}\n\n" - f"قم بإزالتها من هذه القنوات أولاً." - ) - buttons = [nav_row(f"session_show_{sid}")] - await safe_edit(callback, text, InlineKeyboardMarkup(buttons)) - return - # Move to trash - db.delete_session(sid) - await callback.answer("✅ تم نقل الجلسة إلى سلة المحذوفات.") - await sessions_list_menu(client, callback) - - -async def test_session(client, callback): - sid = callback.data.split("_", 2)[2] - s = db.get_session(sid) - if not s: - await callback.answer("غير موجودة.") - return - await callback.answer(f"جاري اختبار {s.get('name', sid)}...", show_alert=False) - try: - from pyrogram import Client as PClient - test_client = PClient( - f"test_session_{sid}", - api_id=s.get("api_id", 0), - api_hash=s.get("api_hash", ""), - session_string=s.get("session_string", ""), - in_memory=True - ) - await test_client.start() - me = await test_client.get_me() - await test_client.stop() - db.record_session_usage(sid) - await callback.answer(f"✅ {s.get('name', sid)}: اتصال ناجح كـ {me.first_name or me.id}", show_alert=True) - except Exception as e: - await callback.answer(f"❌ فشل: {str(e)[:100]}", show_alert=True) - - -# --- AI Keys --- - -async def ai_list_menu(client, callback): - items = db.get_all_ai_keys() - text = "🧠 **مفاتيح AI**\n\n✅ المفتاح المفعّل متاح لطلبات الذكاء الاصطناعي.\n❌ المفتاح المعطّل محفوظ لكنه لا يُستخدم.\n\n" - if not items: - text += "لا توجد مفاتيح." - buttons = [[InlineKeyboardButton("➕ إضافة مفتاح", callback_data="ai_add")], nav_row("secrets_menu")] - await safe_edit(callback, text, InlineKeyboardMarkup(buttons)) - return - btns = [] - for k in items: - status = "✅" if k.get("enabled", True) else "❌" - btns.append(InlineKeyboardButton(f"{status} {k.get('name', k['id'])}", callback_data=f"ai_show_{k['id']}")) - rows = grid_buttons(btns, 2) - rows.append([InlineKeyboardButton("➕ إضافة مفتاح", callback_data="ai_add")]) - rows.append(nav_row("secrets_menu")) - text += f"العدد: {len(items)}" - await safe_edit(callback, text, InlineKeyboardMarkup(rows)) - - -async def add_ai_key_prompt(client, callback): - uid = callback.from_user.id - user_states.pop(uid, None) - user_states[uid] = {"state": "waiting_ai_key_add"} - await safe_edit(callback, "✏️ أرسل بيانات مفتاح AI بهذا التنسيق:\n\n`المزود | المفتاح`\n\nالمزود مثال: `gemini` أو `openai`\nمثال: `gemini | AIzaSy...`", InlineKeyboardMarkup([nav_row("ai_list")])) - - -async def show_ai_key_settings(client, callback): - kid = callback.data.split("_", 2)[2] - k = db.get_ai_key(kid) - if not k: - await callback.answer("غير موجود.") - return - deps = db.get_dependencies_for("ai_key", kid) - deps_text = "\n".join(f"• {entity_name(d)}" for d in deps[:10]) if deps else "لا توجد" - stats = db.get_ai_key_stats(kid) - text = ( - f"🧠 **{k.get('name', kid)}**\n" - f"🆔 `{kid}`\n" - f"🏢 المزود: {k.get('provider', '?')}\n" - f"🔑 الحالة: {'✅ مفعل' if k.get('enabled', True) else '❌ معطل'}\n" - f"📊 الاستخدام: {stats.get('usage_count', 0)}\n" - f"❌ الأخطاء: {stats.get('error_count', 0)}\n" - f"🕐 آخر استخدام: {_format_ts(stats.get('last_used'))}\n\n" - f"⏯️ تشغيل/إيقاف: التحكم في استخدام مفتاح {k.get('name', kid)}.\n🧪 اختبار: التحقق من اتصال المزود.\n🗑 حذف: نقل المفتاح إلى سلة المحذوفات.\n\n" - f"**القنوات المستخدمة:**\n{deps_text}" - ) - buttons = [ - [InlineKeyboardButton("⏯️ تشغيل/إيقاف", callback_data=f"ai_toggle_{kid}")], - [InlineKeyboardButton("🧪 اختبار", callback_data=f"ai_test_{kid}")], - [InlineKeyboardButton("🗑 حذف", callback_data=f"ai_delete_{kid}")], - [InlineKeyboardButton("🔙 رجوع", callback_data="ai_list"), InlineKeyboardButton("🏠 القائمة الرئيسية", callback_data="main_menu")], - ] - await safe_edit(callback, text, InlineKeyboardMarkup(buttons)) - - -async def toggle_ai_key(client, callback): - kid = callback.data.split("_", 2)[2] - k = db.get_ai_key(kid) - if not k: - await callback.answer("غير موجود.") - return - db.set_ai_key_enabled(kid, not k.get("enabled", True)) - await callback.answer("تم التحديث.") - callback.data = f"ai_show_{kid}" - await show_ai_key_settings(client, callback) - - -async def delete_ai_key_with_deps(client, callback): - kid = callback.data.split("_", 2)[2] - k = db.get_ai_key(kid) - if not k: - await callback.answer("غير موجود.") - return - deps = db.get_dependencies_for("ai_key", kid) - if deps: - dep_text = "\n".join(f"• {entity_name(d)}" for d in deps[:10]) - text = f"⚠️ **لا يمكن حذف {k.get('name', kid)}**\n\nمستخدم من قبل:\n{dep_text}\n\nقم بإزالتها من هذه القنوات أولاً." - buttons = [nav_row(f"ai_show_{kid}")] - await safe_edit(callback, text, InlineKeyboardMarkup(buttons)) - return - db.delete_ai_key(kid) - await callback.answer("✅ تم النقل إلى سلة المحذوفات.") - await ai_list_menu(client, callback) - - -async def test_ai_key(client, callback): - kid = callback.data.split("_", 2)[2] - k = db.get_ai_key(kid) - if not k: - await callback.answer("غير موجود.") - return - provider = k.get("provider", "").lower() - api_key = k.get("api_key", "") - if not api_key: - await callback.answer("❌ لا يوجد مفتاح.", show_alert=True) - return - await callback.answer(f"جاري اختبار {k.get('name', kid)}...", show_alert=False) - try: - if provider == "gemini": - import httpx - url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent?key={api_key}" - async with httpx.AsyncClient(timeout=10) as hc: - resp = await hc.post(url, json={"contents": [{"parts": [{"text": "hi"}]}]}) - if resp.status_code == 200: - db.record_ai_key_usage(kid) - await callback.answer("✅ اتصال ناجح!", show_alert=True) - else: - await callback.answer(f"❌ فشل: {resp.status_code}", show_alert=True) - else: - await callback.answer(f"✅ مزود {provider} مسجل.", show_alert=True) - except Exception as e: - await callback.answer(f"❌ فشل: {str(e)[:100]}", show_alert=True) - - -# --- Publishing Bots --- - -async def bots_list_menu(client, callback): - items = db.get_all_publishing_bots() - text = "🤖 **بوتات النشر**\n\n✅ البوت المفعّل متاح للنشر في القنوات المرتبطة.\n❌ البوت المعطّل لا ينشر حتى تعيد تشغيله.\n\n" - if not items: - text += "لا توجد بوتات." - buttons = [[InlineKeyboardButton("➕ إضافة بوت", callback_data="bot_add")], nav_row("secrets_menu")] - await safe_edit(callback, text, InlineKeyboardMarkup(buttons)) - return - btns = [] - for b in items: - status = "✅" if b.get("enabled", True) else "❌" - btns.append(InlineKeyboardButton(f"{status} {b.get('name', b['id'])}", callback_data=f"bot_show_{b['id']}")) - rows = grid_buttons(btns, 2) - rows.append([InlineKeyboardButton("➕ إضافة بوت", callback_data="bot_add")]) - rows.append(nav_row("secrets_menu")) - text += f"العدد: {len(items)}" - await safe_edit(callback, text, InlineKeyboardMarkup(rows)) - - -async def add_bot_prompt(client, callback): - uid = callback.from_user.id - user_states.pop(uid, None) - user_states[uid] = {"state": "waiting_bot_add"} - await safe_edit(callback, "✏️ أرسل توكن البوت:\n\nمثال:\n`123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`", InlineKeyboardMarkup([nav_row("bots_list")])) - - -async def show_bot_settings(client, callback): - bid = callback.data.split("_", 2)[2] - b = db.get_publishing_bot(bid) - if not b: - await callback.answer("غير موجود.") - return - deps_raw = db.get_dependencies_for("publishing_bot", bid) if hasattr(db, "get_dependencies_for") else [] - # استخدام BotChannelMapper للحصول على القنوات - if hasattr(db, "mapper") and hasattr(db.mapper, "get_channels_for_bot"): - mapped_ids = db.mapper.get_channels_for_bot(bid) - deps = [d for d in deps_raw if str(d.get("id", "")) in mapped_ids] if deps_raw else [] - if not deps_raw: - deps = [db.get_channel(cid) for cid in mapped_ids if db.get_channel(cid)] - else: - deps = deps_raw - deps_verified = verifier.get_cached_verifications_for_bot(bid) - deps_lines = [] - for d in deps: - ch_id = d.get("id", "") - ch_name = entity_name(d) - v = deps_verified.get(str(ch_id), {}) - vstatus = v.get("verified", False) - can_post = v.get("can_post", False) - last_check = v.get("last_check", 0) - if vstatus: - age = f" (منذ {int((time.time() - last_check) / 60)}د)" if last_check else "" - perm = "نشر:✅" if can_post else "نشر:❌" - deps_lines.append(f"✅ {ch_name} | {perm}{age}") - else: - deps_lines.append(f"❌ {ch_name} | غير موثوق") - deps_text = "\n".join(deps_lines) if deps_lines else "لا توجد" - stats = b.get("stats", {}) or {} - text = ( - f"🤖 **{b.get('name', bid)}**\n" - f"🆔 `{bid}`\n" - f"👤 @{b.get('username', '?')}\n" - f"🔑 الحالة: {'✅ مفعل' if b.get('enabled', True) else '❌ معطل'}\n" - f"📊 إجمالي النشرات: {b.get('publish_count', 0)}\n" - f" 📝 نصوص: {stats.get('text', 0)}\n" - f" 🖼 صور: {stats.get('photo', 0)}\n" - f" 🎥 فيديو: {stats.get('video', 0)}\n" - f" 🗂 البومات: {stats.get('album', 0)}\n" - f"❌ الأخطاء: {b.get('error_count', 0)}\n" - f"🕐 آخر نشر: {_format_ts(b.get('last_publish'))}\n\n" - f"⏯️ تشغيل/إيقاف: التحكم في استخدام البوت للنشر.\n✏️ إعادة تسمية: تغيير الاسم الظاهر للبوت فقط.\n🔍 فحص القنوات: التحقق من صلاحيات النشر في القنوات المرتبطة.\n🧪 اختبار: التحقق من توكن البوت واتصاله.\n🗑 حذف: نقل البوت إلى سلة المحذوفات.\n\n" - f"**القنوات المستخدمة:**\n{deps_text}" - ) - buttons = [ - [InlineKeyboardButton("⏯️ تشغيل/إيقاف", callback_data=f"bot_toggle_{bid}")], - [InlineKeyboardButton("✏️ إعادة تسمية", callback_data=f"bot_rename_{bid}")], - [InlineKeyboardButton("🔍 فحص القنوات", callback_data=f"bot_verify_{bid}")], - [InlineKeyboardButton("🧪 اختبار", callback_data=f"bot_test_{bid}")], - [InlineKeyboardButton("🗑 حذف", callback_data=f"bot_delete_{bid}")], - [InlineKeyboardButton("🔙 رجوع", callback_data="bots_list"), InlineKeyboardButton("🏠 القائمة الرئيسية", callback_data="main_menu")], - ] - await safe_edit(callback, text, InlineKeyboardMarkup(buttons)) - - -async def toggle_bot(client, callback): - bid = callback.data.split("_", 2)[2] - b = db.get_publishing_bot(bid) - if not b: - await callback.answer("غير موجود.") - return - db.set_publishing_bot_enabled(bid, not b.get("enabled", True)) - await callback.answer("تم التحديث.") - callback.data = f"bot_show_{bid}" - await show_bot_settings(client, callback) - - -async def delete_bot_with_deps(client, callback): - bid = callback.data.split("_", 2)[2] - b = db.get_publishing_bot(bid) - if not b: - await callback.answer("غير موجود.") - return - # استخدام BotChannelMapper - if hasattr(db, "mapper"): - dep_ch_ids = db.mapper.get_channels_for_bot(bid) - deps = [db.get_channel(cid) for cid in dep_ch_ids if db.get_channel(cid)] - else: - deps = db.get_dependencies_for("publishing_bot", bid) - if deps: - dep_text = "\n".join(f"• {entity_name(d)}" for d in deps[:10]) - text = f"⚠️ **لا يمكن حذف {b.get('name', bid)}**\n\nمستخدم من قبل:\n{dep_text}\n\nقم بإزالتها من هذه القنوات أولاً." - buttons = [nav_row(f"bot_show_{bid}")] - await safe_edit(callback, text, InlineKeyboardMarkup(buttons)) - return - db.delete_publishing_bot(bid) - await callback.answer("✅ تم النقل إلى سلة المحذوفات.") - await bots_list_menu(client, callback) - - -async def test_bot(client, callback): - bid = callback.data.split("_", 2)[2] - b = db.get_publishing_bot(bid) - if not b: - await callback.answer("غير موجود.") - return - token = db.bot_manager.get_token(bid) if hasattr(db, "bot_manager") else b.get("token", "") - if not token: - await callback.answer("❌ لا يوجد توكن.", show_alert=True) - return - await callback.answer(f"جاري اختبار {b.get('name', bid)}...", show_alert=False) - ver = await verifier.validate_token(token) - if ver.get("valid"): - username = f"@{ver['username']}" if ver.get("username") else ver.get("id", "") - db.update_publishing_bot(bid, username=ver.get("username", "")) - db.record_bot_publish(bid) - await callback.answer(f"✅ {b.get('name', bid)}: {username}", show_alert=True) - else: - await callback.answer(f"❌ فشل: {ver.get('error', 'غير معروف')}", show_alert=True) - - -async def verify_bot_channels(client, callback): - bid = callback.data.split("_", 2)[2] - b = db.get_publishing_bot(bid) - if not b: - await callback.answer("غير موجود.") - return - token = db.bot_manager.get_token(bid) if hasattr(db, "bot_manager") else b.get("token", "") - if not token: - await callback.answer("❌ لا يوجد توكن.", show_alert=True) - return - await callback.answer(f"🔍 جاري فحص القنوات للبوت {b.get('name', bid)}...", show_alert=False) - # استخدام BotChannelMapper للحصول على القنوات المرتبطة - if hasattr(db, "mapper"): - channel_ids = db.mapper.get_channels_for_bot(bid) - else: - raw_deps = db.get_dependencies_for("publishing_bot", bid) - channel_ids = [d.get("id", "") for d in raw_deps] - if not channel_ids: - await callback.answer("لا توجد قنوات مربوطة.", show_alert=True) - return - ok_count = 0 - fail_count = 0 - for ch_id in channel_ids: - ch = db.get_channel(ch_id) if hasattr(db, "get_channel") else None - ch_name = entity_name(ch) if ch else ch_id - try: - ver = await verifier.check_bot_in_channel(bid, ch_id, force=True) - if ver.get("verified") and ver.get("can_post"): - ok_count += 1 - else: - fail_count += 1 - except Exception as e: - fail_count += 1 - msg = f"🔍 نتائج فحص {b.get('name', bid)}:\n✅ موثوق: {ok_count}\n❌ غير موثوق: {fail_count}" - await callback.answer(msg, show_alert=True) - callback.data = f"bot_show_{bid}" - await show_bot_settings(client, callback) - - -async def rename_bot_prompt(client, callback): - bid = callback.data.split("_", 2)[2] - b = db.get_publishing_bot(bid) - if not b: - await callback.answer("غير موجود.") - return - uid = callback.from_user.id - user_states[uid] = {"state": "waiting_bot_rename", "bot_id": bid} - await safe_edit(callback, f"✏️ أرسل الاسم الجديد للبوت **{b.get('name', bid)}**:\n\nالاسم الحالي: `{b.get('name', bid)}`\nاليوزر: @{b.get('username', '')}", InlineKeyboardMarkup([nav_row(f"bot_show_{bid}")])) - - -# --- Websites --- - -async def web_list_menu(client, callback): - items = db.get_all_websites() - text = "🌐 **مواقع الويب**\n\n✅ الموقع المفعّل يدخل ضمن دورات الجلب.\n❌ الموقع المعطّل محفوظ لكنه لا يُجلب.\n\n" - if not items: - text += "لا توجد مواقع." - buttons = [[InlineKeyboardButton("➕ إضافة موقع", callback_data="web_add")], nav_row("secrets_menu")] - await safe_edit(callback, text, InlineKeyboardMarkup(buttons)) - return - btns = [] - for w in items: - status = "✅" if w.get("enabled", True) else "❌" - btns.append(InlineKeyboardButton(f"{status} {w.get('name', w['id'])}", callback_data=f"web_show_{w['id']}")) - rows = grid_buttons(btns, 2) - rows.append([InlineKeyboardButton("➕ إضافة موقع", callback_data="web_add")]) - rows.append(nav_row("secrets_menu")) - text += f"العدد: {len(items)}" - await safe_edit(callback, text, InlineKeyboardMarkup(rows)) - - -async def add_website_prompt(client, callback): - uid = callback.from_user.id - user_states.pop(uid, None) - user_states[uid] = {"state": "waiting_website_add"} - await safe_edit(callback, "✏️ أرسل رابط الموقع:\n\nمثال:\n`https://example.com`\n\nيمكنك إضافة `| محدد` لاختيار عناصر محددة (CSS selector)\nمثال:\n`https://example.com | article.news`", InlineKeyboardMarkup([nav_row("web_list")])) - - -async def show_website_settings(client, callback): - wid = callback.data.split("_", 2)[2] - w = db.get_website(wid) - if not w: - await callback.answer("غير موجود.") - return - deps = db.get_dependencies_for("website", wid) - deps_text = "\n".join(f"• {entity_name(d)}" for d in deps[:10]) if deps else "لا توجد" - text = ( - f"🌐 **{w.get('name', wid)}**\n" - f"🆔 `{wid}`\n" - f"🔗 الرابط: {w.get('url', '?')}\n" - f"🎯 المحدد: `{w.get('selector', 'body')}`\n" - f"🔑 الحالة: {'✅ مفعل' if w.get('enabled', True) else '❌ معطل'}\n" - f"❌ الأخطاء: {w.get('error_count', 0)}\n" - f"🕐 آخر جلب: {_format_ts(w.get('last_fetch'))}\n\n" - "⏯️ تشغيل/إيقاف: التحكم في جلب هذا الموقع.\n✏️ تعديل: تغيير الرابط أو المحدد.\n🧪 اختبار: جلب الموقع والتحقق من استجابته.\n🗑 حذف: نقل الموقع إلى سلة المحذوفات.\n\n" - f"**القنوات المستخدمة:**\n{deps_text}" - ) - buttons = [ - [InlineKeyboardButton("⏯️ تشغيل/إيقاف", callback_data=f"web_toggle_{wid}")], - [InlineKeyboardButton("✏️ تعديل", callback_data=f"web_edit_{wid}")], - [InlineKeyboardButton("🧪 اختبار", callback_data=f"web_test_{wid}")], - [InlineKeyboardButton("🗑 حذف", callback_data=f"web_delete_{wid}")], - [InlineKeyboardButton("🔙 رجوع", callback_data="web_list"), InlineKeyboardButton("🏠 القائمة الرئيسية", callback_data="main_menu")], - ] - await safe_edit(callback, text, InlineKeyboardMarkup(buttons)) - - -async def toggle_website(client, callback): - wid = callback.data.split("_", 2)[2] - w = db.get_website(wid) - if not w: - await callback.answer("غير موجود.") - return - db.set_website_enabled(wid, not w.get("enabled", True)) - await callback.answer("تم التحديث.") - callback.data = f"web_show_{wid}" - await show_website_settings(client, callback) - - -async def edit_website_prompt(client, callback): - wid = callback.data.split("_", 2)[2] - w = db.get_website(wid) - if not w: - await callback.answer("غير موجود.") - return - uid = callback.from_user.id - user_states.pop(uid, None) - user_states[uid] = {"state": "waiting_website_edit", "website_id": wid} - await safe_edit(callback, f"✏️ أرسل البيانات الجديدة للموقع:\n\n`الرابط | المحدد`\n\nالحالي:\nالرابط: {w.get('url')}\nالمحدد: `{w.get('selector', 'body')}`", InlineKeyboardMarkup([nav_row(f"web_show_{wid}")])) - - -async def delete_website_with_deps(client, callback): - wid = callback.data.split("_", 2)[2] - w = db.get_website(wid) - if not w: - await callback.answer("غير موجود.") - return - deps = db.get_dependencies_for("website", wid) - if deps: - dep_text = "\n".join(f"• {entity_name(d)}" for d in deps[:10]) - text = f"⚠️ **لا يمكن حذف {w.get('name', wid)}**\n\nمستخدم من قبل:\n{dep_text}\n\nقم بإزالتها من هذه القنوات أولاً." - buttons = [nav_row(f"web_show_{wid}")] - await safe_edit(callback, text, InlineKeyboardMarkup(buttons)) - return - db.delete_website(wid) - await callback.answer("✅ تم النقل إلى سلة المحذوفات.") - await web_list_menu(client, callback) - - -async def test_website(client, callback): - wid = callback.data.split("_", 2)[2] - w = db.get_website(wid) - if not w: - await callback.answer("غير موجود.") - return - await callback.answer(f"جاري اختبار {w.get('name', wid)}...", show_alert=False) - try: - import httpx - async with httpx.AsyncClient(timeout=15, follow_redirects=True) as hc: - resp = await hc.get(w.get("url", ""), headers={"User-Agent": "Mozilla/5.0"}) - if resp.status_code == 200: - content_len = len(resp.text) - db.update_website(wid, last_fetch=int(time.time())) - await callback.answer(f"✅ متاح! {content_len} حرف", show_alert=True) - else: - await callback.answer(f"❌ HTTP {resp.status_code}", show_alert=True) - except Exception as e: - await callback.answer(f"❌ فشل: {str(e)[:100]}", show_alert=True) - - -# --- Trash --- - -async def trash_menu(client, callback): - items = db.get_trash_items() - text = "🗑 **سلة المحذوفات**\n\n♻️ الاستعادة: يعيد العنصر إلى قائمته السابقة.\n🗑 الحذف النهائي: يزيل العنصر نهائياً ولا يمكن التراجع عنه.\n\n" - if not items: - text += "السلة فارغة." - buttons = [nav_row("secrets_menu")] - await safe_edit(callback, text, InlineKeyboardMarkup(buttons)) - return - type_labels = {"session": "📱 جلسة", "ai_key": "🧠 مفتاح AI", "publishing_bot": "🤖 بوت", "website": "🌐 موقع"} - rows = [] - for idx, item in enumerate(items[:30]): - t = item.get("type", "") - label = type_labels.get(t, t) - name = item.get("data", {}).get("name", item.get("original_id", "")) - tid = item.get("id", "") - rows.append([ - InlineKeyboardButton(f"♻️ {label}: {name}", callback_data=f"trash_restore_{tid}"), - InlineKeyboardButton(f"🗑 حذف", callback_data=f"trash_permdel_{tid}"), - ]) - rows.append([InlineKeyboardButton("🗑 حذف نهائي للكل", callback_data="trash_empty_prompt")]) - rows.append(nav_row("secrets_menu")) - text += f"العدد: {len(items)}" - await safe_edit(callback, text, InlineKeyboardMarkup(rows)) - - -async def trash_restore_item(client, callback): - tid = callback.data.split("_", 2)[2] - ok = db.restore_from_trash(tid) - if ok: - await callback.answer("✅ تمت الاستعادة.") - else: - await callback.answer("❌ فشل الاستعادة.", show_alert=True) - await trash_menu(client, callback) - - -async def trash_permanent_delete_prompt(client, callback): - tid = callback.data.split("_", 2)[2] - text = "⚠️ **تأكيد الحذف النهائي**\n\nهل تريد حذف هذا العنصر نهائياً؟ لا يمكن التراجع." - buttons = [ - [InlineKeyboardButton("✅ نعم، احذف نهائياً", callback_data=f"trash_permconfirm_{tid}")], - nav_row("trash_menu"), - ] - await safe_edit(callback, text, InlineKeyboardMarkup(buttons)) - - -async def trash_permanent_delete_confirm(client, callback): - tid = callback.data.split("_", 2)[2] - ok = db.permanent_delete_from_trash(tid) - await callback.answer("✅ تم الحذف النهائي." if ok else "❌ فشل.", show_alert=True) - await trash_menu(client, callback) - - -async def trash_empty_prompt(client, callback): - text = "⚠️ **تفريغ سلة المحذوفات**\n\nسيتم حذف جميع العناصر نهائياً. لا يمكن التراجع." - buttons = [ - [InlineKeyboardButton("✅ نعم، امسح الكل", callback_data="trash_empty_confirm")], - nav_row("trash_menu"), - ] - await safe_edit(callback, text, InlineKeyboardMarkup(buttons)) - - -async def trash_empty_confirm(client, callback): - db.empty_trash() - await callback.answer("✅ تم تفريغ السلة.") - await trash_menu(client, callback) - - -# --- Channel Extended Settings --- - -async def channel_extended_settings(client, callback): - ch_id = callback.data.split("_", 2)[2] - ch = db.get_channel(ch_id) - if not ch: - await callback.answer("غير موجودة.") - return - cfg = db.get_channel_config(ch_id) - disable_preview = db.get_channel_disable_preview(ch_id) if hasattr(db, "get_channel_disable_preview") else False - text = ( - f"⚙️ **الإعدادات المتقدمة:**\n{entity_name(ch)}\n\n" - f"💬 اقتباس العنوان: {'✅ مفعل' if cfg.get('title_quote') else '❌ متوقف'}\n" - f"💬 اقتباس التوقيع: {'✅ مفعل' if cfg.get('signature_quote') else '❌ متوقف'}\n" - f"🔗 إخفاء معاينة الروابط: {'✅ مفعل' if disable_preview else '❌ متوقف'}\n" - f"📱 الجلسات المخصصة: {len(cfg.get('assigned_sessions', []))}\n" - f"🤖 البوتات المخصصة: {len(cfg.get('assigned_bots', []))}\n" - f"🧠 AI المخصص: {len(cfg.get('assigned_ai', []))}\n" - f"🌐 مواقع مخصصة: {len(cfg.get('websites', []))}\n\n" - "💬 الاقتباس: التحكم في تنسيق العنوان والتوقيع.\n🔗 المعاينة: إظهار أو إخفاء معاينة الروابط.\n📱/🤖/🧠/🌐 التخصيص: اختيار الموارد التي تستخدمها هذه القناة.\n" - ) - buttons = [ - [InlineKeyboardButton(f"💬 اقتباس العنوان: {'✅' if cfg.get('title_quote') else '❌'}", callback_data=f"ch_titlequote_{ch_id}")], - [InlineKeyboardButton(f"💬 اقتباس التوقيع: {'✅' if cfg.get('signature_quote') else '❌'}", callback_data=f"ch_sigquote_{ch_id}")], - [InlineKeyboardButton(f"🔗 معاينة: {'✅' if not disable_preview else '❌'}", callback_data=f"ch_preview_{ch_id}")], - [InlineKeyboardButton("📱 الجلسات", callback_data=f"assign_sessions_{ch_id}"), InlineKeyboardButton("🤖 البوتات", callback_data=f"assign_bots_{ch_id}")], - [InlineKeyboardButton("🧠 AI", callback_data=f"assign_ai_{ch_id}"), InlineKeyboardButton("🌐 مواقع", callback_data=f"assign_websites_{ch_id}")], - [InlineKeyboardButton("🔙 رجوع", callback_data=f"ch_{ch_id}"), InlineKeyboardButton("🏠 القائمة الرئيسية", callback_data="main_menu")], - ] - await safe_edit(callback, text, InlineKeyboardMarkup(buttons)) - - -async def toggle_channel_title_quote(client, callback): - ch_id = callback.data.split("_", 2)[2] - cfg = db.get_channel_config(ch_id) - db.set_channel_title_quote(ch_id, not cfg.get("title_quote", False)) - await callback.answer("تم التحديث.") - callback.data = f"quotemenu_{ch_id}" - await channel_quote_menu(client, callback) - - -async def toggle_channel_signature_quote(client, callback): - ch_id = callback.data.split("_", 2)[2] - cfg = db.get_channel_config(ch_id) - db.set_channel_signature_quote(ch_id, not cfg.get("signature_quote", False)) - await callback.answer("تم التحديث.") - callback.data = f"quotemenu_{ch_id}" - await channel_quote_menu(client, callback) - - -async def toggle_channel_preview(client, callback): - ch_id = callback.data.split("_", 2)[2] - cur = db.get_channel_disable_preview(ch_id) if hasattr(db, "get_channel_disable_preview") else False - db.set_channel_disable_preview(ch_id, not cur) - await callback.answer("تم التحديث.") - callback.data = f"postset_{ch_id}" - await post_settings_menu(client, callback) - - -# ============================================================ -# General Channel Settings (الإعدادات العامة للقناة) -# ============================================================ - -async def general_settings_menu(client, callback): - ch_id = callback.data.split("_", 1)[1] - ch = db.get_channel(ch_id) - if not ch: - await callback.answer("القناة غير موجودة.") - return - buttons = [ - InlineKeyboardButton("📋 ملخص الإعدادات", callback_data=f"genset_summary_{ch_id}"), - InlineKeyboardButton("📄 نسخ الإعدادات", callback_data=f"genset_copy_{ch_id}"), - InlineKeyboardButton("📥 لصق الإعدادات", callback_data=f"genset_paste_{ch_id}"), - InlineKeyboardButton("♻️ إعادة التعيين", callback_data=f"genset_reset_{ch_id}"), - InlineKeyboardButton("🤖 التحقق من البوت", callback_data=f"chbotcheck|{ch_id}"), - InlineKeyboardButton(f"{'⏸️' if ch.get('paused') else '▶️'} تشغيل/إيقاف القناة", callback_data=f"toggle_{ch_id}"), - InlineKeyboardButton("📱 الجلسات", callback_data=f"assign_sessions_{ch_id}"), - InlineKeyboardButton("🤖 البوتات", callback_data=f"assign_bots_{ch_id}"), - InlineKeyboardButton("🧠 AI", callback_data=f"assign_ai_{ch_id}"), - InlineKeyboardButton("🌐 المواقع", callback_data=f"assign_websites_{ch_id}"), - InlineKeyboardButton("🗑 حذف القناة", callback_data=f"delchannel_{ch_id}"), - ] - rows = grid_buttons(buttons, 2) - rows.append(nav_row(f"ch_{ch_id}")) - ch_state = '⏸️ متوقفة' if ch.get('paused') else '▶️ تعمل' - await safe_edit(callback, f"⚙️ **الإعدادات العامة**\n{channel_display_name(ch_id)}\n\nالحالة: {ch_state}\n\n📋 الملخص: عرض الإعدادات الحالية.\n📄 نسخ / 📥 لصق: نقل الإعدادات بين القنوات.\n♻️ إعادة التعيين: استرجاع القيم الافتراضية.\n🤖 التحقق: فحص صلاحية بوت القناة.\n▶️/⏸️ تشغيل/إيقاف: التحكم في استقبال القناة للمنشورات.\n📱 الجلسات / 🤖 البوتات / 🧠 AI / 🌐 المواقع: إدارة الموارد المخصصة.\n🗑 حذف القناة: إزالة القناة بعد التأكيد.", InlineKeyboardMarkup(rows)) - - -# ============================================================ -# 🖥 النظام — خاص بكل قناة على حدة -# ============================================================ - -async def system_channel_menu(client, callback): - ch_id = callback.data.split("_", 1)[1] - ch = db.get_channel(ch_id) - if not ch: - await callback.answer("القناة غير موجودة.") - return - buttons = [ - InlineKeyboardButton("🧪 اختبار القناة", callback_data=f"testset_{ch_id}"), - InlineKeyboardButton("🔍 الفحص الشامل", callback_data=f"full_check|{ch_id}"), - InlineKeyboardButton("📊 حالة النظام", callback_data=f"system_status|{ch_id}"), - InlineKeyboardButton("▶️ إدارة التشغيل", callback_data=f"ops_menu|{ch_id}"), - InlineKeyboardButton("📝 إدارة السجل", callback_data=f"log_menu|{ch_id}"), - InlineKeyboardButton("🔔 ال��شعارات", callback_data=f"notifications_menu|{ch_id}"), - ] - rows = grid_buttons(buttons, 2) - rows.append(nav_row(f"ch_{ch_id}")) - await safe_edit(callback, f"🖥 **النظام**\n{channel_display_name(ch_id)}\n\n🧪 اختبار القناة: تجربة إعدادات القناة.\n🔍 الفحص الشامل: فحص الموارد والصلاحيات.\n📊 حالة النظام: عرض الحالة والإحصائيات.\n▶️ إدارة التشغيل: الصيانة واختبار القنوات والأخطاء.\n📝 إدارة السجل: عرض أو تحديث أو مسح السجل.\n🔔 الإشعارات: التحكم في تنبيهات النظام.", InlineKeyboardMarkup(rows)) - - -async def general_settings_copy(client, callback): - """نسخ إعدادات القناة إلى الحافظة.""" - ch_id = callback.data.split("_", 2)[2] - ch = db.get_channel(ch_id) - if not ch: - await callback.answer("غير موجودة.") - return - clip = { - "bold_publish": ch.get("bold_publish", True), - "tail": ch.get("tail", ""), - "tail_enabled": ch.get("tail_enabled", True), - "tail_min_words": ch.get("tail_min_words", 20), - "tail_position": ch.get("tail_position", "bottom"), - "quote_types": ch.get("quote_types", {"text": False, "photo": False, "video": False, "album": False}), - "publish_delay": ch.get("publish_delay"), - "hashtags": ch.get("hashtags", []), - "disable_web_page_preview": ch.get("disable_web_page_preview", False), - "link_remove_tg": ch.get("link_remove_tg", False), - "link_remove_tg_user": ch.get("link_remove_tg_user", False), - "link_remove_web": ch.get("link_remove_web", False), - } - if hasattr(db, "set_settings_clipboard"): - db.set_settings_clipboard(clip) - await callback.answer("✅ تم نسخ إعدادات هذه القناة.", show_alert=True) - - -async def general_settings_paste(client, callback): - """لصق إعدادات القناة من الحافظة مع تأكيد.""" - ch_id = callback.data.split("_", 2)[2] - clip = db.get_settings_clipboard() if hasattr(db, "get_settings_clipboard") else None - if not clip: - await callback.answer("❌ لا توجد إعدادات منسوخة. انسخ أولاً.", show_alert=True) - return - text = ( - f"⚠️ **هل تريد استبدال إعدادات هذه القناة؟**\n\n" - f"{entity_name(db.get_channel(ch_id))}\n\n" - f"سيتم لصق آخر إعدادات تم نسخها." - ) - buttons = [ - [InlineKeyboardButton("✅ نعم", callback_data=f"genset_paste_confirm_{ch_id}")], - nav_row(f"genset_{ch_id}"), - ] - await safe_edit(callback, text, InlineKeyboardMarkup(buttons)) - - -async def general_settings_paste_confirm(client, callback): - """تأكيد لصق الإعدادات.""" - ch_id = callback.data.split("_", 3)[3] - clip = db.get_settings_clipboard() if hasattr(db, "get_settings_clipboard") else None - if not clip: - await callback.answer("❌ لا توجد إعدادات.", show_alert=True) - return - ch = db.get_channel(ch_id) - if not ch: - await callback.answer("غير موجودة.") - return - for key in ("bold_publish", "tail", "tail_enabled", "tail_min_words", "tail_position", "quote_types", "publish_delay", "hashtags", "disable_web_page_preview", "link_remove_tg", "link_remove_tg_user", "link_remove_web"): - if key in clip: - db.update_channel(ch_id, key, clip[key]) - await callback.answer("✅ تم لصق الإعدادات بنجاح.", show_alert=True) - callback.data = f"genset_{ch_id}" - await general_settings_menu(client, callback) - - -async def general_settings_reset(client, callback): - """إعادة تعيين إعدادات القناة إلى الافتراضية مع تأكيد.""" - ch_id = callback.data.split("_", 2)[2] - text = ( - f"⚠️ **إعادة تعيين إعدادات القناة؟**\n\n" - f"{entity_name(db.get_channel(ch_id))}\n\n" - f"سيتم إرجاع جميع الإعدادات إلى القيم الافتراضية.\n" - f"لن يتم حذف القناة أو المصادر أو الربط." - ) - buttons = [ - [InlineKeyboardButton("✅ نعم", callback_data=f"genset_reset_confirm_{ch_id}")], - nav_row(f"genset_{ch_id}"), - ] - await safe_edit(callback, text, InlineKeyboardMarkup(buttons)) - - -async def general_settings_reset_confirm(client, callback): - """تأكيد إعادة التعيين.""" - ch_id = callback.data.split("_", 3)[3] - ch = db.get_channel(ch_id) - if not ch: - await callback.answer("غير موجودة.") - return - db.update_channel(ch_id, "bold_publish", True) - db.update_channel(ch_id, "tail", "") - db.update_channel(ch_id, "tail_enabled", True) - db.update_channel(ch_id, "tail_min_words", 20) - if hasattr(db, "set_channel_tail_position"): - db.set_channel_tail_position(ch_id, "bottom") - if hasattr(db, "set_channel_quote_publish"): - db.set_channel_quote_publish(ch_id, False) - db.update_channel(ch_id, "publish_delay", None) - if hasattr(db, "set_channel_disable_preview"): - db.set_channel_disable_preview(ch_id, False) - db.update_channel(ch_id, "link_remove_tg", False) - db.update_channel(ch_id, "link_remove_tg_user", False) - db.update_channel(ch_id, "link_remove_web", False) - await callback.answer("♻️ تم إعادة تعيين الإعدادات.", show_alert=True) - callback.data = f"genset_{ch_id}" - await general_settings_menu(client, callback) - - -async def general_settings_summary(client, callback): - ch_id = callback.data.split("_", 2)[2] - ch = db.get_channel(ch_id) - if not ch: - await callback.answer("غير موجودة.") - return - bold = ch.get("bold_publish", True) - tail = ch.get("tail", "") - tail_enabled = ch.get("tail_enabled", True) - tail_min = ch.get("tail_min_words", 20) - tail_pos = ch.get("tail_position", "bottom") - quote_types = get_channel_quote_types_safe(ch_id) - hashtags = ch.get("hashtags", []) - delay = ch.get("publish_delay") - sources = ch.get("special_sources", []) - if hasattr(db, "mapper"): - bot_count = len(db.mapper.get_bots_for_channel(ch_id)) - else: - cfg = db.get_channel_config(ch_id) if hasattr(db, "get_channel_config") else {} - bot_count = len(cfg.get("assigned_bots", [])) - - enabled_count = 0 - disabled_count = 0 - lines = [f"📊 **ملخص إعدادات {entity_name(ch)}**\n"] - - lines.append("\n📝 **المنشورات**") - if bold: - lines.append("✅ الخط السميك"); enabled_count += 1 - else: - lines.append("❌ الخط السميك"); disabled_count += 1 - lines.append(f"{'✅' if quote_types.get('text') else '❌'} اقتباس النصوص") - if quote_types.get('text'): enabled_count += 1 - else: disabled_count += 1 - lines.append(f"{'✅' if quote_types.get('photo') else '❌'} اقتباس الصور") - if quote_types.get('photo'): enabled_count += 1 - else: disabled_count += 1 - lines.append(f"{'✅' if quote_types.get('video') else '❌'} اقتباس الفيديو") - if quote_types.get('video'): enabled_count += 1 - else: disabled_count += 1 - lines.append(f"{'✅' if quote_types.get('album') else '❌'} اقتباس الألبومات") - if quote_types.get('album'): enabled_count += 1 - else: disabled_count += 1 - lines.append(f"{'✅' if hashtags else '❌'} الهاشتاكات ({len(hashtags)})") - if hashtags: enabled_count += 1 - else: disabled_count += 1 - lines.append(f"{'✅' if delay is None else '⚠️'} سرعة النشر: {delay or 'افتراضي'}") - enabled_count += 1 - - lines.append(f"\n🔗 **الروابط**") - lines.append(f"{'✅' if ch.get('link_remove_tg') else '❌'} حذف روابط تيليجرام") - if ch.get("link_remove_tg"): enabled_count += 1 - else: disabled_count += 1 - lines.append(f"{'✅' if ch.get('link_remove_tg_user') else '❌'} حذف يوزرات @") - if ch.get("link_remove_tg_user"): enabled_count += 1 - else: disabled_count += 1 - lines.append(f"{'✅' if ch.get('link_remove_web') else '❌'} حذف روابط المواقع") - if ch.get("link_remove_web"): enabled_count += 1 - else: disabled_count += 1 - - lines.append(f"\n🔖 **التوقيع**") - lines.append(f"{'✅' if tail_enabled else '❌'} تشغيل") - if tail_enabled: enabled_count += 1 - else: disabled_count += 1 - lines.append(f"{'⬆️ أعلى' if tail_pos == 'top' else '⬇️ أسفل'} المنشور") - enabled_count += 1 - if tail_min >= 20: - lines.append(f"✅ شرط {tail_min} كلمة"); enabled_count += 1 - else: - lines.append(f"❌ شرط الكلمات"); disabled_count += 1 - if tail: - lines.append(f"📝 النص: {tail[:30]}{'...' if len(tail) > 30 else ''}") - - lines.append(f"\n📚 **المصادر**") - lines.append(f"{len(sources)} مصدر مخصص") - lines.append(f"{bot_count} بوت نشر") - - lines.append(f"\n-----------------------") - lines.append(f"🟢 مفعل: {enabled_count}") - lines.append(f"🔴 متوقف: {disabled_count}") - - buttons = [ - nav_row(f"genset_{ch_id}"), - ] - await safe_edit(callback, "\n".join(lines), InlineKeyboardMarkup(buttons)) - - -async def ui_setting_info_menu(client, callback): - """صفحة شرح وتحكم موحدة لإعدادات Boolean؛ لا تنفذ التغيير عند فتحها.""" - parts = callback.data.split("|", 3) - if len(parts) != 4: - await callback.answer("طلب غير صالح.", show_alert=True) - return - _, kind, target, back = parts - ch_id = target if target != "global" else None - back_target = back.replace("~", "|") if back else (_peek_prev_back(callback) or "main_menu") - back_token = back_target.replace("|", "~") - state = False - if kind == "short_posts": - state = is_ignore_short_posts_enabled_for_channel(ch_id) if ch_id else is_ignore_short_posts_enabled() - title, purpose = "📝 النصوص القصيرة", "يحدد هل تُمنع المنشورات النصية القصيرة من النشر في هذه القناة." - on_desc, off_desc = "يتم منع نشر المنشورات النصية القصيرة.", "تُنشر المنشورات النصية القصيرة بشكل طبيعي." - elif kind == "bold": - ch = db.get_channel(ch_id) or {} - state = bool(ch.get("bold_publish", True)) - title, purpose = "🖊 الخط السميك", "يحدد استخدام الخط السميك عند تنسيق منشورات هذه القناة." - on_desc, off_desc = "تُنشر النصوص المدعومة بالخط السميك.", "تُنشر النصوص دون تفعيل الخط السميك." - elif kind == "title_quote": - cfg = db.get_channel_config(ch_id) if ch_id and hasattr(db, "get_channel_config") else {} - state = bool(cfg.get("title_quote")) - title, purpose = "💬 اقتباس العنوان", "يحدد هل يظهر عنوان المنشور بصيغة اقتباس Telegram." - on_desc, off_desc = "يظهر العنوان داخل اقتباس.", "يُنشر العنوان بصيغة عادية." - elif kind == "signature_quote": - cfg = db.get_channel_config(ch_id) if ch_id and hasattr(db, "get_channel_config") else {} - state = bool(cfg.get("signature_quote")) - title, purpose = "💬 اقتباس التوقيع", "يحدد هل يظهر التوقيع بصيغة اقتباس Telegram." - on_desc, off_desc = "يظهر التوقيع داخل اقتباس.", "يُنشر التوقيع بصيغة عادية." - elif kind == "preview": - disabled = db.get_channel_disable_preview(ch_id) if ch_id and hasattr(db, "get_channel_disable_preview") else False - state = not bool(disabled) - title, purpose = "🔗 معاينة الروابط", "يحدد هل تظهر معاينة الروابط عند نشر منشورات هذه القناة." - on_desc, off_desc = "تظهر معاينة الروابط.", "تُنشر الروابط دون معاينة." - elif kind == "maintenance": - state = bool(db.is_maintenance_mode()) if hasattr(db, "is_maintenance_mode") else False - title, purpose = "🛠 وضع الصيانة", "يوقف النشر مع إبقاء فحص المصادر مستمراً." - on_desc, off_desc = "يتوقف النشر وتستمر عملية فحص المصادر.", "يعود النشر إلى العمل الطبيعي." - elif kind == "notification": - settings = db.get_notification_settings() if hasattr(db, "get_notification_settings") else {} - state = bool(settings.get(target, True)) - title = "🔔 " + str(getattr(db, "NOTIFICATION_TYPES", {}).get(target, target)) - purpose = "يحدد هل يرسل البوت هذا النوع من التنبيهات للمشرفين." - on_desc, off_desc = "يتم إرسال هذا التنبيه عند حدوثه.", "لا يتم إرسال هذا النوع من التنبيهات." - elif kind.startswith("quote_"): - qtype = kind.split("_", 1)[1] - state = bool(get_channel_quote_types_safe(ch_id).get(qtype, False)) - labels = {"text": "النصوص", "photo": "الصور", "video": "الفيديو", "album": "الألبومات"} - title, purpose = "💬 اقتباس " + labels.get(qtype, qtype), "يحدد هل يُنشر هذا النوع بصيغة اقتباس رسمي في هذه القناة." - on_desc, off_desc = "يُنشر هذا النوع داخل اقتباس رسمي.", "يُنشر هذا النوع بصيغته العادية." - elif kind == "tail": - state = bool(db.get_channel_tail_enabled(ch_id)) if hasattr(db, "get_channel_tail_enabled") else True - title, purpose = "🔖 التوقيع", "يحدد هل يضاف توقيع القناة إلى المنشورات." - on_desc, off_desc = "يُضاف التوقيع إلى المنشورات وفق الإعدادات الحالية.", "لا يُضاف التوقيع إلى المنشورات." - elif kind == "quote_type": - try: - ch_id, qtype = target.split("~", 1) - except ValueError: - await callback.answer("إعداد اقتباس غير صالح.", show_alert=True) - return - state = bool(get_channel_quote_types_safe(ch_id).get(qtype, False)) - labels = {"text": "النصوص", "photo": "الصور", "video": "الفيديو", "album": "الألبومات"} - title = "💬 اقتباس " + labels.get(qtype, qtype) - purpose = "يحدد هل يُنشر هذا النوع بصيغة اقتباس رسمية في هذه القناة." - on_desc, off_desc = "يُنشر هذا النوع داخل اقتباس رسمي.", "يُنشر هذا النوع بصيغته العادية." - elif kind == "source_enabled": - state = not bool(db.is_source_paused(target)) - title, purpose = "🟢 تشغيل المصدر", "يحدد هل يستمر هذا المصدر في استقبال المنشورات." - on_desc, off_desc = "يستمر استقبال منشورات المصدر.", "يتوقف استقبال منشورات المصدر مؤقتاً." - elif kind == "source_emoji": - state = bool(db.get_source_remove_emoji(target)) - title, purpose = "🙂 إزالة الإيموجي", "يحدد هل تُزال الإيموجيات من منشورات هذا المصدر." - on_desc, off_desc = "تُزال الإيموجيات من منشورات المصدر.", "تبقى الإيموجيات في منشورات المصدر." - elif kind == "source_content": - try: - source_id, ctype = target.split("~", 1) - except ValueError: - await callback.answer("فلتر محتوى غير صالح.", show_alert=True) - return - state = bool(db.get_source_content_types(source_id).get(ctype, True)) - title, purpose = "📰 نوع المحتوى", "يحدد هل يستقبل المصدر هذا النوع من المنشورات." - on_desc, off_desc = "يتم استقبال هذا النوع من المصدر.", "يتم تجاهل هذا النوع من المصدر." - elif kind.startswith("link_"): - key = kind - state = bool((db.get_channel(ch_id) or {}).get(key, False)) - labels = {"link_remove_tg": "روابط Telegram", "link_remove_tg_user": "مستخدمو Telegram", "link_remove_web": "روابط المواقع"} - title, purpose = "🔗 " + labels.get(key, key), "يحدد هل يُزال هذا النوع من الروابط من منشورات القناة." - on_desc, off_desc = "تُزال هذه الروابط من المنشورات.", "تبقى هذه الروابط في المنشورات." - elif kind == "tail_min": - cfg = db.get_channel_config(ch_id) if hasattr(db, "get_channel_config") else {} - state = int(cfg.get("tail_min_words", 20) or 0) > 0 - title, purpose = "🔢 الحد الأدنى للتوقيع", "يحدد هل يُطبّق الحد الأدنى للكلمات قبل إضافة التوقيع." - on_desc, off_desc = "يُضاف التوقيع عند بلوغ الحد الأدنى الحالي.", "لا يُطبّق حد أدنى للكلمات." - elif kind == "tail_pos": - position = db.get_channel_tail_position(ch_id) if hasattr(db, "get_channel_tail_position") else "bottom" - state = position == "top" - title, purpose = "📍 مكان التوقيع", "يحدد موضع التوقيع في أعلى المنشور أو أسفله." - on_desc, off_desc = "يوضع التوقيع أعلى المنشور.", "يوضع التوقيع أسفل المنشور." - elif kind == "pause": - ch = db.get_channel(ch_id) or {} - state = not bool(ch.get("paused", False)) - title, purpose = "⏯️ تشغيل القناة", "يحدد هل تستمر القناة في معالجة ونشر المنشورات." - on_desc, off_desc = "تستمر القناة في العمل والنشر.", "تتوقف القناة مؤقتاً عن المعالجة والنشر." - elif kind == "assign": - try: - rtype, ch_id, item_id = target.split("~", 2) - except ValueError: - await callback.answer("تعيين مورد غير صالح.", show_alert=True) - return - info = _RESOURCE_MAP.get(rtype) - if not info: - await callback.answer("نوع مورد غير معروف.", show_alert=True) - return - if rtype == "bots" and hasattr(db, "mapper"): - state = bool(db.mapper.is_assigned(item_id, ch_id)) - else: - cfg = db.get_channel_config(ch_id) or {} - state = str(item_id) in [str(x) for x in cfg.get(info["cfg_key"], [])] - title, purpose = "🔧 تعيين المورد", "يحدد هل هذا المورد مرتبط بهذه القناة." - on_desc, off_desc = "يُستخدم المورد مع هذه القناة.", "لا يُستخدم المورد مع هذه القناة." - elif kind == "session": - item = db.get_session(target) or {} - state = bool(item.get("enabled", True)) - title, purpose = "🔐 الجلسة", "يحدد هل تكون جلسة Telegram متاحة للتشغيل." - on_desc, off_desc = "تبقى الجلسة متاحة للتشغيل.", "تتوقف الجلسة عن التشغيل." - elif kind == "ai_key": - item = db.get_ai_key(target) or {} - state = bool(item.get("enabled", True)) - title, purpose = "🤖 مفتاح الذكاء الاصطناعي", "يحدد هل يمكن استخدام هذا المفتاح في عمليات AI." - on_desc, off_desc = "يمكن استخدام المفتاح.", "لا يُستخدم المفتاح." - elif kind == "publishing_bot": - item = db.get_publishing_bot(target) or {} - state = bool(item.get("enabled", True)) - title, purpose = "🤖 بوت النشر", "يحدد هل يمكن استخدام بوت النشر هذا." - on_desc, off_desc = "يبقى البوت متاحاً للنشر.", "يتوقف استخدام البوت للنشر." - elif kind == "website": - item = db.get_website(target) or {} - state = bool(item.get("enabled", True)) - title, purpose = "🌐 الموقع", "يحدد هل يمكن استخدام مصدر الموقع هذا." - on_desc, off_desc = "يبقى الموقع متاحاً للاستخدام.", "يتوقف استخدام الموقع." - else: - await callback.answer("إعداد غير معروف.", show_alert=True) - return - current = "✅ مفعّل" if state else "❌ متوقف" - text = f"{title}\n\nوظيفة الإعداد:\n{purpose}\n\nالحالة الحالية:\n{current}\n\nعند التشغيل:\n{on_desc}\n\nعند الإيقاف:\n{off_desc}" - rows = [[InlineKeyboardButton("✅ تشغيل", callback_data=f"ui_set|{kind}|{target}|{back_token}|1")], [InlineKeyboardButton("❌ إيقاف", callback_data=f"ui_set|{kind}|{target}|{back_token}|0")], [InlineKeyboardButton("🔙 رجوع", callback_data=back_target)]] - await safe_edit(callback, text, InlineKeyboardMarkup(rows)) - - -async def ui_setting_set(client, callback): - """يحفظ اختيار المستخدم ثم يعيد فتح صفحة الإعداد نفسها بالحالة الجديدة.""" - parts = callback.data.split("|", 4) - if len(parts) != 5: - await callback.answer("طلب غير صالح.", show_alert=True) - return - _, kind, target, back, desired_raw = parts - back = back.replace("~", "|") if back else (_peek_prev_back(callback) or "main_menu") - desired = desired_raw == "1" - ch_id = target if target != "global" else None - if kind == "short_posts": - current = is_ignore_short_posts_enabled_for_channel(ch_id) if ch_id else is_ignore_short_posts_enabled() - if current != desired: - if ch_id and hasattr(db, "set_channel_ignore_short_posts"): - db.set_channel_ignore_short_posts(ch_id, desired) - elif hasattr(db, "set_ignore_short_posts"): - db.set_ignore_short_posts(desired) - label = "منع النصوص القصيرة" - result = "سيتم منع المنشورات النصية القصيرة في هذه القناة." if desired else "سيتم نشر المنشورات النصية القصيرة بشكل طبيعي." - elif kind == "bold": - ch = db.get_channel(ch_id) or {} - current = bool(ch.get("bold_publish", True)) - if current != desired: - db.update_channel(ch_id, "bold_publish", desired) - label, result = "الخط السميك", "سيُستخدم الخط السميك عند توفره." if desired else "لن يُفعّل الخط السميك." - elif kind == "title_quote": - cfg = db.get_channel_config(ch_id) if hasattr(db, "get_channel_config") else {} - current = bool(cfg.get("title_quote")) - if current != desired and hasattr(db, "set_channel_title_quote"): - db.set_channel_title_quote(ch_id, desired) - label, result = "اقتباس العنوان", "سيظهر العنوان داخل اقتباس." if desired else "سيُنشر العنوان بصيغة عادية." - elif kind == "signature_quote": - cfg = db.get_channel_config(ch_id) if hasattr(db, "get_channel_config") else {} - current = bool(cfg.get("signature_quote")) - if current != desired and hasattr(db, "set_channel_signature_quote"): - db.set_channel_signature_quote(ch_id, desired) - label, result = "اقتباس التوقيع", "سيظهر التوقيع داخل اقتباس." if desired else "سيُنشر التوقيع بصيغة عادية." - elif kind == "preview": - current = not bool(db.get_channel_disable_preview(ch_id)) if hasattr(db, "get_channel_disable_preview") else True - if current != desired and hasattr(db, "set_channel_disable_preview"): - db.set_channel_disable_preview(ch_id, not desired) - label, result = "معاينة الروابط", "ستظهر معاينة الروابط." if desired else "ستُنشر الروابط دون معاينة." - elif kind == "maintenance": - current = bool(db.is_maintenance_mode()) if hasattr(db, "is_maintenance_mode") else False - if current != desired and hasattr(db, "set_maintenance_mode"): - db.set_maintenance_mode(desired) - label, result = "وضع الصيانة", "توقف النشر واستمر فحص المصادر." if desired else "عاد النشر إلى العمل الطبيعي." - elif kind == "notification": - settings = db.get_notification_settings() if hasattr(db, "get_notification_settings") else {} - current = bool(settings.get(target, True)) - if current != desired and hasattr(db, "set_notification_setting"): - db.set_notification_setting(target, desired) - label, result = "التنبيه", "سيتم إرسال التنبيه." if desired else "لن يتم إرسال التنبيه." - elif kind.startswith("quote_"): - qtype = kind.split("_", 1)[1] - current = bool(get_channel_quote_types_safe(ch_id).get(qtype, False)) - if current != desired and hasattr(db, "set_channel_quote_type"): - db.set_channel_quote_type(ch_id, qtype, desired) - label, result = "اقتباس النوع", "سيُنشر النوع داخل اقتباس رسمي." if desired else "سيُنشر النوع بصيغته العادية." - elif kind == "tail": - current = bool(db.get_channel_tail_enabled(ch_id)) if hasattr(db, "get_channel_tail_enabled") else True - if current != desired and hasattr(db, "set_channel_tail_enabled"): - db.set_channel_tail_enabled(ch_id, desired) - label, result = "التوقيع", "سيُضاف التوقيع إلى المنشورات." if desired else "لن يُضاف التوقيع إلى المنشورات." - elif kind == "quote_type": - try: - ch_id, qtype = target.split("~", 1) - except ValueError: - await callback.answer("إعداد اقتباس غير صالح.", show_alert=True) - return - current = bool(get_channel_quote_types_safe(ch_id).get(qtype, False)) - if current != desired and hasattr(db, "set_channel_quote_type"): - db.set_channel_quote_type(ch_id, qtype, desired) - label, result = "اقتباس النوع", "سيُنشر النوع داخل اقتباس رسمي." if desired else "سيُنشر النوع بصيغته العادية." - elif kind == "source_enabled": - current = not bool(db.is_source_paused(target)) - if current != desired: - db.set_source_paused(target, not desired) - label, result = "تشغيل المصدر", "يستمر استقبال منشورات المصدر." if desired else "يتوقف استقبال منشورات المصدر مؤقتاً." - elif kind == "source_emoji": - current = bool(db.get_source_remove_emoji(target)) - if current != desired: - db.set_source_remove_emoji(target, desired) - label, result = "إزالة الإيموجي", "تُزال الإيموجيات من منشورات المصدر." if desired else "تبقى الإيموجيات في منشورات المصدر." - elif kind == "source_content": - try: - source_id, ctype = target.split("~", 1) - except ValueError: - await callback.answer("فلتر محتوى غير صالح.", show_alert=True) - return - current = bool(db.get_source_content_types(source_id).get(ctype, True)) - if current != desired: - ok = db.set_source_content_type(source_id, ctype, desired) - if ok is False: - await callback.answer("لا يمكن إيقاف آخر نوع محتوى.", show_alert=True) - callback.data = f"ui_info|{kind}|{target}|{back.replace('|', '~')}" - await ui_setting_info_menu(client, callback) - return - label, result = "نوع المحتوى", "يتم استقبال هذا النوع من المصدر." if desired else "يتم تجاهل هذا النوع من المصدر." - elif kind.startswith("link_"): - key = kind - ch = db.get_channel(ch_id) or {} - current = bool(ch.get(key, False)) - if current != desired: - db.update_channel(ch_id, key, desired) - label, result = "فلتر الروابط", "تُزال هذه الروابط من المنشورات." if desired else "تبقى هذه الروابط في المنشورات." - elif kind == "tail_min": - current = int(db.get_channel_tail_min_words(ch_id) if hasattr(db, "get_channel_tail_min_words") else 20) >= 20 - if current != desired and hasattr(db, "set_channel_tail_min_words"): - db.set_channel_tail_min_words(ch_id, 20 if desired else 0) - label, result = "الحد الأدنى للتوقيع", "يُطبّق حد 20 كلمة للتوقيع." if desired else "لا يُطبّق حد أدنى للكلمات." - elif kind == "tail_pos": - current = (db.get_channel_tail_position(ch_id) if hasattr(db, "get_channel_tail_position") else "bottom") == "top" - if current != desired and hasattr(db, "set_channel_tail_position"): - db.set_channel_tail_position(ch_id, "top" if desired else "bottom") - label, result = "مكان التوقيع", "يوضع التوقيع أعلى المنشور." if desired else "يوضع التوقيع أسفل المنشور." - elif kind == "pause": - ch = db.get_channel(ch_id) or {} - current = not bool(ch.get("paused", False)) - if current != desired: - db.update_channel(ch_id, "paused", not desired) - label, result = "تشغيل القناة", "تستمر القناة في العمل والنشر." if desired else "تتوقف القناة مؤقتاً عن المعالجة والنشر." - elif kind == "assign": - try: - rtype, ch_id, item_id = target.split("~", 2) - except ValueError: - await callback.answer("تعيين مورد غير صالح.", show_alert=True) - return - info = _RESOURCE_MAP.get(rtype) - if not info: - await callback.answer("نوع مورد غير معروف.", show_alert=True) - return - if rtype == "bots" and hasattr(db, "mapper"): - current = bool(db.mapper.is_assigned(item_id, ch_id)) - else: - cfg = db.get_channel_config(ch_id) or {} - current = str(item_id) in [str(x) for x in cfg.get(info["cfg_key"], [])] - if current != desired: - if rtype == "bots" and hasattr(db, "mapper"): - if desired: - db.mapper.assign(item_id, ch_id) - token = db.bot_manager.get_token(item_id) if hasattr(db, "bot_manager") else "" - if token: - try: - await verifier.check_bot_in_channel(item_id, ch_id, force=True) - except Exception: - pass - else: - db.mapper.unassign(item_id, ch_id) - else: - values = list(cfg.get(info["cfg_key"], [])) - if desired and str(item_id) not in [str(x) for x in values]: - values.append(item_id) - elif not desired: - values = [x for x in values if str(x) != str(item_id)] - db.update_channel_config(ch_id, **{info["cfg_key"]: values}) - label, result = "تعيين المورد", "سيُستخدم المورد مع هذه القناة." if desired else "لن يُستخدم المورد مع هذه القناة." - elif kind == "session": - item = db.get_session(target) or {} - current = bool(item.get("enabled", True)) - if current != desired: - db.set_session_enabled(target, desired) - label, result = "الجلسة", "تبقى الجلسة متاحة للتشغيل." if desired else "تتوقف الجلسة عن التشغيل." - elif kind == "ai_key": - item = db.get_ai_key(target) or {} - current = bool(item.get("enabled", True)) - if current != desired: - db.set_ai_key_enabled(target, desired) - label, result = "مفتاح الذكاء الاصطناعي", "يمكن استخدام المفتاح." if desired else "لا يُستخدم المفتاح." - elif kind == "publishing_bot": - item = db.get_publishing_bot(target) or {} - current = bool(item.get("enabled", True)) - if current != desired: - db.set_publishing_bot_enabled(target, desired) - label, result = "بوت النشر", "يبقى البوت متاحاً للنشر." if desired else "يتوقف استخدام البوت للنشر." - elif kind == "website": - item = db.get_website(target) or {} - current = bool(item.get("enabled", True)) - if current != desired: - db.set_website_enabled(target, desired) - label, result = "الموقع", "يبقى الموقع متاحاً للاستخدام." if desired else "يتوقف استخدام الموقع." - else: - await callback.answer("إعداد غير معروف.", show_alert=True) - return - await callback.answer(f"✅ تم {'تشغيل' if desired else 'إيقاف'} {label}.", show_alert=True) - callback.data = f"ui_info|{kind}|{target}|{back.replace('|', '~')}" - await ui_setting_info_menu(client, callback) -async def action_info_menu(client, callback): - """صفحة شرح عامة للإجراءات قبل التنفيذ، مع الحفاظ على callback التنفيذي الأصلي.""" - parts = callback.data.split("|", 2) - if len(parts) != 3: - await callback.answer("طلب غير صالح.", show_alert=True) - return - _, action, payload = parts - descriptions = { - "testpub": ("🧪 اختبار نشر القناة", "يرسل منشور اختبار فعلياً إلى هذه القناة للتحقق من صلاحية النشر وتطبيق تنسيق القناة الحالي.", "▶️ إرسال اختبار النشر"), - "testsrc": ("🧪 اختبار المصدر", "يفحص إمكانية الوصول إلى المصدر ويعرض نوعه وآخر رسالة متاحة والقنوات المستهدفة دون تغيير إعداداته.", "▶️ اختبار المصدر"), - "chbotcheck": ("🤖 فحص بوت القناة", "يتحقق من وجود بوت النشر داخل القناة وصلاحياته، ثم يعرض الحالة الفعلية.", "▶️ بدء فحص البوت"), - "test_all_channels": ("🧪 اختبار كل القنوات", "ينفذ اختبار نشر للقنوات غير المتوقفة ويعرض عدد القنوات الناجحة والفاشلة.", "▶️ اختبار كل القنوات"), - "full_check": ("🔍 الفحص الشامل", "يفحص الجلسة والبوت والوسيطة وقنوات النشر والمصادر دون تغيير إعداداتها.", "▶️ بدء الفحص الشامل"), - "preview": ("👁 معاينة المنشور", "يعرض تقريراً يوضح كيف ستطبق إعدادات القناة الحالية على منشور تجريبي قبل النشر.", "▶️ عرض المعاينة"), - "clear_errors": ("🗑 مسح الأخطاء", "يمسح سجل الأخطاء المعروض في لوحة الأخطاء فقط، ولا يغيّر إعدادات النشر أو السجل التشغيلي العام.", "▶️ مسح سجل الأخطاء"), - "stats": ("📊 الإحصائيات", "يعرض إجمالي المنشورات والقنوات والمصادر والكلمات المحظورة وتفصيل المنشورات حسب القناة.", "▶️ عرض الإحصائيات"), - "export": ("📥 تصدير البيانات", "ينشئ ملف JSON يحتوي إعدادات وبيانات البوت الحالية ويرسله كنسخة احتياطية.", "▶️ تصدير البيانات"), - "srcstats": ("📊 إحصائيات المصدر", "يعرض أرقام الاستلام والنشر والرفض والتجاهل والتكرار والأخطاء وآخر حدث للمصدر.", "▶️ عرض إحصائيات المصدر"), - "srclog": ("📋 سجل المصدر", "يعرض آخر العمليات المسجلة لهذا المصدر مع الوقت ونوع الحدث ورقم الرسالة والسبب.", "▶️ عرض سجل المصدر"), - "session_test": ("🧪 اختبار الجلسة", "يتحقق من اتصال جلسة Telegram المحددة دون تغيير إعداد تفعيلها.", "▶️ اختبار الجلسة"), - "ai_test": ("🧪 اختبار مفتاح AI", "يتحقق من اتصال مزود الذكاء الاصطناعي للمفتاح المحدد دون تغيير حالته.", "▶️ اختبار المفتاح"), - "bot_test": ("🧪 اختبار بوت النشر", "يتحقق من توكن بوت النشر واتصاله، مع تحديث إحصائية الاستخدام عند نجاح الاختبار وفق الدالة الأصلية.", "▶️ اختبار البوت"), - "bot_verify": ("🔍 فحص قنوات البوت", "يفحص صلاحيات بوت النشر في القنوات المرتبطة به ويعرض عدد القنوات الموثوقة وغير الموثوقة.", "▶️ فحص القنوات"), - "web_test": ("🧪 اختبار الموقع", "يجلب الموقع المحدد ويتحقق من استجابته ويعرض رمز HTTP أو عدد الأحرف عند النجاح.", "▶️ اختبار الموقع"), - "test": ("🧪 اختبار القناة", "ينفذ اختباراً للقناة للتحقق من إمكانية النشر.", "▶️ تنفيذ الاختبار"), - "import": ("📥 استيراد البيانات", "يستورد إعدادات البوت من ملف JSON مدعوم. ⚠️ سيتم استبدال أو تحديث البيانات وفق سلوك الاستيراد الحالي، وسيبقى انتظار الملف عبر نفس MessageHandler وuser_state.", "📥 متابعة الاستيراد"), - "copysrc": ("📋 نسخ إعدادات المصدر", "يحفظ إعدادات هذا المصدر في user_state لتتمكن من لصقها في مصدر آخر أو عدة مصادر. لن يبدأ النسخ إلى مصادر أخرى من هذه الصفحة.", "📋 متابعة نسخ الإعدادات"), - "pastesrc": ("📋 لصق إعدادات المصدر", "يلصق الإعدادات المنسوخة في هذا المصدر باستخدام user_state الحالي ثم يعرض نتيجة العملية الأصلية.", "📋 تنفيذ اللصق"), - "bulkpastesrc": ("📋 لصق الإعدادات لعدة مصادر", "يفتح نفس MessageHandler الحالي لانتظار قائمة المصادر، مع الحفاظ على user_state وآلية الإدخال الأصلية.", "📋 متابعة اللصق المتعدد"), - "restore": ("♻️ استعادة العنصر", "يعيد العنصر من سلة المحذوفات إلى حالته السابقة. سيُستخدم مسار الاستعادة الأصلي ثم تظهر نتيجة العملية وتعود إلى السلة.", "♻️ متابعة الاستعادة"), - "delete_session": ("🗑 حذف الجلسة", "ينقل الجلسة إلى سلة المحذوفات إذا لم تكن مستخدمة. سيجري فحص dependencies الأصلي قبل التنفي�� ولن يتم تجاوز تحذير الاستخدام.", "🗑 متابعة حذف الجلسة"), - "delete_ai": ("🗑 حذف مفتاح AI", "ينقل مفتاح AI إلى سلة المحذوفات إذا لم يكن مستخدماً. سيبقى فحص dependencies الأصلي كما هو.", "🗑 متابعة حذف المفتاح"), - "delete_bot": ("🗑 حذف بوت النشر", "ينقل بوت النشر إلى سلة المحذوفات إذا لم يكن مرتبطاً بقنوات. سيبقى فحص dependencies الأصلي كما هو.", "🗑 متابعة حذف البوت"), - "delete_web": ("🗑 حذف الموقع", "ينقل الموقع إلى سلة المحذوفات وفق سلوكه الحالي، مع الحفاظ على أي فحص dependencies موجود في الدالة الأصلية.", "🗑 متابعة حذف الموقع"), - "delete_channel": ("🗑 حذف قناة النشر", "يفتح نفس قائمة اختيار القناة ثم يبقي زر التأكيد الحالي قبل الحذف الفعلي.", "🗑 متابعة حذف القناة"), - "purge": ("🗑 حذف المنشورات", "يفتح صفحة اختيار/تأكيد نوع المنشورات ثم يحافظ على تأكيد الحذف الحالي قبل التنفيذ.", "🗑 متابعة حذف المنشورات"), - "trash_permdel": ("🗑 الحذف النهائي من السلة", "يحذف العنصر نهائياً من سلة المحذوفات ولا يمكن التراجع عن العملية. ستبقى صفحة التأكيد الحالية.", "🗑 متابعة الحذف النهائي"), - "trash_empty": ("🗑 تفريغ سلة المحذوفات", "يحذف جميع عناصر السلة نهائياً ولا يمكن التراجع. ستبقى صفحة التأكيد الحالية.", "🗑 متابعة تفريغ السلة"), - } - title, desc, button = descriptions.get(action, ("ℹ️ شرح العملية", "راجع وظيفة العملية قبل تنفيذها.", "▶️ تنفيذ العملية")) - execute_token, back_token = (payload.split("^", 1) + [None])[:2] if "^" in payload else (payload, None) - back = back_token.replace("~", "|") if back_token else (_peek_prev_back(callback) or "main_menu") - text = f"{title}\n\nوظيفة العملية:\n{desc}\n\nلن يتم التنفيذ حتى تضغط زر التنفيذ." - await safe_edit(callback, text, InlineKeyboardMarkup([ - [InlineKeyboardButton(button, callback_data=f"ui_action_exec|{action}|{execute_token}^{back.replace('|', '~')}")], - [InlineKeyboardButton("🔙 رجوع", callback_data=back)], - ])) - - -async def action_execute_menu(client, callback): - """ينفذ Action محدداً عبر دالته الأصلية، ثم يعرض نتيجتها أو يعيد صفحة المورد.""" - parts = callback.data.split("|", 2) - if len(parts) != 3 or "^" not in parts[2]: - await callback.answer("طلب تنفيذ غير صالح.", show_alert=True) - return - _, action, payload = parts - execute_token, back_token = payload.split("^", 1) - execute_data = execute_token.replace("~", "|") - back_data = back_token.replace("~", "|") - executors = { - "testpub": test_publish_channel, - "testsrc": test_source, - "chbotcheck": channel_bot_check, - "test_all_channels": test_all_channels, - "full_check": full_check_menu, - "preview": preview_post, - "clear_errors": clear_errors, - "stats": show_stats, - "export": export_callback, - "srcstats": source_stats_menu, - "srclog": source_log_menu, - "session_test": test_session, - "ai_test": test_ai_key, - "bot_test": test_bot, - "bot_verify": verify_bot_channels, - "web_test": test_website, - "import": import_callback, - "copysrc": copy_source_settings_prompt, - "pastesrc": paste_source_settings, - "bulkpastesrc": bulk_paste_source_prompt, - "restore": trash_restore_item, - "delete_session": delete_session_with_deps, - "delete_ai": delete_ai_key_with_deps, - "delete_bot": delete_bot_with_deps, - "delete_web": delete_website_with_deps, - "delete_channel": delete_channel_prompt, - "purge": purge_published_prompt, - "trash_permdel": trash_permanent_delete_prompt, - "trash_empty": trash_empty_prompt, - } - if action not in executors: - await callback.answer("هذا الإجراء غير متاح.", show_alert=True) - return - callback.data = execute_data - await executors[action](client, callback) - return_handlers = { - "testpub": test_settings_menu, - "export": backup_menu, - "session_test": show_session_settings, - "ai_test": show_ai_key_settings, - "bot_test": show_bot_settings, - "web_test": show_website_settings, - } - if action in return_handlers: - callback.data = back_data - await return_handlers[action](client, callback) - - - -def register_bot_handlers(app: Client): - app.add_handler(MessageHandler(start, filters.command("start"))) - app.add_handler(MessageHandler(handle_import, filters.document)) - register_blogger_handlers(app) - - async def callback_dispatcher(client, callback): - data = callback.data - if str(data).startswith("ui_ctx|"): - expanded = _expand_ui_callback_data(data) - if expanded is None: - await callback.answer("انتهت صلاحية هذا الزر. افتح القائمة من جديد.", show_alert=True) - return - data = expanded - callback.data = expanded - - if data == "main_menu": - await safe_edit(callback, "القائمة الرئيسية:", main_keyboard) - elif data == "back_stack": - previous = _back_step(callback) - if previous is None: - await callback.answer("لا توجد صفحة سابقة في هذه الجلسة.", show_alert=True) - return - callback.data = previous - if str(previous).startswith("ui_ctx|"): - expanded = _expand_ui_callback_data(previous) - if expanded is None: - await callback.answer("انتهت صلاحية هذا الزر. افتح القائمة من جديد.", show_alert=True) - return - callback.data = expanded - await callback_dispatcher(client, callback) - else: - _push_back(callback) - if data.startswith("ui_info|"): - await ui_setting_info_menu(client, callback) - elif data.startswith("ui_set|"): - await ui_setting_set(client, callback) - elif data.startswith("ui_action_exec|"): - await action_execute_menu(client, callback) - elif data.startswith("ui_action|"): - await action_info_menu(client, callback) - elif data in BLOGGER_CALLBACKS: - await BLOGGER_CALLBACKS[data](client, callback) - elif data.startswith("togglebold_"): - ch_id = data.rsplit("_", 1)[1] - callback.data = f"ui_info|bold|{ch_id}|postset_{ch_id}" - await ui_setting_info_menu(client, callback) - elif data.startswith("toggle_short_posts|"): - ch_id = data.split("|", 1)[1] - callback.data = f"ui_info|short_posts|{ch_id}|postset_{ch_id}" - await ui_setting_info_menu(client, callback) - elif data.startswith("ch_titlequote_"): - ch_id = data.rsplit("_", 1)[1] - callback.data = f"ui_info|title_quote|{ch_id}|quotemenu_{ch_id}" - await ui_setting_info_menu(client, callback) - elif data.startswith("ch_sigquote_"): - ch_id = data.rsplit("_", 1)[1] - callback.data = f"ui_info|signature_quote|{ch_id}|quotemenu_{ch_id}" - await ui_setting_info_menu(client, callback) - elif data.startswith("ch_preview_"): - ch_id = data.rsplit("_", 1)[1] - callback.data = f"ui_info|preview|{ch_id}|postset_{ch_id}" - await ui_setting_info_menu(client, callback) - elif data.startswith("blogger_"): - from modules.blogger.ui import DYNAMIC_PREFIXES - matched = False - for prefix, handler_name in DYNAMIC_PREFIXES.items(): - if data.startswith(prefix): - import importlib - mod = importlib.import_module("modules.blogger.ui") - handler = getattr(mod, handler_name) - await handler(client, callback) - matched = True - break - if not matched: - await callback.answer("أمر غير معروف.") - elif data == "menu_channels": - await menu_channels(client, callback) - elif data.startswith("ch_extended_"): - await channel_extended_settings(client, callback) - elif data.startswith("ch_titlequote_"): - await toggle_channel_title_quote(client, callback) - elif data.startswith("ch_sigquote_"): - await toggle_channel_signature_quote(client, callback) - elif data.startswith("ch_preview_"): - await toggle_channel_preview(client, callback) - elif data.startswith("postset_"): - await post_settings_menu(client, callback) - elif data.startswith("srcset_"): - await source_settings_channel_menu(client, callback) - elif data.startswith("tails_"): - await tail_settings_menu(client, callback) - elif data.startswith("testset_"): - await test_settings_menu(client, callback) - elif data.startswith("preview_"): - ch_id = data.split("_", 1)[1] - callback.data = f"ui_action|preview|preview_{ch_id}^testset_{ch_id}" - await action_info_menu(client, callback) - elif data.startswith("tailtoggle_"): - ch_id = data.rsplit("_", 1)[1] - callback.data = f"ui_info|tail|{ch_id}|tails_{ch_id}" - await ui_setting_info_menu(client, callback) - elif data.startswith("tailmintoggle_"): - ch_id = data.rsplit("_", 1)[1] - callback.data = f"ui_info|tail_min|{ch_id}|tails_{ch_id}" - await ui_setting_info_menu(client, callback) - elif data.startswith("tailpos_"): - ch_id = data.rsplit("_", 1)[1] - callback.data = f"ui_info|tail_pos|{ch_id}|tails_{ch_id}" - await ui_setting_info_menu(client, callback) - elif data.startswith("genset_copy_"): - await general_settings_copy(client, callback) - elif data.startswith("genset_paste_confirm_"): - await general_settings_paste_confirm(client, callback) - elif data.startswith("genset_paste_"): - await general_settings_paste(client, callback) - elif data.startswith("genset_reset_confirm_"): - await general_settings_reset_confirm(client, callback) - elif data.startswith("genset_reset_"): - await general_settings_reset(client, callback) - elif data.startswith("genset_summary_"): - await general_settings_summary(client, callback) - elif data.startswith("genset_"): - await general_settings_menu(client, callback) - elif data.startswith("sysset_"): - await system_channel_menu(client, callback) - elif data == "system_menu": - await system_menu(client, callback) - elif data.startswith("purgepage_"): - await purge_published_menu(client, callback) - elif data.startswith("chwordadd|"): - await channel_add_blocked_word_prompt(client, callback) - elif data.startswith("chworddelidx|"): - await confirm_channel_del_blocked_word(client, callback) - elif data.startswith("chworddel|"): - await channel_del_blocked_word_prompt(client, callback) - elif data.startswith("chwords|"): - await channel_blocked_words_menu(client, callback) - elif data.startswith("chlinks|"): - await channel_links_menu(client, callback) - elif data.startswith("chbotcheck|"): - ch_id = data.split("|", 1)[1] - callback.data = f"ui_action|chbotcheck|chbotcheck~{ch_id}^ch_{ch_id}" - await action_info_menu(client, callback) - elif data.startswith("chlinktg|") or data.startswith("chlinktguser|") or data.startswith("chlinkweb|"): - prefix, ch_id = data.split("|", 1) - kind = { - "chlinktg": "link_remove_tg", - "chlinktguser": "link_remove_tg_user", - "chlinkweb": "link_remove_web", - }[prefix] - callback.data = f"ui_info|{kind}|{ch_id}|chlinks~{ch_id}" - await ui_setting_info_menu(client, callback) - elif data.startswith("chdeltermadd|"): - await channel_add_delete_term_prompt(client, callback) - elif data.startswith("chdeltermdel|"): - await channel_del_delete_term_prompt(client, callback) - elif data.startswith("chdelterms|"): - await channel_delete_terms_menu(client, callback) - elif data.startswith("ch_"): - await channel_settings(client, callback) - elif data == "add_channel": - await add_channel_prompt(client, callback) - elif data.startswith("togglebold_"): - await toggle_channel_bold(client, callback) - elif data.startswith("quotemenu_"): - await channel_quote_menu(client, callback) - elif data.startswith("toggleqtype|"): - _, ch_id, qtype = data.split("|", 2) - callback.data = f"ui_info|quote_type|{ch_id}~{qtype}|quotemenu_{ch_id}" - await ui_setting_info_menu(client, callback) - elif data.startswith("togglequote_"): - # Compatibility/navigation alias: this opens the quote menu and is not a Boolean setter. - await toggle_channel_quote(client, callback) - elif data.startswith("hashtags_"): - await channel_hashtags_menu(client, callback) - elif data.startswith("addhashtags_"): - await add_channel_hashtags_prompt(client, callback) - elif data.startswith("delhashtags_"): - await del_channel_hashtags_prompt(client, callback) - elif data.startswith("testpub_"): - ch_id = data.split("_", 1)[1] - callback.data = f"ui_action|testpub|testpub_{ch_id}^testset_{ch_id}" - await action_info_menu(client, callback) - elif data.startswith("purgepub|"): - execute_token = data.replace("|", "~") - _, ch_id, kind = data.split("|", 2) - callback.data = f"ui_action|purge|{execute_token}^purgepage_{ch_id}" - await action_info_menu(client, callback) - elif data.startswith("confirmpurge|"): - await confirm_purge_published(client, callback) - elif data == "toggle_maintenance" or data.startswith("toggle_maintenance|"): - ch_id = data.split("|", 1)[1] if "|" in data else None - back = f"ops_menu~{ch_id}" if ch_id else "ops_menu" - callback.data = f"ui_info|maintenance|global|{back}" - await ui_setting_info_menu(client, callback) - elif data == "toggle_short_posts" or data.startswith("toggle_short_posts|"): - ch_id = data.split("|", 1)[1] if "|" in data else "global" - back = f"ops_menu~{ch_id}" if ch_id != "global" else "ops_menu" - callback.data = f"ui_info|short_posts|{ch_id}|{back}" - await ui_setting_info_menu(client, callback) - elif data.startswith("toggle_notif_"): - key = data.split("toggle_notif_", 1)[1].split("|", 1)[0] - notif_ch = data.split("|", 1)[1] if "|" in data else None - callback.data = f"ui_info|notification|{key}|notifications_menu~{notif_ch}" if notif_ch else f"ui_info|notification|{key}|notifications_menu" - await ui_setting_info_menu(client, callback) - elif data.startswith("legacy_toggle_notif_"): - key = data.split("legacy_toggle_notif_", 1)[1] - notif_ch = data.split("|", 1)[1] if "|" in data else None - if key in db.NOTIFICATION_TYPES: - settings = db.get_notification_settings() - db.set_notification_setting(key, not settings.get(key, True)) - await callback.answer("تم التبديل.") - callback.data = f"notifications_menu|{notif_ch}" if notif_ch else "notifications_menu" - await notifications_menu(client, callback) - else: - await callback.answer("غير معروف.") - elif data.startswith("toggle_assign_"): - parts = data.split("_", 4) - if len(parts) == 5: - _, _, rtype, ch_id, item_id = parts - callback.data = f"ui_info|assign|{rtype}~{ch_id}~{item_id}|assign_{rtype}_{ch_id}" - await ui_setting_info_menu(client, callback) - else: - await toggle_assign_resource(client, callback) - elif data.startswith("toggle_"): - ch_id = data.split("_", 1)[1] - callback.data = f"ui_info|pause|{ch_id}|ch_{ch_id}" - await ui_setting_info_menu(client, callback) - elif data.startswith("delchannel_"): - ch_id = data.split("_", 1)[1] - callback.data = f"ui_action|delete_channel|delchannel_{ch_id}^genset_{ch_id}" - await action_info_menu(client, callback) - elif data.startswith("confirm_del_"): - await confirm_delete_channel(client, callback) - elif data.startswith("tailmenu_"): - await tail_menu(client, callback) - elif data.startswith("edittail_"): - await edit_tail_prompt(client, callback) - elif data.startswith("deltail_"): - await delete_tail(client, callback) - elif data == "menu_public_src": - await menu_public_sources(client, callback) - elif data == "add_public_src": - await add_public_src_prompt(client, callback) - elif data == "del_public_src": - await del_public_src_prompt(client, callback) - elif data.startswith("del_pub_idx_"): - await confirm_del_public_src(client, callback) - elif data == "menu_blocked": - await menu_blocked_words(client, callback) - elif data == "add_blocked_word": - await add_blocked_word_prompt(client, callback) - elif data == "del_blocked_word": - await del_blocked_word_prompt(client, callback) - elif data.startswith("delword_idx_"): - await confirm_del_blocked_word(client, callback) - elif data.startswith("specsrc_"): - await manage_special_sources(client, callback) - elif data.startswith("addspecsrc_"): - await add_special_source_prompt(client, callback) - elif data.startswith("delspecsrc_"): - await delete_special_source_prompt(client, callback) - elif data.startswith("confdelspec_"): - await confirm_delete_special_source(client, callback) - elif data.startswith("srcset|"): - await source_settings_menu(client, callback) - elif data.startswith("togglesource|"): - _, source_id, back = data.split("|", 2) - back_token = back.replace("|", "~") - callback.data = f"ui_info|source_enabled|{source_id}|srcset~{source_id}~{back_token}" - await ui_setting_info_menu(client, callback) - elif data.startswith("toggleemoji|"): - _, source_id, back = data.split("|", 2) - back_token = back.replace("|", "~") - callback.data = f"ui_info|source_emoji|{source_id}|srcset~{source_id}~{back_token}" - await ui_setting_info_menu(client, callback) - elif data.startswith("contentmenu|"): - await source_content_menu(client, callback) - elif data.startswith("togglectype|"): - _, source_id, ctype, back = data.split("|", 3) - back_token = back.replace("|", "~") - callback.data = f"ui_info|source_content|{source_id}~{ctype}|contentmenu~{source_id}~{back_token}" - await ui_setting_info_menu(client, callback) - elif data.startswith("clean_src|"): - await source_cleanup_menu(client, callback) - elif data.startswith("addclean|"): - await add_source_cleanup_prompt(client, callback) - elif data.startswith("delclean|"): - await del_source_cleanup_prompt(client, callback) - elif data == "global_clean": - await global_cleanup_menu(client, callback) - elif data == "addglobalclean": - await add_global_cleanup_prompt(client, callback) - elif data == "delglobalclean": - await del_global_cleanup_prompt(client, callback) - elif data.startswith("speedmenu_"): - await channel_speed_menu(client, callback) - elif data.startswith("setspeed|"): - await set_channel_speed(client, callback) - elif data.startswith("customspeed_"): - await custom_channel_speed_prompt(client, callback) - elif data.startswith("srcstats|"): - _, source_id, back = data.split("|", 2) - execute_token = data.replace("|", "~") - back_token = f"srcset~{source_id}~{back.replace('|', '~')}" - callback.data = f"ui_action|srcstats|{execute_token}^{back_token}" - await action_info_menu(client, callback) - elif data.startswith("srclog|"): - _, source_id, back = data.split("|", 2) - execute_token = data.replace("|", "~") - back_token = f"srcset~{source_id}~{back.replace('|', '~')}" - callback.data = f"ui_action|srclog|{execute_token}^{back_token}" - await action_info_menu(client, callback) - elif data.startswith("testsrc|"): - _, source_id, back = data.split("|", 2) - execute_token = data.replace("|", "~") - back_token = f"srcset~{source_id}~{back.replace('|', '~')}" - callback.data = f"ui_action|testsrc|{execute_token}^{back_token}" - await action_info_menu(client, callback) - elif data.startswith("copysrc|"): - _, source_id, back = data.split("|", 2) - execute_token = data.replace("|", "~") - back_token = f"srcset~{source_id}~{back.replace('|', '~')}" - callback.data = f"ui_action|copysrc|{execute_token}^{back_token}" - await action_info_menu(client, callback) - elif data.startswith("pastesrc|"): - _, source_id, back = data.split("|", 2) - execute_token = data.replace("|", "~") - back_token = f"srcset~{source_id}~{back.replace('|', '~')}" - callback.data = f"ui_action|pastesrc|{execute_token}^{back_token}" - await action_info_menu(client, callback) - elif data.startswith("bulkpastesrc|"): - _, source_id, back = data.split("|", 2) - execute_token = data.replace("|", "~") - back_token = f"srcset~{source_id}~{back.replace('|', '~')}" - callback.data = f"ui_action|bulkpastesrc|{execute_token}^{back_token}" - await action_info_menu(client, callback) - elif data == "system_status" or data.startswith("system_status|"): - await system_status_menu(client, callback) - elif data == "full_check" or data.startswith("full_check|"): - ch_id = data.split("|", 1)[1] if "|" in data else None - execute_token = data.replace("|", "~") - back_token = f"system_status~{ch_id}" if ch_id else "system_status" - callback.data = f"ui_action|full_check|{execute_token}^{back_token}" - await action_info_menu(client, callback) - elif data == "ops_menu" or data.startswith("ops_menu|"): - await operations_menu(client, callback) - elif data == "errors_menu" or data.startswith("errors_menu|"): - await errors_menu(client, callback) - elif data == "clear_errors" or data.startswith("clear_errors|"): - ch_id = data.split("|", 1)[1] if "|" in data else None - execute_token = data.replace("|", "~") - back_token = f"errors_menu~{ch_id}" if ch_id else "errors_menu" - callback.data = f"ui_action|clear_errors|{execute_token}^{back_token}" - await action_info_menu(client, callback) - elif data == "named_backup_prompt": - await named_backup_prompt(client, callback) - elif data == "test_all_channels" or data.startswith("test_all_channels|"): - ch_id = data.split("|", 1)[1] if "|" in data else None - execute_token = data.replace("|", "~") - back_token = f"ops_menu~{ch_id}" if ch_id else "ops_menu" - callback.data = f"ui_action|test_all_channels|{execute_token}^{back_token}" - await action_info_menu(client, callback) - elif data == "stats": - callback.data = "ui_action|stats|stats^main_menu" - await action_info_menu(client, callback) - elif data == "backup_menu": - await backup_menu(client, callback) - elif data == "export_data": - callback.data = "ui_action|export|export_data^backup_menu" - await action_info_menu(client, callback) - elif data == "import_data": - callback.data = "ui_action|import|import_data^backup_menu" - await action_info_menu(client, callback) - elif data == "log_menu" or data.startswith("log_menu|"): - await log_management_menu(client, callback) - elif data.startswith("log_view"): - await view_bot_log(client, callback) - elif data.startswith("log_refresh"): - await view_bot_log(client, callback) - elif data.startswith("log_size"): - await show_log_size(client, callback) - elif data.startswith("log_clear_prompt"): - await clear_log_prompt(client, callback) - elif data.startswith("log_clear_confirm"): - await confirm_clear_log(client, callback) - # Multi-Bot Expansion: Secrets - elif data == "secrets_menu": - await secrets_menu(client, callback) - elif data == "sessions_list": - await sessions_list_menu(client, callback) - elif data == "session_add": - await add_session_prompt(client, callback) - elif data.startswith("session_show_"): - await show_session_settings(client, callback) - elif data.startswith("session_toggle_"): - sid = data.split("session_toggle_", 1)[1] - callback.data = f"ui_info|session|{sid}|session_show_{sid}" - await ui_setting_info_menu(client, callback) - elif data.startswith("session_delete_"): - sid = data.split("session_delete_", 1)[1] - callback.data = f"ui_action|delete_session|session_delete_{sid}^session_show_{sid}" - await action_info_menu(client, callback) - elif data.startswith("session_test_"): - sid = data.split("session_test_", 1)[1] - callback.data = f"ui_action|session_test|session_test_{sid}^session_show_{sid}" - await action_info_menu(client, callback) - elif data == "ai_list": - await ai_list_menu(client, callback) - elif data == "ai_add": - await add_ai_key_prompt(client, callback) - elif data.startswith("ai_show_"): - await show_ai_key_settings(client, callback) - elif data.startswith("ai_toggle_"): - kid = data.split("ai_toggle_", 1)[1] - callback.data = f"ui_info|ai_key|{kid}|ai_show_{kid}" - await ui_setting_info_menu(client, callback) - elif data.startswith("ai_delete_"): - kid = data.split("ai_delete_", 1)[1] - callback.data = f"ui_action|delete_ai|ai_delete_{kid}^ai_show_{kid}" - await action_info_menu(client, callback) - elif data.startswith("ai_test_"): - kid = data.split("ai_test_", 1)[1] - callback.data = f"ui_action|ai_test|ai_test_{kid}^ai_show_{kid}" - await action_info_menu(client, callback) - elif data == "bots_list": - await bots_list_menu(client, callback) - elif data == "bot_add": - await add_bot_prompt(client, callback) - elif data.startswith("bot_rename_"): - await rename_bot_prompt(client, callback) - elif data.startswith("bot_verify_"): - bid = data.split("bot_verify_", 1)[1] - callback.data = f"ui_action|bot_verify|bot_verify_{bid}^bot_show_{bid}" - await action_info_menu(client, callback) - elif data.startswith("bot_show_"): - await show_bot_settings(client, callback) - elif data.startswith("bot_toggle_"): - bid = data.split("bot_toggle_", 1)[1] - callback.data = f"ui_info|publishing_bot|{bid}|bot_show_{bid}" - await ui_setting_info_menu(client, callback) - elif data.startswith("bot_delete_"): - bid = data.split("bot_delete_", 1)[1] - callback.data = f"ui_action|delete_bot|bot_delete_{bid}^bot_show_{bid}" - await action_info_menu(client, callback) - elif data.startswith("bot_test_"): - bid = data.split("bot_test_", 1)[1] - callback.data = f"ui_action|bot_test|bot_test_{bid}^bot_show_{bid}" - await action_info_menu(client, callback) - elif data == "web_list": - await web_list_menu(client, callback) - elif data == "web_add": - await add_website_prompt(client, callback) - elif data.startswith("web_show_"): - await show_website_settings(client, callback) - elif data.startswith("web_toggle_"): - wid = data.split("web_toggle_", 1)[1] - callback.data = f"ui_info|website|{wid}|web_show_{wid}" - await ui_setting_info_menu(client, callback) - elif data.startswith("web_edit_"): - await edit_website_prompt(client, callback) - elif data.startswith("web_delete_"): - wid = data.split("web_delete_", 1)[1] - callback.data = f"ui_action|delete_web|web_delete_{wid}^web_show_{wid}" - await action_info_menu(client, callback) - elif data.startswith("web_test_"): - wid = data.split("web_test_", 1)[1] - callback.data = f"ui_action|web_test|web_test_{wid}^web_show_{wid}" - await action_info_menu(client, callback) - elif data == "trash_menu": - await trash_menu(client, callback) - elif data.startswith("trash_restore_"): - tid = data.split("trash_restore_", 1)[1] - callback.data = f"ui_action|restore|trash_restore_{tid}^trash_menu" - await action_info_menu(client, callback) - elif data.startswith("trash_permdel_"): - tid = data.split("trash_permdel_", 1)[1] - callback.data = f"ui_action|trash_permdel|trash_permdel_{tid}^trash_menu" - await action_info_menu(client, callback) - elif data.startswith("trash_permconfirm_"): - await trash_permanent_delete_confirm(client, callback) - elif data == "trash_empty_prompt": - callback.data = "ui_action|trash_empty|trash_empty_prompt^trash_menu" - await action_info_menu(client, callback) - elif data == "trash_empty_confirm": - await trash_empty_confirm(client, callback) - elif data == "notifications_menu" or data.startswith("notifications_menu|"): - await notifications_menu(client, callback) - elif data.startswith("toggle_assign_"): - await toggle_assign_resource(client, callback) - elif data.startswith("assign_"): - await assign_resource_menu(client, callback) - else: - await callback.answer("غير معروف.") - - app.add_handler(CallbackQueryHandler(callback_dispatcher)) - app.add_handler(MessageHandler(handle_text_input, filters.text & filters.private)) - -def register_user_handlers(app: Client): - # نلتقط كل رسائل حساب المستخدم ثم نفلتر داخل user_message_handler - # هذا يساعدنا نعرف هل SESSION_STRING يلتقط المصادر العامة أو لا - app.add_handler(MessageHandler(user_message_handler)) - -def register_middle_handlers(app: Client): - app.add_handler(MessageHandler(middle_channel_handler, filters.chat(config.MIDDLE_CHANNEL))) - -NOTIFICATION_CHECK_INTERVAL = 300 # 5 دقائق -NOTIFICATION_COOLDOWN = 3600 # إعادة التنبيه بعد ساعة - - -async def notify_admins_custom(text, check_key): - """إرسال إشعار لكل المشرفين مع مراعاة cooldown.""" - now = time.time() - last_ts = _last_alert_ts.get(check_key, 0) - if now - last_ts < NOTIFICATION_COOLDOWN: - return - _last_alert_ts[check_key] = now - for uid in config.ADMINS: - try: - await bot_client.send_message(uid, text) - except Exception as e: - logger.warning(f"فشل إرسال إشعار {check_key} للمشرف {uid}: {e}") - - -async def _check_sessions_health(): - """التأكد من أن Sessions شغالة.""" - for s in db.get_all_sessions(): - status = s.get("status", "") - enabled = s.get("enabled", True) - if not enabled or status in ("stopped", "error"): - await notify_admins_custom( - f"⚠️ **Session متوقفة:** `{s.get('name', s['id'])}`\n" - f"الحالة: {status}\n" - f"يرجى مراجعة إدارة الجلسات.", - "session_stopped" - ) - - -async def _check_ai_key_errors(): - """التأكد من أن مفاتيح AI سليمة.""" - for k in db.get_all_ai_keys(): - stats = db.get_ai_key_stats(k["id"]) - errs = stats.get("error_count", 0) - enabled = stats.get("enabled", True) - if not enabled or errs > 3: - await notify_admins_custom( - f"⚠️ **مفتاح AI به مشكلة:** `{k.get('name', k['id'])}`\n" - f"الأخطاء: {errs}\n" - f"المزوّد: {stats.get('provider', '?')}", - "ai_key_error" - ) - - -async def _check_sources_stopped(): - """التأكد من أن المصادر العامة ليست متوقفة.""" - for src in db.get_all_public_sources_with_meta(): - sid = src.get("id") or src.get("_id") - if sid is None: - continue - meta = db.get_source_meta(sid) - paused = meta.get("paused", False) - if paused: - await notify_admins_custom( - f"⚠️ **مصدر متوقف:** `{meta.get('name', sid)}`\n" - f"تم إيقافه يدوياً أو تلقائياً.", - "source_stopped" - ) - - -async def _check_channels_idle(): - """التأكد من أن القنوات تنشر بانتظام.""" - now = time.time() - for ch in db.get_all_channels(): - if ch.get("paused", False): - continue - cid = ch["id"] - try: - with db.lock: - data = db._read() - pm = data.get("published_messages", {}).get(str(cid), {}) - latest_ts = 0 - for msgs in pm.values(): - if isinstance(msgs, list) and msgs: - last_msg = msgs[-1] - if isinstance(last_msg, dict): - latest_ts = max(latest_ts, last_msg.get("ts", 0)) - if latest_ts and now - latest_ts > 3600: - name = ch.get("name", cid) - await notify_admins_custom( - f"⏰ **قناة لم تنشر منذ أكثر من ساعة:** `{name}`\n" - f"آخر نشر: {time.strftime('%Y-%m-%d %H:%M', time.localtime(latest_ts))}", - "channel_idle_hour" - ) - except Exception: - pass - - -async def _check_backup_fresh(): - """التأكد من أن النسخ الاحتياطي حديث.""" - try: - backups = db.get_named_backups() - if not backups: - await notify_admins_custom( - "⚠️ **لا توجد نسخ احتياطية!**\n" - "يرجى إنشاء نسخة احتياطية قريباً.", - "backup_failed" - ) - return - last = backups[-1] - last_ts = last.get("ts", 0) - now = time.time() - if now - last_ts > 86400: # أكثر من يوم - await notify_admins_custom( - f"⚠️ **آخر نسخة احتياطية قديمة:** {time.strftime('%Y-%m-%d %H:%M', time.localtime(last_ts))}\n" - f"يرجى إنشاء نسخة احتياطية جديدة.", - "backup_failed" - ) - except Exception as e: - await notify_admins_custom( - f"⚠️ **فشل التحقق من النسخ الاحتياطية:** {e}", - "backup_failed" - ) - - -async def _check_db_health(): - """التأكد من سلامة قاعدة البيانات.""" - try: - result = db.verify_or_recover_storage() - if not result.get("ok"): - msg = result.get("message", "خطأ غير معروف في قاعدة البيانات") - await notify_admins_custom( - f"❗ **مشكلة بقاعدة البيانات:** {msg}", - "db_issue" - ) - except Exception as e: - await notify_admins_custom( - f"❗ **استثناء أثناء فحص قاعدة البيانات:** {e}", - "db_issue" - ) - - -async def notification_checks_loop(): - """الحلقة الخلفية لفحص التنبيهات كل 5 دقائق.""" - CHECKS = [ - ("session_stopped", _check_sessions_health), - ("ai_key_error", _check_ai_key_errors), - ("source_stopped", _check_sources_stopped), - ("channel_idle_hour", _check_channels_idle), - ("backup_failed", _check_backup_fresh), - ("db_issue", _check_db_health), - ] - await asyncio.sleep(60) # انتظار دقيقة بعد بدء البوت - while True: - try: - settings = db.get_notification_settings() - for key, check_fn in CHECKS: - if settings.get(key, True): - try: - await check_fn() - except Exception as e: - logger.warning(f"خطأ في فحص {key}: {e}") - except Exception as e: - logger.warning(f"خطأ في حلقة التنبيهات: {e}") - await asyncio.sleep(NOTIFICATION_CHECK_INTERVAL) - - -BOT_VERIFY_INTERVAL = 3600 # إعادة التحقق كل ساعة - - -async def _auto_sync_channel_info(ch_id, ch_name=""): - """مزامنة معلومات القناة مع Telegram.""" - try: - chat = await bot_client.get_chat(int(ch_id)) - title = getattr(chat, "title", "") or "" - username = getattr(chat, "username", "") or "" - link = getattr(chat, "invite_link", "") or "" - if title: - old = db.get_channel(ch_id) or {} - updates = {} - if old.get("name") != title: - updates["name"] = title - if old.get("username") != username: - updates["username"] = username - if old.get("link") != link: - updates["link"] = link - if updates: - for k, v in updates.items(): - db.update_channel(ch_id, k, v) - logger.info(f"🔄 تم تحديث معلومات القناة {ch_id}: {updates}") - except Exception as e: - logger.warning(f"⚠️ فشل مزامنة القناة {ch_id}: {e}") - - -async def _auto_detect_bot_changes(b, old_ver, new_ver, ch_id, ch_name): - """كشف التغييرات في حالة البوت وإبلاغ المشرفين.""" - if not old_ver or not new_ver: - return - old_ok = old_ver.get("verified", False) and old_ver.get("can_post", False) - new_ok = new_ver.get("verified", False) and new_ver.get("can_post", False) - if old_ok and not new_ok: - msg = (f"⚠️ **تغيير في صلاحية البوت**\n\n" - f"🤖 {b.get('name', '')}\n" - f"📢 {ch_name}\n" - f"الحالة السابقة: ✅ موثوق\n" - f"الحالة الجديدة: ❌ غير موثوق\n" - f"السبب: {new_ver.get('status', 'غير معروف')}") - await notify_admins(msg) - elif not old_ok and new_ok: - msg = (f"✅ **تمت استعادة صلاحية البوت**\n\n" - f"🤖 {b.get('name', '')}\n" - f"📢 {ch_name}\n" - f"أصبح البوت قادراً على النشر مرة أخرى.") - await notify_admins(msg) - - -async def bot_channel_verify_loop(): - """حلقة دورية: التحقق من البوتات في القنوات + المزامنة التلقائية مع Telegram.""" - await asyncio.sleep(120) # انتظار دقيقتين بعد بدء البوت - while True: - try: - bots = db.get_all_publishing_bots() - for b in bots: - bid = b.get("id", "") - token = db.bot_manager.get_token(bid) if hasattr(db, "bot_manager") else b.get("token", "") - bname = b.get("name", bid) - if not token: - continue - # التحقق من صحة التوكن نفسه (هل البوت ما زال فعالاً) - try: - token_ver = await verifier.validate_token(token) - if not token_ver.get("valid"): - msg = f"⚠️ **بوت غير صالح**\n\n🤖 {bname}\nالتوكن لم يعد صالحاً: {token_ver.get('error', 'غير معروف')}" - await notify_admins(msg) - continue - except Exception: - pass - # استخدام BotChannelMapper للحصول على القنوات - if hasattr(db, "mapper"): - channel_ids = db.mapper.get_channels_for_bot(bid) - else: - deps = db.get_dependencies_for("publishing_bot", bid) - channel_ids = [d.get("id", "") for d in deps] - for ch_id in channel_ids: - if not ch_id: - continue - try: - # مزامنة معلومات القناة أولاً - ch = db.get_channel(ch_id) - await _auto_sync_channel_info(ch_id, entity_name(ch) if ch else ch_id) - # التحقق من وجود البوت في القناة - old_ver = db.get_bot_channel_verification(bid, ch_id) if hasattr(db, "get_bot_channel_verification") else {} - ver = await verifier.check_bot_in_channel(bid, ch_id, force=True) - # كشف التغييرات - if old_ver: - await _auto_detect_bot_changes(b, old_ver, ver, ch_id, entity_name(ch) if ch else ch_id) - except Exception: - pass - await asyncio.sleep(2) # تجنب ضغط API - except Exception as e: - logger.warning(f"خطأ في حلقة فحص البوتات: {e}") - await asyncio.sleep(BOT_VERIFY_INTERVAL) - - -# ============================================================ -# UI: تعيين الموارد للقناة (بوتات، جلسات، AI، مواقع) -# ============================================================ - -_RESOURCE_MAP = { - "bots": {"label": "🤖 بوتات النشر", "getter": "get_all_publishing_bots", "cfg_key": "assigned_bots"}, - "sessions": {"label": "📱 الجلسات", "getter": "get_all_sessions", "cfg_key": "assigned_sessions"}, - "ai": {"label": "🧠 مفاتيح AI", "getter": "get_all_ai_keys", "cfg_key": "assigned_ai"}, - "websites": {"label": "🌐 مواقع الويب", "getter": "get_all_websites", "cfg_key": "websites"}, -} - - -def _get_resource_items(rtype): - getter_name = _RESOURCE_MAP.get(rtype, {}).get("getter") - if not getter_name: - return [] - getter = getattr(db, getter_name, None) - if not getter: - return [] - try: - return getter() - except Exception: - return [] - - -async def assign_resource_menu(client, callback): - """قائمة تعيين مورد معين لقناة.""" - parts = callback.data.split("_", 2) - rtype = parts[1] - ch_id = parts[2] - info = _RESOURCE_MAP.get(rtype) - if not info: - await callback.answer("غير معروف.") - return - if rtype == "bots" and hasattr(db, "mapper"): - assigned = set(db.mapper.get_bots_for_channel(ch_id)) - else: - cfg = db.get_channel_config(ch_id) - assigned = set(str(x) for x in cfg.get(info["cfg_key"], [])) - items = _get_resource_items(rtype) - ch_name = entity_name(db.get_channel(ch_id)) - - if rtype == "bots": - # عرض خاص للبوتات: اسم + يوزر + إحصائيات + تشغيل/إيقاف - lines = [f"🤖 **بوتات النشر** — {ch_name}\n", "✅ معيّن / 🔲 غير معيّن: اضغط البوت لتعيينه لهذه القناة أو إلغاء تعيينه. 🟢/🔴 يوضح حالة البوت.\n"] - if not items: - lines.append("لا توجد بوتات متاحة.") - btns = [] - for item in items: - item_id = str(item.get("id", "")) - name = item.get("name", item_id) - username = item.get("username", "?") - enabled = item.get("enabled", True) - stats = item.get("stats", {}) or {} - pub = int(item.get("publish_count", 0)) - assigned_mark = "✅" if item_id in assigned else "🔲" - enabled_mark = "🟢" if enabled else "🔴" - lines.append( - f"{assigned_mark} {enabled_mark} {name} (@{username})\n" - f" 📊 {pub} منشور | نصوص:{stats.get('text',0)} صور:{stats.get('photo',0)} فيديو:{stats.get('video',0)} البوم:{stats.get('album',0)}" - ) - btns.append(InlineKeyboardButton(f"{'✅' if item_id in assigned else '🔲'} {name} {'🟢' if enabled else '🔴'}", callback_data=f"toggle_assign_{rtype}_{ch_id}_{item_id}")) - rows = grid_buttons(btns, 2) if btns else [] - rows.append(nav_row(f"genset_{ch_id}")) - await safe_edit(callback, "\n".join(lines), InlineKeyboardMarkup(rows)) - return - - lines = [f"{info['label']} — {ch_name}\n", "✅ معيّن / 🔲 غير معيّن: اضغط المورد لتعيينه لهذه القناة أو إلغاء تعيينه.\n"] - if not items: - lines.append("لا توجد عناصر متاحة.") - btns = [] - for item in items: - item_id = str(item.get("id", "")) - name = item.get("name", item_id) - on = "✅" if item_id in assigned else "🔲" - lines.append(f"{on} {name}") - btns.append(InlineKeyboardButton(f"{on} {name}", callback_data=f"toggle_assign_{rtype}_{ch_id}_{item_id}")) - rows = grid_buttons(btns, 2) if btns else [] - rows.append(nav_row(f"genset_{ch_id}")) - await safe_edit(callback, "\n".join(lines), InlineKeyboardMarkup(rows)) - - -async def toggle_assign_resource(client, callback): - """تبديل تعيين مورد لقناة.""" - # callback.data = "toggle_assign_bots_ch_id_bot_id" - parts = callback.data.split("_", 4) - # ['toggle', 'assign', 'bots', 'ch_id', 'bot_id'] - if len(parts) < 5: - await callback.answer("خطأ في البيانات.") - return - rtype = parts[2] - ch_id = parts[3] - item_id = parts[4] - info = _RESOURCE_MAP.get(rtype) - if not info: - await callback.answer("غير معروف.") - return - if rtype == "bots" and hasattr(db, "mapper"): - # استخدام BotChannelMapper للربط - was_assigned = db.mapper.is_assigned(item_id, ch_id) - if was_assigned: - db.mapper.unassign(item_id, ch_id) - else: - db.mapper.assign(item_id, ch_id) - # التحقق الفوري عند التعيين - token = db.bot_manager.get_token(item_id) if hasattr(db, "bot_manager") else "" - if token: - try: - ver = await verifier.check_bot_in_channel(item_id, ch_id, force=True) - except Exception: - pass - else: - # للأنواع الأخرى (sessions, ai, websites) - الطريقة القديمة - cfg = db.get_channel_config(ch_id) - key = info["cfg_key"] - current = list(cfg.get(key, [])) - str_current = [str(x) for x in current] - if str(item_id) in str_current: - current = [x for x in current if str(x) != str(item_id)] - else: - current.append(item_id) - db.update_channel_config(ch_id, **{key: current}) - await callback.answer("تم التحديث.") - callback.data = f"assign_{rtype}_{ch_id}" - await assign_resource_menu(client, callback) - - -# ============================================================ -# UI: قائمة التنبيهات -# ============================================================ - -async def notifications_menu(client, callback): - """عرض قائمة التنبيهات مع إمكانية تشغيل/إيقاف كل فحص.""" - ch_id = None - if "|" in callback.data: - ch_id = callback.data.split("|", 1)[1] - settings = db.get_notification_settings() - lines = ["🔔 **إعدادات التنبيهات**\n", "اضغط نوع التنبيه لتشغيله أو إيقافه. التنبيهات تصل للمشرفين عند حدوث أحداث:\n"] - buttons = [] - for key, label in db.NOTIFICATION_TYPES.items(): - on = settings.get(key, True) - lines.append(f"{'✅' if on else '❌'} {label}") - buttons.append([InlineKeyboardButton( - f"{'✅' if on else '❌'} {label}", - callback_data=f"toggle_notif_{key}|{ch_id}" if ch_id else f"toggle_notif_{key}" - )]) - buttons.append(nav_row(f"sysset_{ch_id}" if ch_id else "system_menu")) - await safe_edit(callback, "\n".join(lines), InlineKeyboardMarkup(buttons)) - - -async def _cache_cleanup_loop(): - """تنظيف دوري للكاش منتهي الصلاحية.""" - while True: - await asyncio.sleep(600) # كل 10 دقائق - try: - if hasattr(db, "cache_clear_expired"): - db.cache_clear_expired() - except Exception: - pass - - -async def run_clients(): - global bot_client, user_client - - os.makedirs("/data/sessions", exist_ok=True) - - bot_client = Client( - "/data/sessions/bot_session", - api_id=config.API_ID, - api_hash=config.API_HASH, - bot_token=config.BOT_TOKEN - ) - - user_client = Client( - "/data/sessions/user_session", - api_id=config.API_ID, - api_hash=config.API_HASH, - session_string=config.SESSION_STRING - ) - - register_bot_handlers(bot_client) - register_user_handlers(user_client) - register_middle_handlers(bot_client) - - await bot_client.start() - await user_client.start() - - try: - if hasattr(db, "cleanup_old_runtime_data"): - db.cleanup_old_runtime_data(days=3) - except Exception as e: - logger.warning(f"تعذر تنظيف بيانات التشغيل القديمة: {e}") - - await warm_middle_peer() - try: - await warm_all_peers_after_import() - except Exception as e: - logger.warning(f"فشل تهيئة القنوات/المصادر عند التشغيل: {e}") - - logger.info("✅ البوتان شغّالان") - asyncio.create_task(poll_public_sources_loop()) - asyncio.create_task(heartbeat_watchdog_loop()) - asyncio.create_task(notification_checks_loop()) - asyncio.create_task(bot_channel_verify_loop()) - asyncio.create_task(_cache_cleanup_loop()) - try: - blogger_pub = BloggerPublisher() - asyncio.create_task(blogger_pub.start()) - logger.info("✅ تم تشغيل Blogger Publisher") - except Exception as e: - logger.warning(f"⚠️ فشل تشغيل Blogger Publisher: {e}") - logger.info("✅ تم تشغيل نظام Polling للمصادر العامة") - logger.info("✅ تم تشغيل Heartbeat/Watchdog") - logger.info("✅ تم تشغيل نظام التنبيهات") - logger.info("✅ تم تشغيل فحص البوتات الدوري") - logger.info("✅ تم تشغيل تنظيف الكاش الدوري") - await asyncio.Event().wait() - -def run_bot(): - lock_file = "/tmp/telegram_bot_core_running.lock" - - if os.path.exists(lock_file): - # نتحقق إن البوت القديم فعلاً شغّال وليس ملف قفل متروك من crash سابق - stale = True - try: - with open(lock_file, "r") as f: - old_pid = int(f.read().strip()) - os.kill(old_pid, 0) # لا يرسل إشارة، فقط يتحقق من وجود العملية - stale = False - except (ValueError, FileNotFoundError): - stale = True - except ProcessLookupError: - stale = True - except PermissionError: - stale = False # العملية موجودة لكن لا صلاحية للفحص - - if not stale: - logger.warning("⚠️ البوت يعمل مسبقاً، تم منع تشغيل نسخة ثانية.") - return - - # ملف قفل متروك — نمسحه ونكمل - try: - os.remove(lock_file) - logger.info("🧹 تم مسح ملف قفل متروك من تشغيل سابق.") - except Exception: - pass - - with open(lock_file, "w") as f: - f.write(str(os.getpid())) - - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - try: - loop.run_until_complete(run_clients()) - except KeyboardInterrupt: - pass - except Exception as e: - logger.exception(f"توقف البوت بسبب خطأ: {e}") - finally: - try: - if bot_client: - loop.run_until_complete(bot_client.stop()) - except Exception: - pass - - try: - if user_client: - loop.run_until_complete(user_client.stop()) - except Exception: - pass - - try: - if os.path.exists(lock_file): - os.remove(lock_file) - except Exception: - pass - - loop.close() \ No newline at end of file