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 from core.app import App as P29App from core.control.section_control import SectionControl 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 _P29_APP = None _P29_SECTION_CONTROL = None def _get_p29_runtime(): global _P29_APP, _P29_SECTION_CONTROL if _P29_APP is None: _P29_APP = P29App(legacy_db=db) _P29_SECTION_CONTROL = SectionControl(_P29_APP.runtime, admin_check=lambda uid: is_admin(uid) if uid is not None else False) return _P29_APP.runtime, _P29_SECTION_CONTROL 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_core_tokens(text): """يبني مجموعة كلمات حدث حتمية لمطابقة الصياغات العربية المختصرة. لا يعتمد على AI ولا يساوي بين خبرين لمجرد اشتراكهما بكلمة عامة.""" stopwords = { "من", "في", "عن", "على", "الى", "إلى", "مع", "هذا", "هذه", "ذلك", "تلك", "الذي", "التي", "تم", "قد", "كما", "بعد", "قبل", "اليوم", "غدا", "غدًا", "اعلن", "أعلن", "تعلن", "يعلن", "أعلنت", "قال", "تقول", "ذكر", "حول", "ضمن", "بشأن", "بخصوص", "و", "أو", "او", "ثم", "أن", "ان", "إن", "وذلك", } tokens = set() for token in normalize_for_similarity(text).split(): token = token.strip(".,،؛:!?()[]{}\"'") if len(token) < 3 or token in stopwords: continue variants = {token} if token.startswith("ال") and len(token) > 4: variants.add(token[2:]) # توحيد لواصق عربية شائعة داخل هوية الحدث: للعراق/العراق → عراق. if token.startswith("لل") and len(token) > 4: variants.add(token[2:]) if token.startswith("و") and len(token) > 4: variants.add(token[1:]) if token.endswith("ية") and len(token) > 5: variants.add(token[:-2]) tokens.update(variants) return tokens def _event_identity_score(left, right): """درجة تغطية كلمات الحدث المشتركة؛ الصفر يعني عدم كفاية الهوية.""" left_core = _event_core_tokens(left) right_core = _event_core_tokens(right) if len(left_core) < 3 or len(right_core) < 3: return 0.0 shared = left_core & right_core if len(shared) < 3: return 0.0 return len(shared) / max(1, min(len(left_core), len(right_core))) def _event_numbers(text): return set(re.findall(r"(?= 0.75: return True, "event_identity", event_identity_score if ratio >= 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("📰 الأخبار", callback_data="section_menu|news"), InlineKeyboardButton("⚽ الرياضة", callback_data="section_menu|sports"), ], [ InlineKeyboardButton("🌐 Blogger Publisher", callback_data="section_menu|blogger") ] ]) async def section_menu(client, callback): """Unified Phase-15 controls; legacy Blogger callbacks remain untouched.""" try: section = str(callback.data).split("|", 1)[1] except Exception: await callback.answer("قسم غير صالح.", show_alert=True) return if section == "blogger": from modules.blogger.ui import blogger_main_menu await blogger_main_menu(client, callback) return runtime, control = _get_p29_runtime() state = runtime.get_section_state(section) buttons = [ [ InlineKeyboardButton("⚙️ الإعدادات", callback_data=f"{section}:settings"), InlineKeyboardButton("📡 المصادر", callback_data=f"{section}:sources"), ], [ InlineKeyboardButton("🚫 الكلمات المحظورة", callback_data=f"{section}:blocked"), InlineKeyboardButton("♻️ منع التكرار", callback_data=f"{section}:duplicates"), ], [ InlineKeyboardButton("🤖 الذكاء الاصطناعي", callback_data=f"{section}:ai"), InlineKeyboardButton("📋 الطابور", callback_data=f"{section}:queue"), ], [ InlineKeyboardButton("📊 الحالة", callback_data=f"{section}:status"), InlineKeyboardButton("🛠️ الإصلاح التلقائي", callback_data=f"{section}:repair"), ], [ InlineKeyboardButton("▶️ تشغيل", callback_data=f"{section}:enable"), InlineKeyboardButton("⛔ إيقاف", callback_data=f"{section}:disable"), ], [InlineKeyboardButton("⬅️ رجوع", callback_data="main_menu")], ] text = ( f"**{section.upper()} — Phase 15**\n\n" f"الحالة: {'🟢 تعمل' if state['enabled'] else '🔴 متوقفة'}\n" f"AI: {'🟢' if state['ai_enabled'] else '🔴'}\n" f"منع التكرار: {'🟢' if state['duplicate_protection'] else '🔴'}\n" f"Auto-Repair: {'🟢' if state['auto_repair_enabled'] else '🔴'}" ) await safe_edit(callback, text, InlineKeyboardMarkup(buttons)) async def section_control_callback(client, callback): runtime, control = _get_p29_runtime() result = control.handle(str(callback.data), getattr(callback.from_user, "id", None)) if not result.get("ok"): await callback.answer(str(result.get("reason", "فشل")), show_alert=True) return await callback.answer("تم.") section = str(callback.data).split(":", 1)[0] callback.data = f"section_menu|{section}" await section_menu(client, callback) 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, channel_ids=None): """يفحص الكلمات المحظورة على النص الخام قبل أي تنظيف أو معالجة. يطبق القائمة العامة، ثم قوائم القنوات المستهدفة فقط حتى لا يؤثر إعداد قسم على قسم آخر. يرجع الكلمة المحظورة أو None، ولا يستدعي أي AI.""" 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 if not channel_ids or not hasattr(db, "get_channel_blocked_words"): return None try: seen = set() for channel_id in channel_ids: if channel_id in seen: continue seen.add(channel_id) for word in db.get_channel_blocked_words(channel_id) or []: 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) # تحديد القنوات المستهدفة فقط لتطبيق قوائم الحظر الخاصة بها؛ لا توجد معالجة للنص هنا. target_ids = get_targets_for_source(source_id) raw_blocked_word = check_global_blocked_words_raw(raw_text, channel_ids=target_ids) 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 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 if not target_ids: logger.info("🟡 لا توجد قنوات مستهدفة لهذا المصدر، تم اعتبار المنشور معالجاً حتى لا يتكرر.") return True raw_text = full_clean_text(raw_text, source_id=source_id) logger.info(f"📨 [{origin}] رسالة من {source_id} | type={content_type} | message_id={message_id} | نص: {str(raw_text)[:80]}...") # كلمات الحذف الخاصة بالمصدر/القناة تُطبق قبل Dedup، بينما قالب النشر والروابط # الخاصة بالقناة لا تدخل في هوية الحدث. dedup_text_by_target = { target: prepare_dedup_text(raw_text, source_id=source_id, channel_id=target) for target in target_ids } # حجز ذري لنفس رسالة المصدر بعد المحظورات وكلمات الحذف، وقبل Dedup والنشر. # المساران updates وpolling يتنافسان هنا؛ الخاسر يخرج ولا ينشر. 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 as exc: logger.exception(f"فشل claim للحدث؛ تم إيقاف النشر الآمن: source={source_id} message_id={message_id}: {exc}") return False if not claimed_now: logger.info(f"🔒 [{origin}] الحدث محجوز مسبقاً من مسار آخر: source={source_id} message_id={message_id} — تم إيقاف المسار الخاسر") if hasattr(db, "record_source_event"): db.record_source_event(source_id, "duplicate", "source event claim lost", message_id) return True 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 # ملاحظة: فحص الكلمة المحظورة الخاص بكل قناة ما زال يتم على حدة داخل حلقة # الإرسال أدناه (عبر 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): candidate_text = dedup_text_by_target.get(target, "") if not candidate_text: continue scope_id = section_scope_for_channel(target) duplicated, duplicate_reason, duplicate_score = is_hybrid_news_duplicate( candidate_text, source_id=source_id, message_id=message_id, threshold=0.75, scope_id=scope_id ) if duplicated: event_fp = make_event_fp(candidate_text) logger.info(f"🔁 Duplicate detected: scope={section_label_for_channel(target)} reason={duplicate_reason} score={duplicate_score:.3f} source={source_id} message_id={message_id}") 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(candidate_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}") if hasattr(db, "release_source_event_claim") and message_id is not None: db.release_source_event_claim(source_id, message_id) 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( dedup_text_by_target.get(target) or raw_text, source_id=source_id, message_id=message_id, scope_id=section_scope_for_channel(target), section_label=section_label_for_channel(target), ) if not any_success and not any_logical_rejection and hasattr(db, "release_source_event_claim") and message_id is not None: db.release_source_event_claim(source_id, message_id) 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( dedup_text_by_target.get(target) or raw_text, source_id=source_id, message_id=message_id, scope_id=section_scope_for_channel(target), section_label=section_label_for_channel(target), ) if not any_success and not any_logical_rejection and hasattr(db, "release_source_event_claim") and message_id is not None: db.release_source_event_claim(source_id, message_id) 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 as exc: logger.exception(f"فشل claim للألبوم؛ تم إيقاف النشر الآمن: source={source_id} message_id={album_event_id}: {exc}") return False 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 # كلمات الحذف الخاصة بالمصدر/القناة تُطبق قبل Dedup، بينما قالب النشر لا يدخل في البصمة. dedup_text_by_target = { target: prepare_dedup_text(raw_text, source_id=source_id, channel_id=target) for target in target_ids } 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): candidate_text = dedup_text_by_target.get(target, "") if not candidate_text: continue scope_id = section_scope_for_channel(target) duplicated, dup_reason, dup_score = is_hybrid_news_duplicate( candidate_text, source_id=source_id, message_id=album_event_id, threshold=0.75, scope_id=scope_id ) if duplicated: event_fp = make_event_fp(candidate_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(candidate_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}") if hasattr(db, "release_source_event_claim") and album_event_id is not None: db.release_source_event_claim(source_id, album_event_id) 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( dedup_text_by_target.get(target) or 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 not any_success and hasattr(db, "release_source_event_claim") and album_event_id is not None: db.release_source_event_claim(source_id, album_event_id) 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"⚠️ تم تمرير الألبوم للوسيطة لكن فشل نشره للقنوات. سيبقى قابلاً لإعادة المحاولة: {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.startswith("section_menu|"): await section_menu(client, callback) return if data.startswith(("news:", "sports:", "blogger:")): await section_control_callback(client, callback) return 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()