Spaces:
Runtime error
Runtime error
| #!/usr/bin/env python3 | |
| # -*- coding: utf-8 -*- | |
| import os | |
| import sys | |
| import time | |
| import signal | |
| import logging | |
| import threading | |
| import subprocess | |
| import glob | |
| from flask import Flask, jsonify | |
| # إعدادات عامة | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s - %(levelname)s - %(message)s", | |
| ) | |
| HERE = os.path.dirname(os.path.abspath(__file__)) | |
| PYTHON = sys.executable # نفس مفسّر بايثون الحالي | |
| LISTENER_PATH = os.path.join(HERE, "telegram_listener.py") | |
| CHECK_INTERVAL = 5 # ثواني بين فحوصات الحارس | |
| app = Flask(__name__) | |
| _listener_proc = None | |
| _stop_flag = False | |
| _python_processes = {} # لتخزين عمليات بايثون النشطة | |
| def _spawn_listener(): | |
| """تشغيل telegram_listener.py كعملية خلفية.""" | |
| if not os.path.isfile(LISTENER_PATH): | |
| logging.error("لم يتم العثور على telegram_listener.py في: %s", LISTENER_PATH) | |
| return None | |
| try: | |
| proc = subprocess.Popen( | |
| [PYTHON, LISTENER_PATH], | |
| cwd=HERE, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.STDOUT, | |
| text=True, | |
| bufsize=1, | |
| ) | |
| logging.info("تم تشغيل telegram_listener.py (pid=%s)", proc.pid) | |
| return proc | |
| except Exception as e: | |
| logging.exception("تعذّر تشغيل telegram_listener.py: %s", e) | |
| return None | |
| def _watchdog(): | |
| """حارس لإبقاء المستمع شغّالاً دائماً؛ يعيد تشغيله عند التوقف.""" | |
| global _listener_proc, _stop_flag | |
| while not _stop_flag: | |
| if _listener_proc is None or _listener_proc.poll() is not None: | |
| # إذا كان غير موجود أو متوقف — شغّله | |
| _listener_proc = _spawn_listener() | |
| time.sleep(CHECK_INTERVAL) | |
| def _start_watchdog_once(): | |
| """تشغيل خيط الحارس مرّة واحدة.""" | |
| if not getattr(_start_watchdog_once, "started", False): | |
| t = threading.Thread(target=_watchdog, daemon=True) | |
| t.start() | |
| _start_watchdog_once.started = True | |
| logging.info("تم تشغيل خيط الحارس.") | |
| def run_all_python_files(): | |
| """تشغيل جميع ملفات بايثون في المجلد الحالي""" | |
| global _python_processes | |
| # الحصول على جميع ملفات بايثون في المجلد الحالي | |
| python_files = glob.glob(os.path.join(HERE, "*.py")) | |
| # استبعاد الملف الحالي لتجنب التكرار اللانهائي | |
| current_file = os.path.abspath(__file__) | |
| python_files = [f for f in python_files if os.path.abspath(f) != current_file] | |
| if not python_files: | |
| logging.warning("لم يتم العثور على ملفات بايثون أخرى في المجلد") | |
| return {} | |
| processes = {} | |
| for py_file in python_files: | |
| try: | |
| filename = os.path.basename(py_file) | |
| # تجنب تشغيل الملفات التي قد تسبب مشاكل | |
| if filename in ["app.py", "__init__.py"]: | |
| continue | |
| proc = subprocess.Popen( | |
| [PYTHON, py_file], | |
| cwd=HERE, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.STDOUT, | |
| text=True, | |
| bufsize=1, | |
| ) | |
| processes[filename] = proc | |
| logging.info("تم تشغيل %s (pid=%s)", filename, proc.pid) | |
| except Exception as e: | |
| logging.error("فشل تشغيل %s: %s", filename, e) | |
| _python_processes.update(processes) | |
| return processes | |
| # | |
| def index(): | |
| # تأكد أن الحارس يعمل (مفيد إذا أعاد السيرفر التحميل) | |
| _start_watchdog_once() | |
| return "🚀 App is running… Telegram listener watchdog is active." | |
| def health(): | |
| alive = (_listener_proc is not None) and (_listener_proc.poll() is None) | |
| return jsonify( | |
| status="ok", | |
| listener_running=alive, | |
| pid=_listener_proc.pid if alive else None, | |
| ) | |
| def start_telegram(): | |
| global _listener_proc | |
| if _listener_proc is None or _listener_proc.poll() is not None: | |
| _listener_proc = _spawn_listener() | |
| if _listener_proc is None: | |
| return "⚠️ فشل تشغيل telegram_listener.py (تحقق من السجلات).", 500 | |
| return "✅ تم تشغيل telegram_listener.py.", 200 | |
| return "ℹ️ المستمع يعمل بالفعل.", 200 | |
| def stop_telegram(): | |
| global _listener_proc | |
| if _listener_proc and _listener_proc.poll() is None: | |
| try: | |
| _listener_proc.terminate() | |
| _listener_proc.wait(timeout=10) | |
| logging.info("تم إيقاف telegram_listener.py (pid=%s).", _listener_proc.pid) | |
| except Exception: | |
| try: | |
| _listener_proc.kill() | |
| except Exception: | |
| pass | |
| _listener_proc = None | |
| return "🛑 تم إيقاف telegram_listener.py.", 200 | |
| return "ℹ️ لا توجد عملية مستمع تعمل.", 200 | |
| def run_all_python_endpoint(): | |
| """تشغيل جميع ملفات بايثون في المجلد""" | |
| processes = run_all_python_files() | |
| if processes: | |
| return jsonify({ | |
| "status": "success", | |
| "message": f"تم تشغيل {len(processes)} ملف بايثون", | |
| "processes": {name: proc.pid for name, proc in processes.items()} | |
| }), 200 | |
| else: | |
| return jsonify({ | |
| "status": "warning", | |
| "message": "لم يتم تشغيل أي ملفات بايثون" | |
| }), 200 | |
| def stop_all_python_endpoint(): | |
| """إيقاف جميع عمليات بايثون""" | |
| stopped = stop_all_python_processes() | |
| if stopped: | |
| return jsonify({ | |
| "status": "success", | |
| "message": f"تم إيقاف {len(stopped)} عملية", | |
| "stopped_processes": stopped | |
| }), 200 | |
| else: | |
| return jsonify({ | |
| "status": "info", | |
| "message": "لا توجد عمليات بايثون نشطة" | |
| }), 200 | |
| def list_python_processes(): | |
| """عرض قائمة عمليات بايثون النشطة""" | |
| active_processes = {} | |
| for filename, proc in _python_processes.items(): | |
| if proc and proc.poll() is None: | |
| active_processes[filename] = proc.pid | |
| return jsonify({ | |
| "active_processes": active_processes, | |
| "total": len(active_processes) | |
| }), 200 | |
| def _graceful_shutdown(*_args): | |
| """إيقاف أنيق عند إنهاء الحاوية.""" | |
| global _stop_flag, _listener_proc, _python_processes | |
| _stop_flag = True | |
| # إيقاف المستمع | |
| if _listener_proc and _listener_proc.poll() is None: | |
| try: | |
| _listener_proc.terminate() | |
| _listener_proc.wait(timeout=10) | |
| except Exception: | |
| try: | |
| _listener_proc.kill() | |
| except Exception: | |
| pass | |
| # إيقاف جميع عمليات بايثون | |
| stop_all_python_processes() | |
| # ربط إشارات الإنهاء (مهم لـ Spaces) | |
| signal.signal(signal.SIGTERM, _graceful_shutdown) | |
| signal.signal(signal.SIGINT, _graceful_shutdown) | |
| import os | |
| import time | |
| import requests | |
| import asyncio | |
| import logging | |
| import io | |
| import aiohttp | |
| import base64 | |
| import pytesseract | |
| from PIL import Image | |
| from typing import Dict, Tuple, List, Optional | |
| from dotenv import load_dotenv | |
| from telethon import TelegramClient, events | |
| from telethon.errors import FloodWaitError, ChatAdminRequiredError, ChannelPrivateError | |
| from telethon.tl.types import Channel, Chat | |
| from telethon.tl.functions.channels import GetParticipantRequest | |
| from telethon.errors import ChannelInvalidError, ChannelPrivateError | |
| import httpx | |
| from telegram import Update, InputFile | |
| from telegram.constants import ChatAction | |
| from telegram.ext import ( | |
| ApplicationBuilder, | |
| MessageHandler, | |
| CommandHandler, | |
| ContextTypes, | |
| filters, | |
| ) | |
| # تحميل المتغيرات البيئية | |
| load_dotenv() | |
| # ========================= | |
| # الإعدادات العامة | |
| # ========================= | |
| # إعدادات تيليثون | |
| API_ID = int(os.getenv("API_ID", 0)) | |
| API_HASH = os.getenv("API_HASH", "") | |
| PHONE = os.getenv("PHONE", "") | |
| BOT_TOKEN = os.getenv("BOT_TOKEN", "") | |
| # إعدادات نورا | |
| NOURA_API = (os.getenv("NOURA_API_BASE", "http://127.0.0.1:9531")).rstrip("/") | |
| NOURA_KEY = os.getenv("NOURA_API_KEY", "changeme") | |
| # أسماء مسموح الرد عليها في الخاص فقط | |
| ALLOWED = {u.strip().lower() for u in os.getenv("ALLOWED_USERNAMES", "").split(",") if u.strip()} | |
| # إعدادات تيليجرام بوت | |
| TELEGRAM_TOKEN = os.getenv("TELEGRAM_TOKEN", "6602941635:AAFxkmBtwOedXwMdR0MWYZa7qCZ0b4znWZ4") | |
| OLLAMA_URL = os.getenv("OLLAMA_URL", "http://127.0.0.1:11434") | |
| # إعدادات Tesseract OCR | |
| pytesseract.pytesseract.tesseract_cmd = '/data/data/com.termux/files/usr/bin/tesseract' | |
| TESSERACT_CONFIG = '--oem 3 --psm 6' | |
| # مرشحات النماذج + إعدادات خفيفة | |
| CANDIDATE_MODELS: List[Tuple[str, Dict]] = [ | |
| ("qwen2-0.5b-lite", {"num_ctx": 128, "num_batch": 16, "num_predict": 32, "temperature": 0.7}), | |
| ("tinyllama-lite", {"num_ctx": 128, "num_batch": 16, "num_predict": 32}), | |
| ("phi3:mini", {"num_ctx": 128, "num_batch": 16, "num_predict": 32}), | |
| ("qwen2-0.5b-lite:latest", {"num_ctx": 128, "num_batch": 16, "num_predict": 32, "temperature": 0.7}), | |
| ("tinyllama-lite:latest", {"num_ctx": 128, "num_batch": 16, "num_predict": 32}), | |
| ("tinyllama:latest", {"num_ctx": 128, "num_batch": 16, "num_predict": 32}), | |
| ("qwen2:0.5b", {"num_ctx": 128, "num_batch": 16, "num_predict": 32}), | |
| ] | |
| HTTP_TIMEOUT = float(os.getenv("OLLAMA_TIMEOUT", "180")) | |
| # إعدادات البوت | |
| client = TelegramClient("userbot_session", API_ID, API_HASH) | |
| LAST_REPLY_AT = {} | |
| MIN_INTERVAL = 5 # ثواني بين ردّين لنفس الشات | |
| # قاموس لتخزين القنوات المصدر والهدف | |
| source_channels = set() | |
| target_channels = set() | |
| # إعدادات التسجيل | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", | |
| force=True, | |
| ) | |
| # ========================= | |
| # أدوات مساعدة | |
| # ========================= | |
| def get_display_name(user) -> str: | |
| name = (getattr(user, "first_name", "") or getattr(user, "username", "") or "").strip() | |
| if not name or name.isdigit(): | |
| name = "حبيبي" | |
| return name[:24] | |
| async def ask_ollama(prompt: str, model: str, options: Dict, keep_alive: str = "5m") -> Tuple[str, Dict]: | |
| url = f"{OLLAMA_URL}/api/generate" | |
| payload = { | |
| "model": model, | |
| "prompt": prompt, | |
| "options": options, | |
| "keep_alive": keep_alive, | |
| "stream": False, | |
| } | |
| async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client: | |
| r = await client.post(url, json=payload) | |
| r.raise_for_status() | |
| j = r.json() | |
| text = (j.get("response") or "").strip() | |
| meta = { | |
| "model": j.get("model"), | |
| "created_at": j.get("created_at"), | |
| "total_duration": j.get("total_duration"), | |
| "load_duration": j.get("load_duration"), | |
| "prompt_eval_count": j.get("prompt_eval_count"), | |
| "prompt_eval_duration": j.get("prompt_eval_duration"), | |
| "eval_count": j.get("eval_count"), | |
| "eval_duration": j.get("eval_duration"), | |
| "done_reason": j.get("done_reason"), | |
| } | |
| return text, meta | |
| async def smart_reply(prompt: str) -> str: | |
| # إذا كان الطلب عن الرسم | |
| drawing_keywords = ['ارسم', 'رسم', 'صورة', 'صور', 'انشي', 'أنشي', 'توليد'] | |
| if any(keyword in prompt.lower() for keyword in drawing_keywords): | |
| return "🎨 لإنشاء الصور، استخدم الأمر:\n/image وصف الصورة\n\nمثال:\n/image امرأة شعرها أحمر وترتدي ثوب أخضر\n/image منظر طبيعي غروب الشمس" | |
| last_error = None | |
| for model, opts in CANDIDATE_MODELS: | |
| try: | |
| text, meta = await ask_ollama(prompt, model, opts) | |
| logging.info( | |
| "Ollama ok | model=%s total=%s load=%s eval=%s/%s done=%s", | |
| meta.get("model"), meta.get("total_duration"), | |
| meta.get("load_duration"), | |
| meta.get("prompt_eval_duration"), meta.get("eval_duration"), | |
| meta.get("done_reason"), | |
| ) | |
| if text: | |
| return text | |
| except Exception as e: | |
| last_error = e | |
| logging.warning("Ollama fail on %s: %s", model, e) | |
| continue | |
| logging.error("جميع المحاولات فشلت: %s", last_error) | |
| return ( | |
| "⚠️ تعذّر توليد رد الآن. جرب رسالة أقصر أو أعد المحاولة لاحقاً.\n" | |
| "🎨 فكرة لوحة مؤقتة: نافذة ليلية مفتوحة على نجوم هادئة، وكوب قهوة دافئ يلمع بخيط ضوء." | |
| ) | |
| # ========================= | |
| # OCR للتعرف على النصوص في الصور | |
| # ========================= | |
| async def extract_text_from_image(image_data: bytes) -> str: | |
| """ | |
| تستخرج النص من صورة باستخدام OCR | |
| """ | |
| try: | |
| # فتح الصورة من bytes | |
| image = Image.open(io.BytesIO(image_data)) | |
| # تحسين جودة الصورة للـ OCR | |
| image = image.convert('L') # تحويل إلى تدرج رمادي | |
| # استخراج النص (بالعربية والإنجليزية) | |
| text = pytesseract.image_to_string(image, lang='ara+eng', config=TESSERACT_CONFIG) | |
| return text.strip() if text else "" | |
| except Exception as e: | |
| logging.error(f"خطأ في استخراج النص من الصورة: {e}") | |
| return "" | |
| async def download_image(image_file) -> bytes: | |
| """ | |
| تحميل الصورة من التليجرام | |
| """ | |
| try: | |
| file = await image_file.get_file() | |
| image_data = await file.download_as_bytearray() | |
| return bytes(image_data) | |
| except Exception as e: | |
| logging.error(f"خطأ في تحميل الصورة: {e}") | |
| return None | |
| async def process_image_with_text(update: Update, context: ContextTypes.DEFAULT_TYPE): | |
| """ | |
| معالجة الصورة واستخراج النص منها | |
| """ | |
| try: | |
| message = update.message | |
| chat_id = update.effective_chat.id | |
| # إظهار حالة المعالجة | |
| await context.bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING) | |
| # تحميل الصورة | |
| if message.photo: | |
| # أخذ أعلى دقة صورة | |
| image_file = message.photo[-1] | |
| elif message.document: | |
| image_file = message.document | |
| else: | |
| return None | |
| image_data = await download_image(image_file) | |
| if not image_data: | |
| return None | |
| # استخراج النص من الصورة | |
| extracted_text = await extract_text_from_image(image_data) | |
| # الحصول على الـ caption إذا موجود | |
| caption = message.caption or "" | |
| # دمج النصوص | |
| full_text = "" | |
| if caption: | |
| full_text += f"📝 النص المرافق: {caption}\n\n" | |
| if extracted_text: | |
| full_text += f"📷 النص في الصورة: {extracted_text}" | |
| return full_text if full_text else None | |
| except Exception as e: | |
| logging.error(f"خطأ في معالجة الصورة: {e}") | |
| return None | |
| # ========================= | |
| # توليد الصور باستخدام Stable Horde | |
| # ========================= | |
| class StableHorde: | |
| def __init__(self, api_key: str = "0000000000"): | |
| self.api_key = api_key | |
| self.base_url = "https://stablehorde.net/api/v2" | |
| async def generate_image(self, prompt: str, model: str = "Deliberate") -> Optional[bytes]: | |
| generate_url = f"{self.base_url}/generate/async" | |
| payload = { | |
| "prompt": prompt, | |
| "params": { | |
| "width": 512, | |
| "height": 512, | |
| "steps": 20, | |
| "n": 1, | |
| }, | |
| "nsfw": False, | |
| "trusted_workers": False, | |
| "models": [model] | |
| } | |
| headers = {"apikey": self.api_key} | |
| try: | |
| async with aiohttp.ClientSession() as session: | |
| # إرسال طلب التوليد | |
| async with session.post(generate_url, json=payload, headers=headers) as response: | |
| if response.status != 202: | |
| error_text = await response.text() | |
| logging.error(f"Stable Horde Error: {response.status} - {error_text}") | |
| return None | |
| data = await response.json() | |
| job_id = data['id'] | |
| logging.info(f"Stable Horde: Job started with ID: {job_id}") | |
| # الانتظار حتى اكتمال التوليد | |
| status_url = f"{self.base_url}/generate/status/{job_id}" | |
| max_attempts = 30 # 30 محاولة × 5 ثوان = 150 ثانية كحد أقصى | |
| attempt = 0 | |
| while attempt < max_attempts: | |
| await asyncio.sleep(5) | |
| attempt += 1 | |
| async with session.get(status_url) as status_response: | |
| if status_response.status != 200: | |
| logging.error(f"Stable Horde Status Error: {status_response.status}") | |
| continue | |
| status_data = await status_response.json() | |
| logging.info(f"Stable Horde Status: {status_data.get('finished')} done, {status_data.get('waiting')} waiting, {status_data.get('processing')} processing") | |
| if status_data['done']: | |
| logging.info("Stable Horde: Job completed successfully") | |
| break | |
| if status_data['faulted']: | |
| logging.error("Stable Horde: Job faulted") | |
| return None | |
| if status_data.get('is_possible', True) == False: | |
| logging.error("Stable Horde: Job not possible") | |
| return None | |
| if attempt >= max_attempts: | |
| logging.error("Stable Horde: Timeout waiting for job completion") | |
| return None | |
| # تحميل الصورة | |
| if status_data['generations'] and status_data['generations'][0]['img']: | |
| base64_img = status_data['generations'][0]['img'] | |
| logging.info("Stable Horde: Image generated successfully") | |
| return base64.b64decode(base64_img) | |
| else: | |
| logging.error("Stable Horde: No image data in response") | |
| return None | |
| except aiohttp.ClientError as e: | |
| logging.error(f"Stable Horde Network Error: {e}") | |
| return None | |
| except asyncio.TimeoutError: | |
| logging.error("Stable Horde: Request timeout") | |
| return None | |
| except Exception as e: | |
| logging.error(f"Stable Horde Unexpected Error: {e}") | |
| return None | |
| # ========================= | |
| # أدوات الإدارة لنورا | |
| # ========================= | |
| def admin_call(path, json=None, timeout=8): | |
| try: | |
| return requests.post( | |
| f"{NOURA_API}{path}", | |
| json=json, | |
| headers={"x-api-key": NOURA_KEY}, | |
| timeout=timeout, | |
| ).json() | |
| except Exception as e: | |
| print("Admin API error:", repr(e)) | |
| return {"ok": False} | |
| async def _is_self_admin(entity) -> bool: | |
| """تحقق أن الحساب الحالي (userbot) مدير في القناة/المجموعة.""" | |
| try: | |
| me = await client.get_me() | |
| perms = await client.get_permissions(entity, me) | |
| return bool( | |
| getattr(perms, "is_admin", False) | |
| or getattr(perms, "is_creator", False) | |
| or getattr(perms, "admin_rights", None) | |
| ) | |
| except Exception as e: | |
| print("is_self_admin error:", repr(e)) | |
| return False | |
| async def _resolve_channel(target: str): | |
| """حوّل @username أو ID إلى كيان تيليجرام.""" | |
| target = (target or "").strip() | |
| if not target: | |
| return None | |
| try: | |
| if target.startswith("@"): | |
| return await client.get_entity(target) | |
| if target.lstrip("+-").isdigit(): | |
| return await client.get_entity(int(target)) | |
| return await client.get_entity(target) | |
| except Exception as e: | |
| print("resolve_channel error:", repr(e)) | |
| return None | |
| async def _post_to_channel(target_entity, text: str) -> bool: | |
| """انشر في القناة/المجموعة لو عندك صلاحية.""" | |
| try: | |
| if not await _is_self_admin(target_entity): | |
| print("post_to_channel: not admin") | |
| return False | |
| await client.send_message(target_entity, text) | |
| return True | |
| except ChatAdminRequiredError: | |
| print("post_to_channel: admin required") | |
| return False | |
| except ChannelPrivateError: | |
| print("post_to_channel: private/forbidden") | |
| return False | |
| except Exception as e: | |
| print("post_to_channel error:", repr(e)) | |
| return False | |
| async def handle_admin_commands(event): | |
| """ | |
| أوامر: | |
| /learn_on [0.82] ← تفعيل التعلم بحد تشابه اختياري | |
| /learn_off ← إيقاف التعلم هنا | |
| /memory ← عدد الأمثلة المحفوظة هنا | |
| /announce @chan|id | msg ← نشر إعلان في قناة أنت مدير فيها | |
| /posthere msg ← نشر في القناة/المجموعة الحالية | |
| """ | |
| text_raw = (event.message.message or "").strip() | |
| text = text_raw.lower() | |
| # /learn_on [min_similarity] | |
| if text.startswith("/learn_on"): | |
| parts = text.split() | |
| min_sim = None | |
| if len(parts) >= 2: | |
| try: | |
| min_sim = float(parts[1]) | |
| except: | |
| min_sim = None | |
| # فعّل التعلم | |
| admin_call("/admin/learn", {"chat_id": event.chat_id, "enabled": True}) | |
| # تمرير min_similarity (لو السيرفر يدعمها) | |
| if min_sim is not None: | |
| try: | |
| requests.post( | |
| f"{NOURA_API}/admin/learn", | |
| json={"chat_id": event.chat_id, "enabled": True, "min_similarity": min_sim}, | |
| headers={"x-api-key": NOURA_KEY}, | |
| timeout=8, | |
| ) | |
| except Exception as e: | |
| print("set min_similarity error:", repr(e)) | |
| await event.respond("✅ تم تفعيل التعلم هنا\n\n🤖 (رد آلي - بوت نورا)") | |
| return True | |
| # /learn_off | |
| if text.startswith("/learn_off"): | |
| admin_call("/admin/learn", {"chat_id": event.chat_id, "enabled": False}) | |
| await event.respond("⛔ تم إيقاف التعلم هنا\n\n🤖 (رد آلي - بوت نورa)") | |
| return True | |
| # /memory | |
| if text.startswith("/memory"): | |
| try: | |
| r = requests.get( | |
| f"{NOURA_API}/admin/stats", | |
| params={"chat_id": event.chat_id}, | |
| headers={"x-api-key": NOURA_KEY}, | |
| timeout=8, | |
| ) | |
| c = r.json().get("count", 0) | |
| await event.respond(f"📚 عدد الأمثلة المحفوظة هنا: {c}\n\n🤖 (رد آلي - بوت نورا)") | |
| except Exception: | |
| await event.respond("⚠️ تعذر جلب الإحصاءات\n\n🤖 (رد آلي - بوت نورا)") | |
| return True | |
| # /announce @chan | message أو /announce 123456789 | message | |
| if text.startswith("/announce"): | |
| try: | |
| _, rest = text_raw.split(" ", 1) | |
| target, msg = [p.strip() for p in rest.split("|", 1)] | |
| except ValueError: | |
| await event.respond("📣 الاستخدام: `/announce @channel | الرسالة`", parse_mode="md") | |
| return True | |
| entity = await _resolve_channel(target) | |
| if not entity: | |
| await event.respond("⚠️ لم أستطع الوصول إلى القناة المطلوبة.") | |
| return True | |
| ok = await _post_to_channel(entity, msg) | |
| if ok: | |
| await event.respond(f"✅ تم النشر في {target}\n\n🤖 (رد آلي - بوت نورا)") | |
| else: | |
| await event.respond(f"⛔ لا أملك صلاحية النشر في {target}\n\n🤖 (رد آلي - بوت نورا)") | |
| return True | |
| # /posthere message ← من داخل القناة/المجموعة | |
| if text.startswith("/posthere"): | |
| if not (event.is_channel or event.is_group): | |
| await event.respond("ℹ️ استخدم هذا الأمر داخل القناة/المجموعة المراد النشر فيها.") | |
| return True | |
| try: | |
| msg = text_raw.split(" ", 1)[1].strip() | |
| except IndexError: | |
| await event.respond("📣 الاستخدام: `/posthere الرسالة`", parse_mode="md") | |
| return True | |
| ok = await _post_to_channel(event.chat_id, msg) | |
| if ok: | |
| await event.respond("✅ تم النشر هنا\n\n🤖 (رد آلي - بوت نورا)") | |
| else: | |
| await event.respond("⛔ لا أملك صلاحية النشر هنا\n\n🤖 (رد آلي - بوت نورa)") | |
| return True | |
| return False | |
| # ========================= | |
| # أدوات الاتصال مع نورا | |
| # ========================= | |
| def is_allowed(event): | |
| # اسمح بالخاص + كل رسائل المجموعات (خيارك الأول) | |
| if not (event.is_private or event.is_group): | |
| return False | |
| # في الخاص فقط نطبق ALLOWED (إن كانت محددة) | |
| if event.is_private and ALLOWED: | |
| uname = (getattr(event.chat, "username", None) or "").lower() | |
| return uname in ALLOWED | |
| return True | |
| def noura_healthy(timeout=3): | |
| try: | |
| r = requests.get(f"{NOURA_API}/health", timeout=timeout) | |
| return r.status_code == 200 | |
| except Exception: | |
| return False | |
| def call_noura(text: str, user: str, meta: dict): | |
| # فحص الصحة بمحاولتين | |
| for attempt in range(2): | |
| if noura_healthy(): | |
| break | |
| time.sleep(1 + attempt) # 1s, 2s | |
| try: | |
| r = requests.post( | |
| f"{NOURA_API}/chat", | |
| json={"text": text, "user": user, "meta": meta}, | |
| headers={"x-api-key": NOURA_KEY}, | |
| timeout=10, | |
| ) | |
| if not (200 <= r.status_code < 300): | |
| print("Noura API non-200:", r.status_code, r.text) | |
| r.raise_for_status() | |
| return r.json() | |
| except Exception as e: | |
| print("Noura API error:", repr(e)) | |
| return {"reply": "تعذر التواصل مع نورا الآن. (رد آلي)", "ts": int(time.time())} | |
| # ========================= | |
| # وظائف البوت الإضافية | |
| # ========================= | |
| async def refresh_admin_channels(): | |
| """تحديث قائمة القنوات التي البوت مشرف فيها تلقائياً""" | |
| global target_channels | |
| print("🔄 جاري تحديث قائمة القنوات...") | |
| try: | |
| # جلب جميع الدردشات التي البوت عضو فيها | |
| async for dialog in client.iter_dialogs(): | |
| if isinstance(dialog.entity, (Channel, Chat)): | |
| try: | |
| # التحقق من صلاحيات المشرف | |
| participant = await client(GetParticipantRequest( | |
| dialog.entity, | |
| await client.get_input_entity('me') | |
| )) | |
| if hasattr(participant.participant, 'admin_rights'): | |
| if participant.participant.admin_rights.post_messages: | |
| # إضافة القناة تلقائياً كقناة هدف | |
| target_channels.add(dialog.entity.id) | |
| print(f"✅ البوت مشرف في: {dialog.name} (ID: {dialog.entity.id})") | |
| except (ChannelInvalidError, ChannelPrivateError, ValueError): | |
| continue | |
| except Exception as e: | |
| continue | |
| except Exception as e: | |
| print(f"⚠️ لا يمكن تحديث القنوات تلقائياً: {e}") | |
| print("💡 تأكد من إضافة البوت كمساعد في القنوات أولاً") | |
| async def handle_channel_message(event): | |
| """معالجة الرسائل من القنوات المصدر""" | |
| try: | |
| # تجاهل الرسائل الخاصة | |
| if event.is_private: | |
| return | |
| chat_id = event.chat_id | |
| # إذا كانت القناة ليست في القنوات المصدر، نتجاهل | |
| if chat_id not in source_channels: | |
| return | |
| print(f"📥 رسالة جديدة من قناة مصدر: {chat_id}") | |
| # إعادة الإرسال لجميع القنوات الهدف | |
| success_count = 0 | |
| for target_id in target_channels: | |
| if target_id != chat_id: # عدم الإرسال لنفس القناة | |
| try: | |
| await client.forward_messages(target_id, event.message) | |
| print(f"✅ تم النشر إلى: {target_id}") | |
| success_count += 1 | |
| except Exception as e: | |
| print(f"❌ فشل النشر إلى {target_id}: {e}") | |
| print(f"✅ تم النشر إلى {success_count} قناة") | |
| except Exception as e: | |
| print(f"❌ خطأ في معالجة الرسالة: {e}") | |
| async def handle_private_command(event): | |
| """معالجة الأوامر الخاصة""" | |
| try: | |
| message = event.message.text | |
| if message.startswith('/start'): | |
| await event.reply("🤖 أهلاً! أنا بوت النشر التلقائي\n\n" | |
| "⚡ الأوامر المتاحة:\n" | |
| "/source - جعل هذه القناة مصدراً (الرد على رسالة)\n" | |
| "/unsource - إزالة القناة من المصادر\n" | |
| "/refresh - تحديث قائمة القنوات تلقائياً\n" | |
| "/list - عرض القنوات المضافة\n" | |
| "/help - المساعدة") | |
| elif message.startswith('/source'): | |
| if event.message.reply_to_msg_id: | |
| replied = await event.get_reply_message() | |
| if replied.forward_from_chat: | |
| chat_id = replied.forward_from_chat.id | |
| source_channels.add(chat_id) | |
| await event.reply(f"✅ تمت إضافة قناة مصدر: {chat_id}") | |
| else: | |
| await event.reply("⚠️ أجب على رسالة من القناة") | |
| else: | |
| await event.reply("💡 الاستخدام: أجب على رسالة من القناة وأرسل /source") | |
| elif message.startswith('/unsource'): | |
| if event.message.reply_to_msg_id: | |
| replied = await event.get_reply_message() | |
| if replied.forward_from_chat: | |
| chat_id = replied.forward_from_chat.id | |
| if chat_id in source_channels: | |
| source_channels.remove(chat_id) | |
| await event.reply(f"✅ تمت إزالة قناة مصدر: {chat_id}") | |
| else: | |
| await event.reply("⚠️ القناة غير مضافة") | |
| else: | |
| await event.reply("⚠️ أجب على رسالة من القناة") | |
| else: | |
| await event.reply("💡 الاستخدام: أجب على رسالة من القناة وأرسل /unsource") | |
| elif message.startswith('/refresh'): | |
| await refresh_admin_channels() | |
| await event.reply(f"✅ تم التحديث: {len(target_channels)} قناة هدف") | |
| elif message.startswith('/list'): | |
| response = "📋 القنوات المصدر:\n" | |
| for i, chat_id in enumerate(source_channels, 1): | |
| response += f"{i}. {chat_id}\n" | |
| response += f"\n🎯 القنوات الهدف ({len(target_channels)}):\n" | |
| response += "✅ البوت مشرف فيها تلقائياً" | |
| await event.reply(response) | |
| elif message.startswith('/help'): | |
| await event.reply("🔧 طريقة الاستخدام:\n\n" | |
| "1. أضف البوت كمساعد في القنوات التي تريد النشر فيها\n" | |
| "2. أرسل رسالة من القناة إلى البوت\n" | |
| "3. أجب على الرسالة بأمر /source لجعلها قناة مصدر\n" | |
| "4. سيتم النشر تلقائياً لجميع القنوات الأخرى!") | |
| except Exception as e: | |
| await event.reply(f"❌ خطأ: {e}") | |
| # ========================= | |
| # Handlers لتليجرام بوت | |
| # ========================= | |
| async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): | |
| await update.message.reply_text( | |
| "مرحباً بك! 🤖\n" | |
| "اكتب أي رسالة، وسأرد عليك باللغة نفسها. " | |
| "في حالات الانقطاع، سأعطيك فكرة لوحة كبديل 🎨.\n\n" | |
| "استخدم الأمر /image لإنشاء صورة بواسطة الذكاء الاصطناعي 🖼️\n" | |
| "أو أرسل صورة وسأقرأ النص فيها وأرد عليه 📷" | |
| ) | |
| async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE): | |
| try: | |
| if not update.message or not update.message.text: | |
| return | |
| # تخطي الرسائل التي تحتوي على صور (يتم معالجتها في handle_images) | |
| if update.message.photo or (update.message.document and update.message.document.mime_type and 'image' in update.message.document.mime_type): | |
| return | |
| chat_id = update.effective_chat.id | |
| user = update.effective_user | |
| user_name = get_display_name(user) | |
| user_text = update.message.text or "" | |
| # التحقق إذا كان طلب رسم | |
| drawing_keywords = ['ارسم', 'رسم', 'صورة', 'صور', 'انشي', 'أنشي', 'توليد'] | |
| if any(keyword in user_text.lower() for keyword in drawing_keywords): | |
| await update.message.reply_text( | |
| "🎨 لإنشاء الصور، استخدم الأمر:\n" | |
| "/image وصف الصورة\n\n" | |
| "مثال:\n" | |
| "/image امرأة شعرها أحمر وترتدي ثوب أخضر\n" | |
| "/image منظر طبيعي غروب الشمس" | |
| ) | |
| return | |
| # إظهار حالة الكتابة | |
| try: | |
| await context.bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING) | |
| except Exception as e: | |
| logging.warning(f"تعذر إظهار حالة الكتابة: {e}") | |
| # توليد عبر Ollama مع fallback | |
| reply_text = await smart_reply(user_text) | |
| # رد نصّي | |
| await update.message.reply_text(reply_text) | |
| except Exception as e: | |
| logging.error(f"خطأ في معالجة الرسالة: {e}", exc_info=True) | |
| try: | |
| await update.message.reply_text("⚠️ حدث خطأ غير متوقع، يرجى المحاولة لاحقاً.") | |
| except Exception: | |
| pass | |
| async def handle_images(update: Update, context: ContextTypes.DEFAULT_TYPE): | |
| """ | |
| يتعامل مع الصور والـ captions | |
| """ | |
| try: | |
| # معالجة الصورة واستخراج النص | |
| extracted_text = await process_image_with_text(update, context) | |
| if extracted_text: | |
| # إرسال النص المستخرج للمستخدم أولاً | |
| await update.message.reply_text( | |
| f"📖 تم التعرف على النص:\n{extracted_text}\n\n" | |
| f"جاري الرد على المحتوى..." | |
| ) | |
| # استخدام النص المستخرج للرد | |
| user = update.effective_user | |
| user_name = get_display_name(user) | |
| # الرد على النص المستخرج | |
| reply_text = await smart_reply(extracted_text) | |
| await update.message.reply_text(reply_text) | |
| else: | |
| await update.message.reply_text( | |
| "📷 لم أتمكن من التعرف على نص في الصورة. " | |
| "هل يمكنك كتابة النص الذي تريدني أن أرد عليه؟" | |
| ) | |
| except Exception as e: | |
| logging.error(f"خطأ في معالجة الصورة: {e}") | |
| await update.message.reply_text("⚠️ حدث خطأ أثناء معالجة الصورة") | |
| async def handle_image_command(update: Update, context: ContextTypes.DEFAULT_TYPE): | |
| try: | |
| chat_id = update.effective_chat.id | |
| user_input = " ".join(context.args) if context.args else "منظر طبيعي خلاب" | |
| await context.bot.send_chat_action(chat_id=chat_id, action=ChatAction.UPLOAD_PHOTO) | |
| await update.message.reply_text("🔄 جاري توليد الصورة، قد يستغرق هذا بضع دقائق...") | |
| horde_client = StableHorde() | |
| image_data = await horde_client.generate_image(user_input) | |
| if image_data: | |
| photo_stream = io.BytesIO(image_data) | |
| photo_stream.name = 'ai_image.jpg' | |
| await context.bot.send_photo( | |
| chat_id=chat_id, | |
| photo=InputFile(photo_stream), | |
| caption=f"🖼️ الصورة الخاصة بك!\nالوصف: {user_input}" | |
| ) | |
| logging.info("Image sent successfully") | |
| else: | |
| await update.message.reply_text( | |
| "❌ فشل في توليد الصورة. قد يكون السبب:\n" | |
| "• مشكلة في اتصال Stable Horde\n" | |
| "• وصف غير واضح للصورة\n" | |
| "• ازدحام في الخدمة\n\n" | |
| "يرجى المحاولة مرة أخرى بعد قليل أو استخدام وصف أكثر وضوحاً." | |
| ) | |
| except Exception as e: | |
| logging.error(f"Error in image command: {e}", exc_info=True) | |
| await update.message.reply_text( | |
| "⚠️ حدث خطأ غير متوقع أثناء توليد الصورة. " | |
| "يرجى المحاولة مرة أخرى لاحقاً." | |
| ) | |
| # ========================= | |
| # الهاندلر الرئيسي لتليثون | |
| # ========================= | |
| async def on_msg(event): | |
| try: | |
| # لا ترد على رسائلك، ولا على رسائل البوتات، ولا رسائل الخدمة | |
| if event.out or getattr(event.sender, "bot", False) or getattr(event.message, "action", None) is not None: | |
| return | |
| # تجنب الرد التلقائي في القنوات (مش مجموعات) | |
| if event.is_channel and not event.is_group: | |
| # اسمح فقط بأوامر الإدارة داخل القنوات | |
| if await handle_admin_commands(event): | |
| return | |
| return | |
| # أوامر الإدارة (تُنفَّذ وتُنهي الهاندلر) | |
| if await handle_admin_commands(event): | |
| return | |
| if not is_allowed(event): | |
| return | |
| # تجاهل الوسائط فقط | |
| if getattr(event.message, "media", None) and not (event.message.message or "").strip(): | |
| return | |
| msg = (event.message.message or "").strip() | |
| if len(msg) < 2: | |
| return | |
| # تجاهل الفوروارد | |
| if getattr(event.message, "fwd_from", None): | |
| return | |
| chat_id = event.chat_id | |
| now = time.time() | |
| if now - LAST_REPLY_AT.get(chat_id, 0) < MIN_INTERVAL: | |
| return | |
| # معلومات إضافية تفيد التعلم | |
| sender = await event.get_sender() | |
| uname = (getattr(event.chat, "username", None) or "unknown") | |
| chat_type = "خاص" if event.is_private else "مجموعة" | |
| meta = { | |
| "peer_id": chat_id, | |
| "tg_username": uname, | |
| "chat_type": chat_type, | |
| "sender_id": getattr(sender, "id", None), | |
| "sender_username": (getattr(sender, "username", None) or None), | |
| } | |
| data = call_noura(msg, user=uname, meta=meta) | |
| reply = (data.get("reply") or "…").strip() | |
| await event.respond(reply) | |
| LAST_REPLY_AT[chat_id] = now | |
| except FloodWaitError as e: | |
| await asyncio.sleep(int(e.seconds) + 1) | |
| except Exception as e: | |
| print("Userbot error:", e) | |
| # ========================= | |
| # Warm-up اختياري عند بدء التشغيل | |
| # ========================= | |
| async def post_init(app): | |
| try: | |
| logging.info("Warm-up: بدء تحميل الموديل الأول لتقليل التأخير…") | |
| _ = await smart_reply("ping") | |
| logging.info("Warm-up: تم.") | |
| except Exception as e: | |
| logging.warning("Warm-up فشل: %s", e) | |
| # ========================= | |
| # الدوال الرئيسية للتشغيل | |
| # ========================= | |
| # استبدل دالة main() الحالية: | |
| async def main(): | |
| # بدء تيليثون | |
| await client.start(phone=PHONE) | |
| print("✅ تم تشغيل تيليثون بنجاح") | |
| # تحديث قائمة القنوات تلقائياً | |
| await refresh_admin_channels() | |
| # بدء تيليجرام بوت | |
| app = ApplicationBuilder().token(TELEGRAM_TOKEN).post_init(post_init).build() | |
| # إضافة الهاندلرات | |
| app.add_handler(CommandHandler("start", start)) | |
| app.add_handler(CommandHandler("image", handle_image_command)) | |
| app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message)) | |
| app.add_handler(MessageHandler(filters.PHOTO | (filters.Document.IMAGE & filters.CAPTION), handle_images)) | |
| # تشغيل البوتين معاً | |
| await asyncio.gather( | |
| client.run_until_disconnected(), | |
| app.run_polling() | |
| ) | |
| # استبدل دالة main() الحالية بهذا الكود: | |
| async def main(): | |
| # بدء تيليثون | |
| await client.start(phone=PHONE) | |
| print("✅ تم تشغيل تيليثون بنجاح") | |
| # تحديث قائمة القنوات تلقائياً | |
| await refresh_admin_channels() | |
| # بدء تيليجرام بوت في خيط منفصل | |
| import threading | |
| def run_bot(): | |
| # إنشاء تطبيق جديد داخل الخيط | |
| app = ApplicationBuilder().token(TELEGRAM_TOKEN).build() | |
| # إضافة الهاندلرات | |
| app.add_handler(CommandHandler("start", start)) | |
| app.add_handler(CommandHandler("image", handle_image_command)) | |
| app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message)) | |
| app.add_handler(MessageHandler(filters.PHOTO | (filters.Document.IMAGE & filters.CAPTION), handle_images)) | |
| # تشغيل البوت | |
| app.run_polling() | |
| # تشغيل البوت في خيط منفصل | |
| bot_thread = threading.Thread(target=run_bot) | |
| bot_thread.daemon = True | |
| bot_thread.start() | |
| # الاستمرار في تشغيل تيليثون في الخيط الرئيسي | |
| await client.run_until_disconnected() | |
| if __name__ == "__main__": | |
| asyncio.run(main()) | |
| if __name__ == "__main__": | |
| # شغّل الحارس تلقائياً عند تشغيل السيرفر | |
| _start_watchdog_once() | |
| # تشغيل جميع ملفات بايثون تلقائياً عند البدء (اختياري) | |
| # run_all_python_files() | |
| port = int(os.environ.get("PORT", "7860")) | |
| app.run(host="0.0.0.0", port=port) |