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) |