Spaces:
Running
Running
Download bot_core.py from Alialdlfy1/Teleautoadminbot: direct link, hf CLI and curl.
- Browser
- Download file 380 kB
-
https://huggingface.co/spaces/Alialdlfy1/Teleautoadminbot/resolve/main/bot_core.py
- Command line
-
hf download hf://spaces/Alialdlfy1/Teleautoadminbot/bot_core.py
-
curl -L -o bot_core.py https://huggingface.co/spaces/Alialdlfy1/Teleautoadminbot/resolve/main/bot_core.py
380 kB
| 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"<b>{escaped}</b>" | |
| return f"<blockquote>{escaped}</blockquote>" | |
| 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"<blockquote>{escaped_news}</blockquote>", 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"<blockquote>{escaped_tail}</blockquote>" + escaped_body[idx + len(escaped_tail):] | |
| if bold_enabled: | |
| escaped_body = f"<b>{escaped_body}</b>" | |
| 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"<b>{escaped}</b>" | |
| 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"(?<!\w)@[A-Za-z0-9_]{4,32}\b") | |
| CHANNEL_WEB_LINK_RE = re.compile(r"(?:https?://|www\.)[^\s]+", re.IGNORECASE) | |
| def apply_channel_link_filters(text, channel_id): | |
| """يحذف الروابط حسب إعدادات هذه القناة فقط: روابط تيليجرام / يوزرات @ / روابط المواقع.""" | |
| if not text or channel_id is None: | |
| return text | |
| try: | |
| ch = db.get_channel(channel_id) | |
| except Exception: | |
| ch = None | |
| if not ch: | |
| return text | |
| cleaned = str(text) | |
| if ch.get("link_remove_tg"): | |
| cleaned = CHANNEL_LINK_TG_RE.sub("", cleaned) | |
| if ch.get("link_remove_tg_user"): | |
| cleaned = CHANNEL_TG_USERNAME_RE.sub("", cleaned) | |
| if ch.get("link_remove_web"): | |
| cleaned = CHANNEL_WEB_LINK_RE.sub( | |
| lambda m: m.group(0) if CHANNEL_LINK_TG_RE.match(m.group(0)) else "", | |
| cleaned, | |
| ) | |
| return compact_text(cleaned) | |
| # ===== Phase 3 Part 1: advanced filters and cleaning ===== | |
| EMOJI_PATTERN = re.compile( | |
| "[" | |
| "\U0001F1E6-\U0001F1FF" | |
| "\U0001F300-\U0001F5FF" | |
| "\U0001F600-\U0001F64F" | |
| "\U0001F680-\U0001F6FF" | |
| "\U0001F700-\U0001F77F" | |
| "\U0001F780-\U0001F7FF" | |
| "\U0001F800-\U0001F8FF" | |
| "\U0001F900-\U0001F9FF" | |
| "\U0001FA00-\U0001FAFF" | |
| "\u2600-\u27BF" | |
| "]+", | |
| flags=re.UNICODE | |
| ) | |
| HIDDEN_CHARS_PATTERN = re.compile(r"[\u200e\u200f\u202a-\u202e\u2066-\u2069\u200b\u200c\u200d\ufeff]") | |
| def compact_text(text): | |
| text = str(text or "") | |
| text = HIDDEN_CHARS_PATTERN.sub("", text) | |
| text = "\n".join(line.strip() for line in text.splitlines() if line.strip()) | |
| text = re.sub(r"[ \t]{2,}", " ", text) | |
| return text.strip() | |
| def remove_term_variants(text, term): | |
| """يحذف الكلمة كما هي، وكهاشتاك، ومع تحويل الفراغات إلى _ أو -.""" | |
| if not text or not term: | |
| return text | |
| term = str(term).strip() | |
| if not term: | |
| return text | |
| base = term.lstrip("#@").strip() | |
| variants = {term, base} | |
| if base: | |
| variants.add("#" + base) | |
| variants.add("@" + base) | |
| variants.add("#" + base.replace(" ", "_")) | |
| variants.add("#" + base.replace(" ", "-")) | |
| variants.add(base.replace(" ", "_")) | |
| variants.add(base.replace(" ", "-")) | |
| cleaned = str(text) | |
| for variant in sorted(variants, key=len, reverse=True): | |
| if not variant: | |
| continue | |
| pattern = re.escape(variant) | |
| # حدود مرنة حتى لا تلتصق الكلمات بباقي النص | |
| cleaned = re.sub(r"(?<!\w)" + pattern + r"(?!\w)", "", cleaned, flags=re.IGNORECASE) | |
| cleaned = cleaned.replace(variant, "") | |
| return cleaned | |
| def _is_emoji_char(ch): | |
| """كشف واسع للإيموجي بدون الاعتماد فقط على Regex حتى تُحذف الرموز المركبة أيضاً.""" | |
| if not ch: | |
| return False | |
| code = ord(ch) | |
| # Unicode emoji blocks + رموز BMP التي تُعرض كإيموجي مع variation selector. | |
| if ( | |
| 0x1F1E6 <= code <= 0x1F1FF or # flags | |
| 0x1F300 <= code <= 0x1FAFF or # emoji/symbols/pictographs | |
| 0x2600 <= code <= 0x27BF or # misc symbols/dingbats | |
| 0x2300 <= code <= 0x23FF or # technical symbols (⌚ ⏰ ⏩ ...) | |
| 0x2190 <= code <= 0x21FF or # arrows | |
| 0x2B00 <= code <= 0x2BFF or # arrows/stars/shapes | |
| code in { | |
| 0x00A9, 0x00AE, 0x203C, 0x2049, 0x2122, 0x2139, | |
| 0x3030, 0x303D, 0x3297, 0x3299, | |
| } | |
| ): | |
| return True | |
| # أجزاء الإيموجي المركبة: ألوان/تعديل لون الجلد/variation/keycap/tag chars. | |
| if ( | |
| 0xFE00 <= code <= 0xFE0F or | |
| 0x1F3FB <= code <= 0x1F3FF or | |
| code == 0x20E3 or | |
| 0xE0020 <= code <= 0xE007F | |
| ): | |
| return True | |
| return False | |
| def remove_emoji_chars(text): | |
| if not text: | |
| return text | |
| cleaned = EMOJI_PATTERN.sub("", str(text)) | |
| cleaned = "".join(ch for ch in cleaned if not _is_emoji_char(ch)) | |
| return compact_text(cleaned) | |
| def programmatic_clean_text(text): | |
| """تنظيف برمجي عام بدون الاعتماد على أي سيرفر خارجي.""" | |
| if not text: | |
| return text | |
| cleaned = html.unescape(str(text)) | |
| cleaned = cleaned.replace(""", "").replace("‏", "").replace("‎", "").replace(" ", " ") | |
| cleaned = HIDDEN_CHARS_PATTERN.sub("", cleaned) | |
| return compact_text(cleaned) | |
| def apply_global_remove_terms(text): | |
| if not text: | |
| return text | |
| try: | |
| terms = db.get_global_remove_terms() if hasattr(db, "get_global_remove_terms") else [] | |
| except Exception: | |
| terms = [] | |
| cleaned = str(text) | |
| for term in terms: | |
| cleaned = remove_term_variants(cleaned, term) | |
| return compact_text(cleaned) | |
| def apply_channel_remove_terms(text, channel_id): | |
| """يطبّق قائمة الحذف الخاصة بقناة نشر معيّنة فقط (مستقلة عن باقي القنوات).""" | |
| if not text or channel_id is None: | |
| return text | |
| try: | |
| terms = db.get_channel_delete_terms(channel_id) if hasattr(db, "get_channel_delete_terms") else [] | |
| except Exception: | |
| terms = [] | |
| cleaned = str(text) | |
| for term in terms: | |
| cleaned = remove_term_variants(cleaned, term) | |
| return compact_text(cleaned) | |
| def prepare_dedup_text(text, source_id=None, channel_id=None): | |
| """يجهز النص للمقارنة بعد كلمات الحذف وقبل قالب النشر. | |
| الكلمات المحظورة تُفحص قبل استدعاء هذه الدالة، ولا تستخدم أي AI.""" | |
| cleaned = full_clean_text(text, source_id=source_id) | |
| cleaned = apply_global_remove_terms(cleaned) | |
| cleaned = apply_channel_remove_terms(cleaned, channel_id) | |
| return compact_text(cleaned) | |
| def full_clean_text(text, source_id=None): | |
| if not text: | |
| return text | |
| cleaned = str(text) | |
| # قراءة إعداد حذف الإيموجي مرة واحدة فقط لتجنب استعلامات متكررة | |
| should_remove_emoji = False | |
| if source_id is not None: | |
| try: | |
| if hasattr(db, "get_source_remove_emoji"): | |
| should_remove_emoji = bool(db.get_source_remove_emoji(source_id)) | |
| except Exception: | |
| pass | |
| if source_id is not None: | |
| cleaned = apply_source_remove_terms(cleaned, source_id) | |
| if should_remove_emoji: | |
| cleaned = remove_emoji_chars(cleaned) | |
| cleaned = programmatic_clean_text(cleaned) | |
| if should_remove_emoji: | |
| cleaned = remove_emoji_chars(cleaned) | |
| return compact_text(cleaned) | |
| def clean_text_for_source(text, source_id=None, channel_id=None): | |
| """تنظيف نهائي مرتبط بالمصدر وبالقناة المستهدفة قبل بناء المنشور.""" | |
| cleaned = clean_text(text, source_id=source_id, channel_id=channel_id) | |
| if cleaned is None: | |
| return None | |
| if source_id is not None: | |
| try: | |
| if hasattr(db, "get_source_remove_emoji") and db.get_source_remove_emoji(source_id): | |
| cleaned = remove_emoji_chars(cleaned) | |
| except Exception: | |
| pass | |
| return compact_text(cleaned) | |
| def published_content_type_for_message(message, is_album=False): | |
| if is_album: | |
| return "album" | |
| if getattr(message, "video", None): | |
| return "video" | |
| if getattr(message, "photo", None): | |
| return "photo" | |
| if getattr(message, "voice", None): | |
| return "voice" | |
| if getattr(message, "audio", None): | |
| return "audio" | |
| if getattr(message, "document", None): | |
| return "document" | |
| if not getattr(message, "media", None): | |
| return "text" | |
| return "document" | |
| def content_type_for_message(message, is_album=False): | |
| if is_album or getattr(message, "media_group_id", None): | |
| return "album" | |
| if not getattr(message, "media", None): | |
| return "text" | |
| if getattr(message, "photo", None): | |
| return "photo" | |
| if getattr(message, "video", None): | |
| return "video" | |
| if getattr(message, "voice", None): | |
| return "voice" | |
| if getattr(message, "audio", None): | |
| return "audio" | |
| if getattr(message, "document", None): | |
| return "document" | |
| # أي ميديا غير مصنفة نعاملها كملف حتى لا تُنشر إلا إذا فعلت الملفات بالمصدر | |
| return "document" | |
| def is_content_allowed_for_source(source_id, content_type): | |
| try: | |
| types = db.get_source_content_types(source_id) if hasattr(db, "get_source_content_types") else {} | |
| except Exception: | |
| types = {} | |
| if not types: | |
| return True | |
| return bool(types.get(content_type, True)) | |
| def is_source_paused(source_id): | |
| try: | |
| return bool(db.is_source_paused(source_id)) if hasattr(db, "is_source_paused") else False | |
| except Exception: | |
| return False | |
| NEWS_STOPWORDS = { | |
| "عاجل", "عاجــــــــل", "عــــاجــــل", "حصري", "هام", "تنويه", "الان", "الآن", | |
| "مصدر", "مصادر", "قال", "قالت", "افاد", "أفاد", "اعلن", "أعلن", "اعلنت", "أعلنت", | |
| "بيان", "خبر", "متابعة", "مراسل", "وكالة", "قناة", "شبكة", "رسميا", "رسمياً", | |
| "واشنطن", "ايران", "إيران" # تُعاد إيران ككلمة معيارية عبر المرادفات أدناه؛ المدن تبقى لهوية الحدث | |
| } | |
| NEWS_SYNONYMS = { | |
| "الأميركية": "امريكا", "الأمريكية": "امريكا", "امريكية": "امريكا", "أميركية": "امريكا", | |
| "الامريكية": "امريكا", "امريكا": "امريكا", "أمريكا": "امريكا", "اميركا": "امريكا", "أميركا": "امريكا", | |
| "الولايات": "امريكا", "المتحدة": "امريكا", "واشنطن": "امريكا", | |
| "إيران": "ايران", "ايراني": "ايران", "إيراني": "ايران", "الايرانية": "ايران", "الإيرانية": "ايران", | |
| "مرتبطة": "متعلقة", "متعلق": "متعلقة", "متعلقة": "متعلقة", "تتعلق": "متعلقة", | |
| "عقوبة": "عقوبات", "العقوبات": "عقوبات", "عقوبات": "عقوبات", | |
| "الخزانة": "خزانة", "وزارة": "وزارة", | |
| } | |
| def normalize_for_similarity(text): | |
| """بصمة إخبارية أقوى لمنع تكرار نفس الخبر بصياغات مختلفة.""" | |
| cleaned = full_clean_text(text or "") | |
| cleaned = remove_emoji_chars(cleaned) | |
| cleaned = re.sub(r"[^\w\s\u0600-\u06FF]", " ", cleaned, flags=re.UNICODE) | |
| cleaned = re.sub(r"\s+", " ", cleaned).strip().lower() | |
| words = [] | |
| for raw in cleaned.split(): | |
| w = raw.strip("_-ـ") | |
| if not w or len(w) < 2: | |
| continue | |
| # إزالة مدود عاجل وأشكال الزخرفة | |
| w = re.sub(r"ـ+", "", w) | |
| w = NEWS_SYNONYMS.get(w, w) | |
| if w in NEWS_STOPWORDS: | |
| continue | |
| if w in {"ال", "في", "من", "على", "الى", "إلى", "عن", "مع", "هذا", "هذه", "ذلك", "تلك"}: | |
| continue | |
| words.append(w) | |
| # نزيل التكرار مع الحفاظ على الترتيب | |
| seen = set() | |
| unique = [] | |
| for w in words: | |
| if w not in seen: | |
| seen.add(w) | |
| unique.append(w) | |
| return " ".join(unique) | |
| def make_text_fp(text): | |
| normalized = normalize_for_similarity(text) | |
| if not normalized: | |
| return "", normalized | |
| return hashlib.sha1(normalized.encode("utf-8", errors="ignore")).hexdigest(), normalized | |
| def make_similarity_fp(text): | |
| fp, normalized = make_text_fp(text) | |
| if len(normalized) < 20: | |
| return "", normalized | |
| return fp, normalized | |
| def make_event_fp(text): | |
| """Stable local event fingerprint: normalized news tokens, not wording.""" | |
| normalized = normalize_for_similarity(text) | |
| tokens = [token for token in normalized.split() if len(token) >= 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"(?<!\w)\d+(?:[.,]\d+)?", str(text or ""))) | |
| def _event_details_differ(left, right): | |
| left_numbers = _event_numbers(left) | |
| right_numbers = _event_numbers(right) | |
| return bool(left_numbers and right_numbers and left_numbers != right_numbers) | |
| def _ai_decide_gray_zone(normalized, sample): | |
| """حسم المنطقة الرمادية عبر AI عند الحاجة وفقط إذا كانت مفاتيح AI مفعلة. | |
| يرجع True إذا قرر AI أنها نفس الحدث. يُستدعى فقط في المنطقة الرمادية | |
| (similarity بين 0.60 و0.75) ولا يستهلك رموزاً لأي منشور مرفوض مسبقاً.""" | |
| try: | |
| if not (hasattr(db, "get_all_ai_keys") and db.get_all_ai_keys()): | |
| return False | |
| setting = False | |
| if hasattr(db, "get_setting"): | |
| try: | |
| setting = bool(db.get_setting("ai_gray_zone_dedup", False)) | |
| except Exception: | |
| setting = False | |
| if not setting: | |
| return False | |
| prompt = ( | |
| "هل هذان النصان الإخباريان يتحدثان عن نفس الحدث المحدث؟ أجب بـ yes أو no فقط.\n" | |
| f"النص الأول: {normalized[:300]}\nالنص الثاني: {sample[:300]}" | |
| ) | |
| import urllib.request as _ur | |
| import urllib.parse as _up | |
| import json as _j2 | |
| ai_calls = globals().get("_p27_ai_call_count", 0) | |
| globals()["_p27_ai_call_count"] = ai_calls + 1 | |
| for k in db.get_all_ai_keys(): | |
| try: | |
| key = k.get("key") or k.get("api_key") | |
| provider = (k.get("provider") or "").lower() | |
| if not key: | |
| continue | |
| if provider.startswith("gemini") and hasattr(db, "get_ai_key"): | |
| try: | |
| url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=" + _up.quote(str(key), safe="") | |
| body = {"contents": [{"parts": [{"text": prompt}]}]} | |
| req = _ur.Request(url, data=_j2.dumps(body).encode(), headers={"Content-Type": "application/json"}) | |
| with _ur.urlopen(req, timeout=20) as r: | |
| resp = _j2.loads(r.read().decode()) | |
| text = resp.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text", "").lower() | |
| return "yes" in text | |
| except Exception: | |
| continue | |
| except Exception: | |
| continue | |
| return False | |
| except Exception: | |
| return False | |
| def is_hybrid_news_duplicate(text, source_id=None, message_id=None, threshold=0.75, scope_id=None): | |
| """Dedup مشترك حسب القسم (scope_id) — المقارنة فقط مع الأخبار المقبولة والمنشورة | |
| فعلياً في نفس القسم خلال آخر 48 ساعة. | |
| تسلسل الطبقات: Exact → URL → Event → Similarity → Event Overlap → AI للمنطقة الرمادية.""" | |
| text_fp, normalized = make_text_fp(text) | |
| event_fp = make_event_fp(text) | |
| url_fp = make_url_fp(text) | |
| if not text_fp: | |
| return False, "none", 0.0 | |
| try: | |
| if source_id is not None and message_id is not None and hasattr(db, "has_recent_source_event"): | |
| if db.has_recent_source_event(source_id, message_id): | |
| return True, "source_event", 1.0 | |
| recent = db.get_recent_fingerprints(scope_id=scope_id) if hasattr(db, "get_recent_fingerprints") else [] | |
| except Exception: | |
| recent = [] | |
| best = 0.0 | |
| details_differ = False | |
| for item in recent[-300:]: | |
| if not isinstance(item, dict): | |
| continue | |
| # 1. Exact | |
| if item.get("fp") == text_fp: | |
| return True, "exact", 1.0 | |
| # 2. URL | |
| if url_fp and item.get("url_fp") == url_fp: | |
| return True, "url", 1.0 | |
| # 3. Event | |
| if event_fp and item.get("event_fp") == event_fp: | |
| return True, "event", 1.0 | |
| sample = item.get("sample", "") | |
| if not sample or len(normalized) < 20: | |
| continue | |
| ratio = difflib.SequenceMatcher(None, normalized, sample).ratio() | |
| best = max(best, ratio) | |
| # اختلاف التفاصيل/الأرقام المهمة يمنع الحكم Duplicate | |
| if _event_details_differ(normalized, sample): | |
| details_differ = True | |
| continue | |
| # مطابقة حتمية لهوية الحدث للصياغات المختصرة، قبل أي AI. | |
| event_identity_score = _event_identity_score(normalized, sample) | |
| if event_identity_score >= 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() |