#!/usr/bin/env python3 """ githubbot.py — GitHub Bot + Multi-Provider AI Assistant pip install python-telegram-bot httpx groq openai huggingface_hub """ import asyncio import io import os, json, base64, logging, httpx, threading, time, traceback from logging.handlers import RotatingFileHandler from pathlib import Path from functools import wraps from datetime import datetime from groq import Groq from openai import OpenAI from huggingface_hub import InferenceClient from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup from telegram.ext import (Application, CommandHandler, CallbackQueryHandler, MessageHandler, filters, ContextTypes, ConversationHandler) # ── قراءة .env ───────────────────────────────────────── def _load_env(): for name in ["githubbot.env", ".env"]: f = Path(__file__).parent / name if f.exists(): for line in f.read_text().splitlines(): line = line.strip() if line and not line.startswith("#") and "=" in line: k, v = line.split("=", 1) os.environ.setdefault(k.strip(), v.strip()) return _load_env() def _persist_env(key: str, value: str) -> None: """Save key=value to githubbot.env.""" path = Path(__file__).parent / "githubbot.env" try: lines = path.read_text().splitlines() if path.exists() else [] lines = [l for l in lines if not l.startswith(f"{key}=")] lines.append(f"{key}={value}") path.write_text("\n".join(lines) + "\n") except Exception as e: log.warning("_persist_env failed (read-only FS?): %s", e) BOT_TOKEN = os.getenv("BOT_TOKEN", "") ADMIN_ID = int(os.getenv("ADMIN_ID", "0")) GH_TOKEN = os.getenv("GH_TOKEN", "") GH_USER = os.getenv("GH_USER", "FAJU85") GH_REPO = os.getenv("GH_REPO", "ORC_Dash_Last") GROQ_KEY = os.getenv("GROQ_KEY", "") DEEPSEEK_KEY = os.getenv("DEEPSEEK_KEY", "") HF_TOKEN = os.getenv("HF_TOKEN", "") MISTRAL_KEY = os.getenv("MISTRAL_KEY", "") logging.basicConfig(format="%(asctime)s | %(levelname)s | %(message)s", level=logging.INFO) log = logging.getLogger(__name__) # ── File logger (rotating, 5 MB × 3 backups) ─────────── _log_path = Path(__file__).parent / "githubbot.log" _fh = RotatingFileHandler(str(_log_path), maxBytes=5*1024*1024, backupCount=3, encoding="utf-8") _fh.setFormatter(logging.Formatter( "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s", datefmt="%Y-%m-%d %H:%M:%S" )) logging.getLogger().addHandler(_fh) # attach to root so all loggers write to file # ── States ───────────────────────────────────────────── (WAIT_PAT, WAIT_GROQ_KEY, WAIT_REPO_NAME, WAIT_COMMIT_MSG, WAIT_FILE_CONTENT, WAIT_FILE_PATH, WAIT_BRANCH_NAME, WAIT_ISSUE_TITLE, WAIT_ISSUE_BODY, WAIT_RELEASE_TAG, WAIT_MULTI_FILES, WAIT_PR_TITLE, WAIT_PR_BODY, WAIT_AI_KEY, WAIT_PR_HEAD, WAIT_PR_BASE, WAIT_SEARCH_QUERY, ) = range(17) # ══════════════════════════════════════════════════════ # GitHub API # ══════════════════════════════════════════════════════ class GitHub: BASE = "https://api.github.com" def __init__(self, token, user): self.token = token self.user = user self.h = {"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"} # Persistent client — reused across all requests (connection pooling) self._session = httpx.Client(headers=self.h, timeout=15) def _c(self): return httpx.Client(headers=self.h, timeout=15) def _req(self, method: str, url: str, **kwargs): """Logged HTTP request with retry (3 attempts, exponential backoff).""" last_exc: Exception | None = None for attempt in range(3): try: t0 = time.monotonic() r = getattr(self._session, method)(url, **kwargs) elapsed = (time.monotonic() - t0) * 1000 log.debug("GH %s %s → %d (%.0f ms)", method.upper(), url.split("api.github.com")[-1], r.status_code, elapsed) if r.status_code >= 400: log.warning("GH API error %d on %s %s | body: %s", r.status_code, method.upper(), url.split("api.github.com")[-1], r.text[:200]) # Retry on server errors and rate limiting if r.status_code in (429, 500, 502, 503, 504) and attempt < 2: wait = 2 ** attempt log.warning("GH retry %d/2 in %ds (status=%d)", attempt + 1, wait, r.status_code) time.sleep(wait) continue return r except (httpx.TimeoutException, httpx.ConnectError) as e: last_exc = e if attempt < 2: wait = 2 ** attempt log.warning("GH network error (attempt %d/3): %s — retrying in %ds", attempt + 1, e, wait) time.sleep(wait) if last_exc: raise last_exc return r # Repos def list_repos(self): r = self._req("get", f"{self.BASE}/user/repos?sort=updated&per_page=20&type=all") return r.json() if r.status_code == 200 else [] def get_repo(self, repo): r = self._req("get", f"{self.BASE}/repos/{self.user}/{repo}") return r.json() if r.status_code == 200 else None def create_repo(self, name, private=False, desc=""): r = self._req("post", f"{self.BASE}/user/repos", json={"name": name, "private": private, "description": desc, "auto_init": True}) return r.json(), r.status_code def delete_repo(self, repo): r = self._req("delete", f"{self.BASE}/repos/{self.user}/{repo}") return r.status_code == 204 # Files def list_files(self, repo, path="", branch="main"): r = self._req("get", f"{self.BASE}/repos/{self.user}/{repo}/contents/{path}", params={"ref": branch}) return r.json() if r.status_code == 200 else [] def get_file(self, repo, path, branch="main"): r = self._req("get", f"{self.BASE}/repos/{self.user}/{repo}/contents/{path}", params={"ref": branch}) if r.status_code == 200: d = r.json() content = base64.b64decode(d.get("content","")).decode("utf-8", errors="replace") return content, d.get("sha","") return None, None def create_or_update_file(self, repo, path, content, message, branch="main", sha=None): payload = {"message": message, "content": base64.b64encode(content.encode()).decode(), "branch": branch} if sha: payload["sha"] = sha r = self._req("put", f"{self.BASE}/repos/{self.user}/{repo}/contents/{path}", json=payload) return r.status_code in (200, 201), r.json() def delete_file(self, repo, path, message, sha, branch="main"): r = self._req("delete", f"{self.BASE}/repos/{self.user}/{repo}/contents/{path}", json={"message": message, "sha": sha, "branch": branch}) return r.status_code == 200 def upload_multiple(self, repo, files: list[dict], branch="main"): """files = [{"path": "...", "content": "..."}, ...]""" results = [] for f in files: _, sha = self.get_file(repo, f["path"], branch) ok, res = self.create_or_update_file( repo, f["path"], f["content"], f.get("message", f"add: {f['path']}"), branch, sha) results.append({"path": f["path"], "ok": ok}) return results # Commits def list_commits(self, repo, branch="main", per_page=10): r = self._req("get", f"{self.BASE}/repos/{self.user}/{repo}/commits", params={"sha": branch, "per_page": per_page}) return r.json() if r.status_code == 200 else [] # Branches def list_branches(self, repo): r = self._req("get", f"{self.BASE}/repos/{self.user}/{repo}/branches") return r.json() if r.status_code == 200 else [] def create_branch(self, repo, name, from_branch="main"): r = self._req("get", f"{self.BASE}/repos/{self.user}/{repo}/git/refs/heads/{from_branch}") if r.status_code != 200: return False, "فشل الحصول على SHA" sha = r.json()["object"]["sha"] r2 = self._req("post", f"{self.BASE}/repos/{self.user}/{repo}/git/refs", json={"ref": f"refs/heads/{name}", "sha": sha}) return r2.status_code == 201, r2.json() def delete_branch(self, repo, branch): r = self._req("delete", f"{self.BASE}/repos/{self.user}/{repo}/git/refs/heads/{branch}") return r.status_code == 204 # Pull Requests def list_prs(self, repo, state="open"): r = self._req("get", f"{self.BASE}/repos/{self.user}/{repo}/pulls", params={"state": state, "per_page": 10}) return r.json() if r.status_code == 200 else [] def create_pr(self, repo, title, head, base="main", body=""): r = self._req("post", f"{self.BASE}/repos/{self.user}/{repo}/pulls", json={"title": title, "head": head, "base": base, "body": body}) return r.status_code == 201, r.json() def merge_pr(self, repo, pr_number, message=""): r = self._req("put", f"{self.BASE}/repos/{self.user}/{repo}/pulls/{pr_number}/merge", json={"merge_method": "merge", "commit_message": message or f"Merge PR #{pr_number}"}) return r.status_code == 200, r.json() # Issues def list_issues(self, repo, state="open"): r = self._req("get", f"{self.BASE}/repos/{self.user}/{repo}/issues", params={"state": state, "per_page": 10}) return r.json() if r.status_code == 200 else [] def create_issue(self, repo, title, body=""): r = self._req("post", f"{self.BASE}/repos/{self.user}/{repo}/issues", json={"title": title, "body": body}) return r.status_code == 201, r.json() def close_issue(self, repo, number): r = self._req("patch", f"{self.BASE}/repos/{self.user}/{repo}/issues/{number}", json={"state": "closed"}) return r.status_code == 200 # Releases def list_releases(self, repo): r = self._req("get", f"{self.BASE}/repos/{self.user}/{repo}/releases?per_page=5") return r.json() if r.status_code == 200 else [] def create_release(self, repo, tag, name="", body=""): r = self._req("post", f"{self.BASE}/repos/{self.user}/{repo}/releases", json={"tag_name": tag, "name": name or tag, "body": body}) return r.status_code == 201, r.json() # Stats def get_profile(self): r = self._req("get", f"{self.BASE}/user") return r.json() if r.status_code == 200 else {} def get_traffic(self, repo): v = self._req("get", f"{self.BASE}/repos/{self.user}/{repo}/traffic/views") cl = self._req("get", f"{self.BASE}/repos/{self.user}/{repo}/traffic/clones") return (v.json() if v.status_code == 200 else {}, cl.json() if cl.status_code == 200 else {}) # Workflows / Actions def list_workflow_runs(self, repo, per_page=10): r = self._req("get", f"{self.BASE}/repos/{self.user}/{repo}/actions/runs", params={"per_page": per_page}) if r.status_code == 200: return r.json().get("workflow_runs", []) return [] def rerun_workflow(self, repo, run_id): r = self._req("post", f"{self.BASE}/repos/{self.user}/{repo}/actions/runs/{run_id}/rerun") return r.status_code == 201 # Commit diff def get_commit(self, repo, sha): r = self._req("get", f"{self.BASE}/repos/{self.user}/{repo}/commits/{sha}") return r.json() if r.status_code == 200 else None # Search repos def search_repos(self, query, per_page=10): r = self._req("get", f"{self.BASE}/search/repositories", params={"q": f"user:{self.user} {query}", "per_page": per_page}) if r.status_code == 200: return r.json().get("items", []) return [] # Compare branches def compare_branches(self, repo, base, head): r = self._req("get", f"{self.BASE}/repos/{self.user}/{repo}/compare/{base}...{head}") return r.json() if r.status_code == 200 else None # Star / Unstar def star_repo(self, owner, repo): r = self._req("put", f"{self.BASE}/user/starred/{owner}/{repo}") return r.status_code == 204 def unstar_repo(self, owner, repo): r = self._req("delete", f"{self.BASE}/user/starred/{owner}/{repo}") return r.status_code == 204 # Fork def fork_repo(self, owner, repo): r = self._req("post", f"{self.BASE}/repos/{owner}/{repo}/forks") return r.status_code == 202, r.json() if r.status_code == 202 else {} # Labels def list_labels(self, repo): r = self._req("get", f"{self.BASE}/repos/{self.user}/{repo}/labels", params={"per_page": 30}) return r.json() if r.status_code == 200 else [] def add_labels_to_issue(self, repo, number, labels): r = self._req("post", f"{self.BASE}/repos/{self.user}/{repo}/issues/{number}/labels", json={"labels": labels}) return r.status_code == 200 # ══════════════════════════════════════════════════════ # Groq AI # ══════════════════════════════════════════════════════ TOOLS = [ {"type":"function","function":{"name":"list_repos", "description":"عرض قائمة الريبوهات","parameters":{"type":"object","properties":{}}}}, {"type":"function","function":{"name":"get_repo_info", "description":"معلومات وإحصائيات ريبو", "parameters":{"type":"object","properties":{ "repo":{"type":"string","description":"اسم الريبو"}},"required":["repo"]}}}, {"type":"function","function":{"name":"list_commits", "description":"عرض آخر commits", "parameters":{"type":"object","properties":{ "repo":{"type":"string"},"branch":{"type":"string","default":"main"}},"required":["repo"]}}}, {"type":"function","function":{"name":"list_files", "description":"عرض ملفات ومجلدات", "parameters":{"type":"object","properties":{ "repo":{"type":"string"},"path":{"type":"string","default":""}},"required":["repo"]}}}, {"type":"function","function":{"name":"list_branches", "description":"عرض الـ branches", "parameters":{"type":"object","properties":{ "repo":{"type":"string"}},"required":["repo"]}}}, {"type":"function","function":{"name":"list_issues", "description":"عرض الـ issues", "parameters":{"type":"object","properties":{ "repo":{"type":"string"},"state":{"type":"string","default":"open"}},"required":["repo"]}}}, {"type":"function","function":{"name":"list_prs", "description":"عرض Pull Requests", "parameters":{"type":"object","properties":{ "repo":{"type":"string"},"state":{"type":"string","default":"open"}},"required":["repo"]}}}, {"type":"function","function":{"name":"create_branch", "description":"إنشاء branch جديد", "parameters":{"type":"object","properties":{ "repo":{"type":"string"},"name":{"type":"string"}, "from_branch":{"type":"string","default":"main"}},"required":["repo","name"]}}}, {"type":"function","function":{"name":"create_issue", "description":"إنشاء issue جديد", "parameters":{"type":"object","properties":{ "repo":{"type":"string"},"title":{"type":"string"}, "body":{"type":"string","default":""}},"required":["repo","title"]}}}, {"type":"function","function":{"name":"merge_pr", "description":"دمج Pull Request", "parameters":{"type":"object","properties":{ "repo":{"type":"string"},"pr_number":{"type":"integer"}},"required":["repo","pr_number"]}}}, {"type":"function","function":{"name":"create_release", "description":"إنشاء release جديد", "parameters":{"type":"object","properties":{ "repo":{"type":"string"},"tag":{"type":"string"}, "name":{"type":"string","default":""},"body":{"type":"string","default":""}},"required":["repo","tag"]}}}, {"type":"function","function":{"name":"read_file", "description":"قراءة محتوى ملف من الريبو", "parameters":{"type":"object","properties":{ "repo":{"type":"string"},"path":{"type":"string"}, "branch":{"type":"string","default":"main"}},"required":["repo","path"]}}}, {"type":"function","function":{"name":"create_file", "description":"إنشاء أو تعديل ملف في الريبو", "parameters":{"type":"object","properties":{ "repo":{"type":"string"},"path":{"type":"string"}, "content":{"type":"string"},"message":{"type":"string"}, "branch":{"type":"string","default":"main"}},"required":["repo","path","content","message"]}}}, {"type":"function","function":{"name":"delete_file", "description":"حذف ملف من الريبو", "parameters":{"type":"object","properties":{ "repo":{"type":"string"},"path":{"type":"string"}, "branch":{"type":"string","default":"main"}},"required":["repo","path"]}}}, {"type":"function","function":{"name":"close_issue", "description":"إغلاق issue", "parameters":{"type":"object","properties":{ "repo":{"type":"string"},"number":{"type":"integer"}},"required":["repo","number"]}}}, {"type":"function","function":{"name":"create_pr", "description":"إنشاء Pull Request جديد", "parameters":{"type":"object","properties":{ "repo":{"type":"string"},"title":{"type":"string"}, "head":{"type":"string"},"base":{"type":"string","default":"main"}, "body":{"type":"string","default":""}},"required":["repo","title","head"]}}}, {"type":"function","function":{"name":"list_workflow_runs", "description":"عرض آخر تشغيلات GitHub Actions", "parameters":{"type":"object","properties":{ "repo":{"type":"string"}},"required":["repo"]}}}, {"type":"function","function":{"name":"search_repos", "description":"البحث في الريبوهات", "parameters":{"type":"object","properties":{ "query":{"type":"string"}},"required":["query"]}}}, {"type":"function","function":{"name":"delete_branch", "description":"حذف branch", "parameters":{"type":"object","properties":{ "repo":{"type":"string"},"branch":{"type":"string"}},"required":["repo","branch"]}}}, ] SYSTEM_PROMPT = f"""أنت مساعد ذكي متخصص في إدارة GitHub للمطور {GH_USER}. تتحدث بالعربية دائماً وتفهم الأوامر العربية والإنجليزية. الريبو الافتراضي هو {GH_REPO}. عند الحاجة لمعلومات من GitHub، استخدم الأدوات المتاحة. قدّم المعلومات بشكل منظم وواضح. أخبر المستخدم بالتطورات والتغييرات المهمة. إذا طُلب منك إنشاء شيء أو تعديل، استخدم الأداة المناسبة.""" _groq_cache: dict[str, Groq] = {} def _groq_client(key: str) -> Groq: """Return a cached Groq client with 30 s timeout (one instance per API key).""" if key not in _groq_cache: _groq_cache[key] = Groq(api_key=key, timeout=30.0) return _groq_cache[key] # ── Multi-provider AI registry ───────────────────────── PROVIDERS: dict[str, dict] = { "groq": { "label": "Groq", "models": ["llama-3.3-70b-versatile", "mixtral-8x7b-32768", "gemma2-9b-it"], "supports_tools": True, "env_key": "GROQ_KEY", "user_key": "groq_key", }, "deepseek": { "label": "DeepSeek", "models": ["deepseek-chat", "deepseek-reasoner"], "supports_tools": True, "env_key": "DEEPSEEK_KEY", "user_key": "deepseek_key", }, "huggingface": { "label": "HuggingFace", "models": [ "humain-ai/ALLaM-7B-Instruct-preview", "Qwen/Qwen2.5-72B-Instruct", "google/gemma-3-27b-it", "mistralai/Mixtral-8x7B-Instruct-v0.1", "deepseek-ai/DeepSeek-R1", ], "supports_tools": False, "env_key": "HF_TOKEN", "user_key": "hf_token", }, "mistral": { "label": "Mistral", "models": ["mistral-large-latest", "mistral-small-latest", "codestral-latest"], "supports_tools": True, "env_key": "MISTRAL_KEY", "user_key": "mistral_key", }, } _openai_cache: dict[str, OpenAI] = {} _hf_cache_dict: dict[str, InferenceClient] = {} def _openai_client(base_url: str, key: str) -> OpenAI: cache_key = f"{base_url}:{key}" if cache_key not in _openai_cache: _openai_cache[cache_key] = OpenAI(api_key=key, base_url=base_url, timeout=30.0) return _openai_cache[cache_key] def _hf_client(key: str) -> InferenceClient: if key not in _hf_cache_dict: _hf_cache_dict[key] = InferenceClient(api_key=key, timeout=30.0) return _hf_cache_dict[key] def _get_ai_key(provider: str, ctx) -> str: env_vals = { "groq": GROQ_KEY, "deepseek": DEEPSEEK_KEY, "huggingface": HF_TOKEN, "mistral": MISTRAL_KEY, } user_key = PROVIDERS.get(provider, {}).get("user_key", "") return env_vals.get(provider, "") or (ctx.user_data.get(user_key, "") if ctx else "") def _execute_tool(gh: "GitHub", name: str, args: dict) -> str: """Execute a GitHub tool call and return JSON string result.""" repo_name = args.get("repo", GH_REPO) if name == "list_repos": repos = gh.list_repos() return json.dumps([{"name": r["name"], "stars": r.get("stargazers_count", 0), "private": r.get("private", False), "updated": r.get("updated_at", "")[:10], "language": r.get("language", "")} for r in repos[:10]]) elif name == "get_repo_info": info = gh.get_repo(repo_name) if not info: return json.dumps({"error": f"repo {repo_name} not found"}) v, cl = gh.get_traffic(repo_name) return json.dumps({"name": repo_name, "stars": info.get("stargazers_count", 0), "forks": info.get("forks_count", 0), "issues": info.get("open_issues_count", 0), "language": info.get("language", ""), "size_kb": info.get("size", 0), "private": info.get("private", False), "views_14d": v.get("count", 0), "clones_14d": cl.get("count", 0)}) elif name == "list_commits": commits = gh.list_commits(repo_name, args.get("branch", "main")) return json.dumps([{"sha": c["sha"][:7], "message": c["commit"]["message"].splitlines()[0], "author": c["commit"]["author"]["name"], "date": c["commit"]["author"]["date"][:10]} for c in commits[:10]]) elif name == "list_files": items = gh.list_files(repo_name, args.get("path", "")) if isinstance(items, list): return json.dumps([{"name": i["name"], "type": i["type"], "size": i.get("size", 0)} for i in items]) return json.dumps({"error": str(items)}) elif name == "list_branches": branches = gh.list_branches(repo_name) return json.dumps([b["name"] for b in branches]) elif name == "list_issues": issues = gh.list_issues(repo_name, args.get("state", "open")) return json.dumps([{"number": i["number"], "title": i["title"], "state": i["state"], "author": i["user"]["login"]} for i in issues[:10]]) elif name == "list_prs": prs = gh.list_prs(repo_name, args.get("state", "open")) return json.dumps([{"number": p["number"], "title": p["title"], "head": p["head"]["ref"], "base": p["base"]["ref"]} for p in prs[:10]]) elif name == "create_branch": ok, _ = gh.create_branch(repo_name, args["name"], args.get("from_branch", "main")) return json.dumps({"success": ok, "branch": args["name"]}) elif name == "create_issue": ok, res = gh.create_issue(repo_name, args["title"], args.get("body", "")) return json.dumps({"success": ok, "number": res.get("number", ""), "title": args["title"]}) elif name == "merge_pr": ok, _ = gh.merge_pr(repo_name, args["pr_number"]) return json.dumps({"success": ok, "pr": args["pr_number"]}) elif name == "create_release": ok, res = gh.create_release(repo_name, args["tag"], args.get("name", ""), args.get("body", "")) return json.dumps({"success": ok, "tag": args["tag"], "url": res.get("html_url", "")}) elif name == "read_file": content, sha = gh.get_file(repo_name, args["path"], args.get("branch", "main")) if content is None: return json.dumps({"error": f"file {args['path']} not found"}) return json.dumps({"path": args["path"], "content": content[:3000], "sha": sha, "truncated": len(content) > 3000}) elif name == "create_file": _, sha = gh.get_file(repo_name, args["path"], args.get("branch", "main")) ok, res = gh.create_or_update_file( repo_name, args["path"], args["content"], args["message"], args.get("branch", "main"), sha) return json.dumps({"success": ok, "path": args["path"]}) elif name == "delete_file": content, sha = gh.get_file(repo_name, args["path"], args.get("branch", "main")) if not sha: return json.dumps({"error": f"file {args['path']} not found"}) ok = gh.delete_file(repo_name, args["path"], f"delete: {args['path']}", sha, args.get("branch", "main")) return json.dumps({"success": ok, "path": args["path"]}) elif name == "close_issue": ok = gh.close_issue(repo_name, args["number"]) return json.dumps({"success": ok, "issue": args["number"]}) elif name == "create_pr": ok, res = gh.create_pr(repo_name, args["title"], args["head"], args.get("base", "main"), args.get("body", "")) return json.dumps({"success": ok, "number": res.get("number", ""), "url": res.get("html_url", "")}) elif name == "list_workflow_runs": runs = gh.list_workflow_runs(repo_name) return json.dumps([{"id": r["id"], "name": r.get("name", ""), "status": r["status"], "conclusion": r.get("conclusion", ""), "branch": r["head_branch"], "created": r["created_at"][:10]} for r in runs[:10]]) elif name == "search_repos": repos = gh.search_repos(args["query"]) return json.dumps([{"name": r["name"], "stars": r.get("stargazers_count", 0), "language": r.get("language", ""), "description": (r.get("description") or "")[:80]} for r in repos[:10]]) elif name == "delete_branch": ok = gh.delete_branch(repo_name, args["branch"]) return json.dumps({"success": ok, "branch": args["branch"]}) log.warning("Unknown tool: %s", name) return json.dumps({"error": f"أداة غير معروفة: {name}"}) def call_groq_with_tools(gh: GitHub, user_message: str, history: list, groq_key: str = "") -> str: key = groq_key or GROQ_KEY if not key: log.warning("call_groq_with_tools: no GROQ_KEY available") return "❌ GROQ_KEY غير موجود — أضفه من قائمة الإعدادات" log.info("Groq call | msg=%.80s | history_len=%d", user_message, len(history)) t0 = time.monotonic() try: client = _groq_client(key) messages = [{"role":"system","content":SYSTEM_PROMPT}] + history[-10:] + \ [{"role":"user","content":user_message}] response = client.chat.completions.create( model="llama-3.3-70b-versatile", messages=messages, tools=TOOLS, tool_choice="auto", max_tokens=2048, ) msg = response.choices[0].message if not msg.tool_calls: elapsed = (time.monotonic() - t0) * 1000 log.info("Groq response (no tools) in %.0f ms | len=%d", elapsed, len(msg.content or "")) return msg.content or "" log.info("Groq requested %d tool(s): %s", len(msg.tool_calls), [tc.function.name for tc in msg.tool_calls]) messages.append({"role":"assistant","content":msg.content or "", "tool_calls":[{"id":tc.id,"type":"function", "function":{"name":tc.function.name, "arguments":tc.function.arguments}} for tc in msg.tool_calls]}) for tc in msg.tool_calls: name = tc.function.name try: args = json.loads(tc.function.arguments) except json.JSONDecodeError as e: log.warning("Failed to parse tool args for %s: %s | raw=%s", name, e, tc.function.arguments[:200]) args = {} result = _execute_tool(gh, name, args) log.debug("Tool %s → result_len=%d", name, len(result)) messages.append({"role":"tool","tool_call_id":tc.id,"content":result}) final = client.chat.completions.create( model="llama-3.3-70b-versatile", messages=messages, max_tokens=2048, ) elapsed = (time.monotonic() - t0) * 1000 content = final.choices[0].message.content or "" log.info("Groq final response in %.0f ms | len=%d", elapsed, len(content)) return content except Exception as e: log.error("call_groq_with_tools exception: %s\n%s", e, traceback.format_exc()) return f"❌ خطأ في الاتصال بـ Groq: {e}" def _call_openai_compat(gh: "GitHub", user_message: str, history: list, client: OpenAI, model: str, supports_tools: bool = True) -> str: log.info("AI call | model=%s | msg=%.80s", model, user_message) t0 = time.monotonic() try: messages = [{"role": "system", "content": SYSTEM_PROMPT}] + history[-10:] + \ [{"role": "user", "content": user_message}] kwargs: dict = {"model": model, "messages": messages, "max_tokens": 2048} if supports_tools: kwargs["tools"] = TOOLS kwargs["tool_choice"] = "auto" response = client.chat.completions.create(**kwargs) msg = response.choices[0].message if not supports_tools or not msg.tool_calls: elapsed = (time.monotonic() - t0) * 1000 log.info("AI response in %.0f ms", elapsed) return msg.content or "" log.info("AI requested %d tool(s)", len(msg.tool_calls)) messages.append({"role": "assistant", "content": msg.content or "", "tool_calls": [{"id": tc.id, "type": "function", "function": {"name": tc.function.name, "arguments": tc.function.arguments}} for tc in msg.tool_calls]}) for tc in msg.tool_calls: try: args = json.loads(tc.function.arguments) except json.JSONDecodeError: args = {} result = _execute_tool(gh, tc.function.name, args) log.debug("Tool %s → len=%d", tc.function.name, len(result)) messages.append({"role": "tool", "tool_call_id": tc.id, "content": result}) final = client.chat.completions.create(model=model, messages=messages, max_tokens=2048) elapsed = (time.monotonic() - t0) * 1000 content = final.choices[0].message.content or "" log.info("AI final response in %.0f ms | len=%d", elapsed, len(content)) return content except Exception as e: log.error("AI call exception: %s\n%s", e, traceback.format_exc()) return f"❌ خطأ في الاتصال بـ AI: {e}" def _call_hf(user_message: str, history: list, client: InferenceClient, model: str) -> str: log.info("HF call | model=%s | msg=%.80s", model, user_message) t0 = time.monotonic() try: messages = [{"role": "system", "content": SYSTEM_PROMPT}] + history[-10:] + \ [{"role": "user", "content": user_message}] response = client.chat.completions.create(model=model, messages=messages, max_tokens=2048) elapsed = (time.monotonic() - t0) * 1000 content = response.choices[0].message.content or "" log.info("HF response in %.0f ms | len=%d", elapsed, len(content)) return content except Exception as e: log.error("HF call exception: %s\n%s", e, traceback.format_exc()) return f"❌ خطأ في الاتصال بـ HuggingFace: {e}" def call_ai_with_tools(gh: "GitHub", user_message: str, history: list, provider: str, model: str, key: str) -> str: """Unified AI router — dispatches to the right provider.""" if not key: prov_label = PROVIDERS.get(provider, {}).get("label", provider) return f"❌ {prov_label} Key غير موجود — أضفه من ⚙️ الإعدادات" if provider == "groq": return call_groq_with_tools(gh, user_message, history, key) elif provider == "deepseek": client = _openai_client("https://api.deepseek.com", key) return _call_openai_compat(gh, user_message, history, client, model, True) elif provider == "mistral": client = _openai_client("https://api.mistral.ai/v1", key) return _call_openai_compat(gh, user_message, history, client, model, True) elif provider == "huggingface": return _call_hf(user_message, history, _hf_client(key), model) log.warning("Unknown provider: %s", provider) return "❌ مزود AI غير معروف" # ══════════════════════════════════════════════════════ # Helpers # ══════════════════════════════════════════════════════ _gh_cache: dict[str, GitHub] = {} def gh(ctx=None) -> GitHub: token = GH_TOKEN or (ctx.user_data.get("gh_token", "") if ctx else "") if token not in _gh_cache: _gh_cache[token] = GitHub(token, GH_USER) return _gh_cache[token] def repo(ctx) -> str: return ctx.user_data.get("current_repo", GH_REPO) def fmt_date(s): try: return datetime.fromisoformat(s.replace("Z","+00:00")).strftime("%Y-%m-%d") except: return s[:10] if s else "" def admin_only(func): @wraps(func) async def wrapper(update: Update, ctx: ContextTypes.DEFAULT_TYPE): if update.effective_user.id != ADMIN_ID: await update.effective_message.reply_text("❌ غير مصرح.") return return await func(update, ctx) return wrapper async def _safe_send(target, text: str, kb=None, parse_mode="Markdown"): """Send/edit a message with Markdown fallback to plain text.""" method = target.reply_text try: await method(text[:4000], parse_mode=parse_mode, reply_markup=kb) except Exception: try: await method(text[:4000], reply_markup=kb) except Exception as e: log.error("_safe_send failed: %s", e) # ══════════════════════════════════════════════════════ # Keyboards # ══════════════════════════════════════════════════════ def main_kb(r=""): r = r or GH_REPO return InlineKeyboardMarkup([ [InlineKeyboardButton(f"📂 {r}", callback_data="repos"), InlineKeyboardButton("🔄 تبديل", callback_data="switch_repo")], [InlineKeyboardButton("📁 الملفات", callback_data="files"), InlineKeyboardButton("📋 Commits", callback_data="commits")], [InlineKeyboardButton("🌿 Branches", callback_data="branches"), InlineKeyboardButton("🔀 Pull Requests", callback_data="prs")], [InlineKeyboardButton("🔖 Issues", callback_data="issues"), InlineKeyboardButton("🚀 Releases", callback_data="releases")], [InlineKeyboardButton("⚡ Actions", callback_data="actions"), InlineKeyboardButton("📊 إحصائيات",callback_data="stats")], [InlineKeyboardButton("📤 رفع ملفات",callback_data="upload_files"), InlineKeyboardButton("🔍 بحث", callback_data="search")], [InlineKeyboardButton("➕ ملف جديد", callback_data="new_file"), InlineKeyboardButton("🌿 Branch جديد",callback_data="new_branch")], [InlineKeyboardButton("🔖 Issue جديد",callback_data="new_issue"), InlineKeyboardButton("🚀 Release جديد",callback_data="new_release")], [InlineKeyboardButton("🤖 مساعد AI", callback_data="ai_chat"), InlineKeyboardButton("⚙️ إعدادات", callback_data="settings")], ]) def settings_kb(gh_ok=False, ai_ok=False, ai_provider="groq"): gh_lbl = f"🔑 GitHub PAT {'✅' if gh_ok else '❌'}" ai_lbl = f"🤖 AI Key {'✅' if ai_ok else '❌'}" prov_lbl = PROVIDERS.get(ai_provider, PROVIDERS["groq"])["label"] return InlineKeyboardMarkup([ [InlineKeyboardButton(gh_lbl, callback_data="set_pat"), InlineKeyboardButton(ai_lbl, callback_data="set_ai_key")], [InlineKeyboardButton(f"🔄 AI: {prov_lbl}", callback_data="set_ai_provider")], [InlineKeyboardButton("📦 ريبو جديد", callback_data="new_repo")], [InlineKeyboardButton("◀️ الرئيسية", callback_data="menu")], ]) def ai_provider_kb(current="groq"): rows = [] for pid, pinfo in PROVIDERS.items(): mark = "✅ " if pid == current else "" rows.append([InlineKeyboardButton(f"{mark}{pinfo['label']}", callback_data=f"set_provider_{pid}")]) rows.append([InlineKeyboardButton("◀️ الرئيسية", callback_data="menu")]) return InlineKeyboardMarkup(rows) def ai_model_kb(provider: str, current_model: str = ""): models = PROVIDERS.get(provider, PROVIDERS["groq"])["models"] rows = [] for i, m in enumerate(models): label = m.split("/")[-1] mark = "✅ " if m == current_model else "" rows.append([InlineKeyboardButton(f"{mark}{label}", callback_data=f"set_model_{i}")]) rows.append([InlineKeyboardButton("◀️ الرئيسية", callback_data="menu")]) return InlineKeyboardMarkup(rows) def back_kb(): return InlineKeyboardMarkup([[InlineKeyboardButton("◀️ الرئيسية", callback_data="menu")]]) def cancel_kb(): return InlineKeyboardMarkup([[InlineKeyboardButton("❌ إلغاء", callback_data="menu")]]) def ai_kb(): return InlineKeyboardMarkup([ [InlineKeyboardButton("📊 اطلعني على آخر التطورات", callback_data="ai_updates")], [InlineKeyboardButton("📋 ملخص الريبو", callback_data="ai_summary")], [InlineKeyboardButton("🐛 Issues المفتوحة",callback_data="ai_issues")], [InlineKeyboardButton("🔀 PRs المعلقة", callback_data="ai_prs")], [InlineKeyboardButton("◀️ الرئيسية", callback_data="menu")], ]) # ══════════════════════════════════════════════════════ # Handlers # ══════════════════════════════════════════════════════ @admin_only async def start(update: Update, ctx: ContextTypes.DEFAULT_TYPE): user_id = update.effective_user.id username = update.effective_user.username or str(user_id) log.info("start | user=%s (id=%d)", username, user_id) g = gh(ctx) profile = await asyncio.to_thread(g.get_profile) name = profile.get("name") or profile.get("login", GH_USER) repos_c = profile.get("public_repos",0) r = repo(ctx) provider = ctx.user_data.get("ai_provider", "groq") model = ctx.user_data.get("ai_model", PROVIDERS[provider]["models"][0]) ai_ok = bool(_get_ai_key(provider, ctx)) ai_status = "✅" if ai_ok else "❌" gh_status = "✅" if GH_TOKEN or ctx.user_data.get("gh_token") else "❌" prov_label = PROVIDERS[provider]["label"] text = (f"👋 مرحباً *{name}*!\n\n" f"📦 Repos: {repos_c}\n" f"📂 الريبو الحالي: `{r}`\n\n" f"GitHub PAT: {gh_status} | AI ({prov_label}): {ai_status}\n" f"🤖 النموذج: `{model.split('/')[-1]}`\n\n" f"💬 يمكنك الكتابة مباشرة للمساعد AI") try: await update.message.reply_text(text, parse_mode="Markdown", reply_markup=main_kb(r)) except Exception: await update.message.reply_text(text, reply_markup=main_kb(r)) @admin_only async def help_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE): """Show available commands and features.""" text = ( "📖 *المساعدة — GitHub Manager Bot*\n\n" "*الأوامر:*\n" "/start — القائمة الرئيسية\n" "/help — هذه الرسالة\n" "/cancel — إلغاء العملية الحالية\n\n" "*الميزات:*\n" "📁 *الملفات* — تصفح، عرض، تعديل، حذف، رفع\n" "📋 *Commits* — عرض آخر commits مع التفاصيل\n" "🌿 *Branches* — عرض، إنشاء، حذف branches\n" "🔀 *PRs* — عرض، إنشاء، دمج Pull Requests\n" "🔖 *Issues* — عرض، إنشاء، إغلاق Issues\n" "🚀 *Releases* — عرض وإنشاء releases\n" "⚡ *Actions* — عرض GitHub Actions وإعادة تشغيلها\n" "📊 *إحصائيات* — Stars, forks, traffic\n" "🔍 *بحث* — البحث في الريبوهات\n" "📤 *رفع ملفات* — رفع ملفات متعددة بـ JSON\n\n" "*مساعد AI:*\n" "💬 اكتب أي سؤال مباشرة وسيرد المساعد\n" "🎤 أرسل رسالة صوتية للتفاعل بالصوت\n" "🤖 يدعم: Groq, DeepSeek, HuggingFace, Mistral\n\n" "⚙️ *الإعدادات* — تغيير PAT, AI Key, المزود, النموذج" ) await _safe_send(update.message, text, main_kb(repo(ctx))) @admin_only async def handle_text(update: Update, ctx: ContextTypes.DEFAULT_TYPE): """معالجة الرسائل النصية — إما AI أو في محادثة""" text = update.message.text.strip() user_id = update.effective_user.id username = update.effective_user.username or str(user_id) log.info("handle_text | user=%s | msg=%.100s", username, text) g = gh(ctx) r = repo(ctx) history = ctx.user_data.setdefault("ai_history", []) history.append({"role":"user","content":text}) await update.message.reply_text("🤔 جاري المعالجة...") provider = ctx.user_data.get("ai_provider", "groq") model = ctx.user_data.get("ai_model", PROVIDERS[provider]["models"][0]) ai_key = _get_ai_key(provider, ctx) try: # Run blocking I/O in thread pool to avoid blocking the event loop result = await asyncio.to_thread( call_ai_with_tools, g, text, history[:-1], provider, model, ai_key ) except Exception as e: log.error("handle_text ai error for user=%s: %s\n%s", username, e, traceback.format_exc()) result = f"❌ خطأ: {e}" history.append({"role":"assistant","content":result}) if len(history) > 20: ctx.user_data["ai_history"] = history[-20:] try: await update.message.reply_text(result[:4000], parse_mode="Markdown", reply_markup=main_kb(r)) except Exception: await update.message.reply_text(result[:4000], reply_markup=main_kb(r)) @admin_only async def handle_voice(update: Update, ctx: ContextTypes.DEFAULT_TYPE): """Transcribe incoming voice message via Groq Whisper then reply with text + TTS audio.""" username = update.effective_user.username or str(update.effective_user.id) log.info("handle_voice | user=%s | duration=%ds", username, update.message.voice.duration) groq_key = GROQ_KEY or ctx.user_data.get("groq_key", "") if not groq_key: await update.message.reply_text("❌ GROQ_KEY مطلوب للتفريغ الصوتي — أضفه من ⚙️ الإعدادات") return await update.message.reply_chat_action("typing") # Download Telegram voice file (OGG/Opus) into memory tg_file = await ctx.bot.get_file(update.message.voice.file_id) ogg_buf = io.BytesIO() await tg_file.download_to_memory(ogg_buf) ogg_buf.seek(0) # Transcribe with Groq Whisper try: client = _groq_client(groq_key) transcription = await asyncio.to_thread( lambda: client.audio.transcriptions.create( file=("voice.ogg", ogg_buf, "audio/ogg"), model="whisper-large-v3-turbo", ) ) text = transcription.text.strip() except Exception as e: log.error("Whisper error: %s\n%s", e, traceback.format_exc()) await update.message.reply_text(f"❌ خطأ في التفريغ الصوتي: {e}") return if not text: await update.message.reply_text("❌ لم أتمكن من فهم الرسالة الصوتية") return await update.message.reply_text(f"🎤 _{text}_", parse_mode="Markdown") # Route transcribed text through the active AI provider history = ctx.user_data.setdefault("ai_history", []) history.append({"role": "user", "content": text}) provider = ctx.user_data.get("ai_provider", "groq") model = ctx.user_data.get("ai_model", PROVIDERS[provider]["models"][0]) ai_key = _get_ai_key(provider, ctx) g = gh(ctx) r = repo(ctx) await update.message.reply_chat_action("typing") try: result = await asyncio.to_thread( call_ai_with_tools, g, text, history[:-1], provider, model, ai_key ) except Exception as e: log.error("handle_voice ai error: %s\n%s", e, traceback.format_exc()) result = f"❌ خطأ: {e}" history.append({"role": "assistant", "content": result}) ctx.user_data["ai_history"] = history[-20:] # Text reply try: await update.message.reply_text(result[:4000], parse_mode="Markdown", reply_markup=main_kb(r)) except Exception: await update.message.reply_text(result[:4000], reply_markup=main_kb(r)) # Voice reply via Groq PlayAI TTS (best-effort — non-critical) await update.message.reply_chat_action("record_voice") try: tts_resp = await asyncio.to_thread( lambda: client.audio.speech.create( model="playai-tts-arabic", voice="Ahmad", input=result[:1000], # TTS is best kept concise response_format="mp3", ) ) mp3_buf = io.BytesIO(tts_resp.read()) mp3_buf.name = "reply.mp3" await update.message.reply_audio(mp3_buf, title="رد صوتي") except Exception as e: log.warning("TTS skipped (non-critical): %s", e) @admin_only async def button(update: Update, ctx: ContextTypes.DEFAULT_TYPE): q = update.callback_query await q.answer() data = q.data user_id = update.effective_user.id username = update.effective_user.username or str(user_id) log.info("button | user=%s | action=%s | repo=%s", username, data, ctx.user_data.get("current_repo", GH_REPO)) g = gh(ctx) r = repo(ctx) async def edit(text, kb=None, md=True): try: await q.message.edit_text(text[:4000], parse_mode="Markdown" if md else None, reply_markup=kb or back_kb()) except Exception as e: log.warning("edit Markdown failed (%s), retrying plain", e) try: await q.message.edit_text(text[:4000], reply_markup=kb or back_kb()) except Exception as e2: log.error("edit plain also failed: %s", e2) # ── Menu ────────────────────────────────────────── if data == "menu": profile = await asyncio.to_thread(g.get_profile) name = profile.get("name") or GH_USER await edit(f"👋 {name}\n📂 `{r}`", main_kb(r)) # ── AI Chat ─────────────────────────────────────── elif data == "ai_chat": await edit( "🤖 *مساعد AI*\n\nيمكنك الكتابة مباشرة أو اختيار من الأسفل:", ai_kb() ) elif data == "ai_updates": await edit("⏳ جاري جلب آخر التطورات...") provider = ctx.user_data.get("ai_provider", "groq") model = ctx.user_data.get("ai_model", PROVIDERS[provider]["models"][0]) ai_key = _get_ai_key(provider, ctx) try: result = await asyncio.to_thread( call_ai_with_tools, g, f"اطلعني على آخر التطورات في ريبو {r}: آخر commits، issues مفتوحة، PRs معلقة", [], provider, model, ai_key ) except Exception as e: log.error("ai_updates error: %s\n%s", e, traceback.format_exc()) result = f"❌ خطأ: {e}" await edit(result[:4000] or "لا توجد تطورات", ai_kb()) elif data == "ai_summary": await edit("⏳ جاري إعداد الملخص...") provider = ctx.user_data.get("ai_provider", "groq") model = ctx.user_data.get("ai_model", PROVIDERS[provider]["models"][0]) ai_key = _get_ai_key(provider, ctx) try: result = await asyncio.to_thread( call_ai_with_tools, g, f"قدّم ملخصاً شاملاً لريبو {r}: الإحصائيات، اللغة، الحجم، آخر تحديث", [], provider, model, ai_key ) except Exception as e: log.error("ai_summary error: %s\n%s", e, traceback.format_exc()) result = f"❌ خطأ: {e}" await edit(result[:4000] or "❌ تعذر جلب المعلومات", ai_kb()) elif data == "ai_issues": await edit("⏳ جاري جلب Issues...") provider = ctx.user_data.get("ai_provider", "groq") model = ctx.user_data.get("ai_model", PROVIDERS[provider]["models"][0]) ai_key = _get_ai_key(provider, ctx) try: result = await asyncio.to_thread( call_ai_with_tools, g, f"اعرض وحلّل Issues المفتوحة في {r} وأعطني رأيك فيها", [], provider, model, ai_key ) except Exception as e: log.error("ai_issues error: %s\n%s", e, traceback.format_exc()) result = f"❌ خطأ: {e}" await edit(result[:4000] or "✅ لا توجد issues", ai_kb()) elif data == "ai_prs": await edit("⏳ جاري جلب PRs...") provider = ctx.user_data.get("ai_provider", "groq") model = ctx.user_data.get("ai_model", PROVIDERS[provider]["models"][0]) ai_key = _get_ai_key(provider, ctx) try: result = await asyncio.to_thread( call_ai_with_tools, g, f"اعرض Pull Requests المفتوحة في {r}", [], provider, model, ai_key ) except Exception as e: log.error("ai_prs error: %s\n%s", e, traceback.format_exc()) result = f"❌ خطأ: {e}" await edit(result[:4000] or "✅ لا توجد PRs", ai_kb()) # ── Settings ────────────────────────────────────── elif data == "settings": gh_ok = bool(GH_TOKEN or ctx.user_data.get("gh_token")) provider = ctx.user_data.get("ai_provider", "groq") ai_ok = bool(_get_ai_key(provider, ctx)) await edit("⚙️ *الإعدادات*", settings_kb(gh_ok, ai_ok, provider)) elif data == "set_pat": await edit("🔑 أرسل GitHub PAT:\n\n" "github.com → Settings → Developer Settings\n" "→ Personal Access Tokens → Generate\n" "→ Scope: `repo`", cancel_kb()) return WAIT_PAT elif data == "set_groq": await edit("🤖 أرسل Groq API Key:\n\nconsole.groq.com → API Keys → Create", cancel_kb()) return WAIT_GROQ_KEY elif data == "set_ai_key": provider = ctx.user_data.get("ai_provider", "groq") prov = PROVIDERS.get(provider, PROVIDERS["groq"]) urls = { "groq": "console.groq.com → API Keys", "deepseek": "platform.deepseek.com → API Keys", "huggingface": "huggingface.co/settings/tokens", "mistral": "console.mistral.ai → API Keys", } await edit(f"🔑 أرسل API Key لـ *{prov['label']}*:\n\n{urls.get(provider, '')}", cancel_kb()) return WAIT_AI_KEY elif data == "set_ai_provider": provider = ctx.user_data.get("ai_provider", "groq") await edit("🤖 *اختر مزود AI:*", ai_provider_kb(provider)) elif data.startswith("set_provider_"): pid = data[len("set_provider_"):] if pid in PROVIDERS: ctx.user_data["ai_provider"] = pid ctx.user_data["ai_model"] = PROVIDERS[pid]["models"][0] prov = PROVIDERS[pid] current_model = PROVIDERS[pid]["models"][0] await edit(f"✅ تم اختيار *{prov['label']}*\n\nاختر النموذج:", ai_model_kb(pid, current_model)) elif data == "set_ai_model": provider = ctx.user_data.get("ai_provider", "groq") current_model = ctx.user_data.get("ai_model", PROVIDERS[provider]["models"][0]) await edit("🤖 *اختر النموذج:*", ai_model_kb(provider, current_model)) elif data.startswith("set_model_"): provider = ctx.user_data.get("ai_provider", "groq") try: idx = int(data[len("set_model_"):]) models = PROVIDERS[provider]["models"] if 0 <= idx < len(models): ctx.user_data["ai_model"] = models[idx] await edit(f"✅ تم اختيار النموذج: `{models[idx].split('/')[-1]}`", back_kb()) except (ValueError, IndexError): pass # ── Repos ───────────────────────────────────────── elif data == "repos": repos = await asyncio.to_thread(g.list_repos) if not repos: await edit("❌ لا توجد ريبوهات أو خطأ في الـ PAT"); return lines = [] for rp in repos[:15]: vis = "🔒" if rp.get("private") else "🌐" stars = rp.get("stargazers_count",0) lang = rp.get("language") or "—" updated = fmt_date(rp.get("updated_at","")) lines.append(f"{vis} `{rp['name']}` ⭐{stars} {lang}\n 📅 {updated}") total = len(repos) kb = InlineKeyboardMarkup([ [InlineKeyboardButton("➕ ريبو جديد", callback_data="new_repo")], [InlineKeyboardButton("◀️ الرئيسية", callback_data="menu")], ]) suffix = f"\n_عرض {min(15,total)} من {total}_" if total > 15 else "" await edit(f"📦 *ريبوهاتك ({total}):*\n\n" + "\n\n".join(lines) + suffix, kb) elif data == "switch_repo": repos = await asyncio.to_thread(g.list_repos) btns = [[InlineKeyboardButton(f"📂 {rp['name']}", callback_data=f"use_{rp['name']}")] for rp in repos[:10]] btns.append([InlineKeyboardButton("◀️ الرئيسية", callback_data="menu")]) await edit("اختر الريبو:", InlineKeyboardMarkup(btns)) elif data.startswith("use_"): nr = data[4:] ctx.user_data["current_repo"] = nr await edit(f"✅ تم التبديل لـ `{nr}`", main_kb(nr)) # ── Files ───────────────────────────────────────── elif data == "files" or data.startswith("ls_"): path = data[3:] if data.startswith("ls_") else "" items = await asyncio.to_thread(g.list_files, r, path) if isinstance(items, dict): await edit(f"❌ {items.get('message','خطأ')}"); return dirs_ = [i for i in items if i.get("type")=="dir"] files_ = [i for i in items if i.get("type")=="file"] btns = [] for d in dirs_[:8]: np = f"{path}/{d['name']}" if path else d["name"] btns.append([InlineKeyboardButton(f"📁 {d['name']}/", callback_data=f"ls_{np}")]) for f_ in files_[:12]: fp = f"{path}/{f_['name']}" if path else f_["name"] sz = f_["size"] s = f"{sz}B" if sz < 1024 else f"{sz/1024:.1f}KB" btns.append([InlineKeyboardButton(f"📄 {f_['name']} ({s})", callback_data=f"view_{fp}")]) if path: parent = "/".join(path.split("/")[:-1]) btns.append([InlineKeyboardButton(f"📁 .. ({parent or r})", callback_data=f"ls_{parent}" if parent else "files")]) btns.append([InlineKeyboardButton("◀️ الرئيسية", callback_data="menu")]) loc = f"`{r}/{path}`" if path else f"`{r}`" await edit(f"📁 {loc}\n{len(dirs_)} مجلد، {len(files_)} ملف", InlineKeyboardMarkup(btns)) elif data.startswith("view_"): path = data[5:] content, sha = await asyncio.to_thread(g.get_file, r, path) if content is None: await edit("❌ تعذر قراءة الملف"); return ctx.user_data["edit_path"] = path ctx.user_data["edit_sha"] = sha lines = content.splitlines() preview = "\n".join(lines[:30]) if len(lines) > 30: preview += f"\n... ({len(lines)-30} سطر إضافي)" kb = InlineKeyboardMarkup([ [InlineKeyboardButton("✏️ تعديل", callback_data="edit_file"), InlineKeyboardButton("🗑️ حذف", callback_data=f"confirm_del_{path}")], [InlineKeyboardButton("📥 تحميل", callback_data=f"dl_{path}")], [InlineKeyboardButton("◀️ الملفات", callback_data="files")], ]) await edit(f"📄 `{path}`\n```\n{preview[:3000]}\n```", kb) elif data == "edit_file": path = ctx.user_data.get("edit_path","") await edit(f"✏️ أرسل المحتوى الجديد لـ `{path}`:", cancel_kb()) return WAIT_FILE_CONTENT elif data.startswith("confirm_del_"): path = data[12:] kb = InlineKeyboardMarkup([ [InlineKeyboardButton("✅ نعم، احذف", callback_data="delete_file"), InlineKeyboardButton("❌ إلغاء", callback_data=f"view_{path}")], ]) await edit(f"🗑️ تأكيد حذف `{path}`؟\nهذا الإجراء لا يمكن التراجع عنه.", kb) elif data == "delete_file": path = ctx.user_data.get("edit_path","") sha = ctx.user_data.get("edit_sha","") log.info("delete_file | user=%s | path=%s | repo=%s", username, path, r) ok = await asyncio.to_thread(g.delete_file, r, path, f"delete: {path}", sha) await edit(f"{'✅ تم حذف' if ok else '❌ فشل حذف'} `{path}`", back_kb()) # ── Commits ─────────────────────────────────────── elif data == "commits": commits = await asyncio.to_thread(g.list_commits, r) if not commits: await edit("❌ لا توجد commits"); return lines = [] btns = [] for c in commits[:10]: msg_ = c["commit"]["message"].splitlines()[0][:50] author = c["commit"]["author"]["name"][:15] date_ = fmt_date(c["commit"]["author"]["date"]) sha_ = c["sha"][:7] lines.append(f"`{sha_}` {msg_}\n 👤 {author} 📅 {date_}") btns.append([InlineKeyboardButton( f"📋 {sha_}: {msg_[:20]}", callback_data=f"diff_{c['sha']}")]) btns.append([InlineKeyboardButton("◀️ الرئيسية", callback_data="menu")]) await edit(f"📋 *Commits في `{r}`:*\n\n" + "\n\n".join(lines), InlineKeyboardMarkup(btns)) # ── Branches ────────────────────────────────────── elif data == "branches": branches = await asyncio.to_thread(g.list_branches, r) if not branches: await edit("❌ لا توجد branches"); return lines = [f"🌿 `{b['name']}`" for b in branches] btns = [] for b in branches: if b["name"] not in ("main", "master"): btns.append([InlineKeyboardButton( f"🗑️ حذف {b['name']}", callback_data=f"del_branch_{b['name']}")]) btns.append([InlineKeyboardButton("➕ Branch جديد", callback_data="new_branch")]) btns.append([InlineKeyboardButton("◀️ الرئيسية", callback_data="menu")]) await edit(f"🌿 *Branches في `{r}`:*\n\n" + "\n".join(lines), InlineKeyboardMarkup(btns)) # ── Pull Requests ───────────────────────────────── elif data == "prs": prs = await asyncio.to_thread(g.list_prs, r) if not prs: await edit("✅ لا توجد Pull Requests مفتوحة"); return btns = [] lines = [] for p in prs[:10]: lines.append(f"#{p['number']} `{p['title'][:45]}`\n" f" `{p['head']['ref']}` → `{p['base']['ref']}`") btns.append([InlineKeyboardButton( f"🔀 Merge #{p['number']}", callback_data=f"merge_{p['number']}")]) btns.append([InlineKeyboardButton("➕ PR جديد", callback_data="new_pr")]) btns.append([InlineKeyboardButton("◀️ الرئيسية", callback_data="menu")]) await edit(f"🔀 *Pull Requests في `{r}`:*\n\n" + "\n\n".join(lines), InlineKeyboardMarkup(btns)) elif data.startswith("merge_"): pr_num = int(data[6:]) await edit(f"⏳ جاري دمج PR #{pr_num}...") ok, res = await asyncio.to_thread(g.merge_pr, r, pr_num) if ok: await edit(f"✅ تم دمج PR #{pr_num} بنجاح! 🎉", main_kb(r)) else: await edit(f"❌ فشل: {res.get('message','')}") elif data == "new_pr": branches = await asyncio.to_thread(g.list_branches, r) non_main = [b["name"] for b in branches if b["name"] not in ("main", "master")] if not non_main: await edit("❌ لا توجد branches غير main — أنشئ branch أولاً", back_kb()) return btns = [[InlineKeyboardButton(f"🌿 {b}", callback_data=f"pr_head_{b}")] for b in non_main[:8]] btns.append([InlineKeyboardButton("❌ إلغاء", callback_data="menu")]) await edit("🔀 *اختر Branch المصدر (head):*", InlineKeyboardMarkup(btns)) elif data.startswith("pr_head_"): head = data[8:] ctx.user_data["pr_head"] = head await edit(f"🔀 Branch المصدر: `{head}`\n\nأرسل عنوان الـ PR:", cancel_kb()) return WAIT_PR_TITLE # ── Issues ──────────────────────────────────────── elif data == "issues": issues = await asyncio.to_thread(g.list_issues, r) if not issues: await edit("✅ لا توجد issues مفتوحة"); return btns = [] lines = [] for i in issues[:10]: lines.append(f"#{i['number']} `{i['title'][:50]}`") btns.append([InlineKeyboardButton( f"✅ إغلاق #{i['number']}", callback_data=f"confirm_close_{i['number']}")]) btns.append([InlineKeyboardButton("➕ Issue جديد", callback_data="new_issue")]) btns.append([InlineKeyboardButton("◀️ الرئيسية", callback_data="menu")]) await edit(f"🔖 *Issues في `{r}`:*\n\n" + "\n".join(lines), InlineKeyboardMarkup(btns)) elif data.startswith("confirm_close_"): num = int(data[14:]) kb = InlineKeyboardMarkup([ [InlineKeyboardButton("✅ نعم، أغلق", callback_data=f"close_issue_{num}"), InlineKeyboardButton("❌ إلغاء", callback_data="issues")], ]) await edit(f"🔖 تأكيد إغلاق Issue #{num}؟", kb) elif data.startswith("close_issue_"): num = int(data[12:]) log.info("close_issue | user=%s | issue=%d | repo=%s", username, num, r) ok = await asyncio.to_thread(g.close_issue, r, num) await edit(f"{'✅ تم إغلاق' if ok else '❌ فشل إغلاق'} Issue #{num}", back_kb()) # ── Releases ────────────────────────────────────── elif data == "releases": releases = await asyncio.to_thread(g.list_releases, r) if not releases: await edit("📦 لا توجد releases بعد"); return lines = [] for rel in releases: tag = rel["tag_name"] date_ = fmt_date(rel.get("published_at","")) lines.append(f"🏷️ `{tag}` 📅 {date_}") kb = InlineKeyboardMarkup([ [InlineKeyboardButton("➕ Release جديد", callback_data="new_release")], [InlineKeyboardButton("◀️ الرئيسية", callback_data="menu")], ]) await edit(f"🚀 *Releases في `{r}`:*\n\n" + "\n".join(lines), kb) # ── Stats ───────────────────────────────────────── elif data == "stats": await edit("⏳ جاري جلب الإحصائيات...") info, (v, cl) = await asyncio.gather( asyncio.to_thread(g.get_repo, r), asyncio.to_thread(g.get_traffic, r), ) if not info: await edit("❌ تعذر جلب المعلومات"); return text = (f"📊 *{r}*\n\n" f"⭐ Stars: {info.get('stargazers_count',0)}\n" f"🍴 Forks: {info.get('forks_count',0)}\n" f"👁️ Watchers: {info.get('watchers_count',0)}\n" f"🐛 Issues: {info.get('open_issues_count',0)}\n" f"🔤 Language: {info.get('language','—')}\n" f"📦 Size: {info.get('size',0)} KB\n" f"🔒 Private: {'نعم' if info.get('private') else 'لا'}\n" f"📅 Created: {fmt_date(info.get('created_at',''))}\n" f"🔄 Updated: {fmt_date(info.get('updated_at',''))}\n\n" f"👀 Views (14d): {v.get('count',0)}\n" f"📥 Clones (14d): {cl.get('count',0)}") await edit(text) # ── Actions / Workflows ───────────────────────────── elif data == "actions": await edit("⏳ جاري جلب GitHub Actions...") runs = await asyncio.to_thread(g.list_workflow_runs, r) if not runs: await edit("✅ لا توجد تشغيلات Actions بعد", back_kb()); return status_icons = { "success": "✅", "failure": "❌", "cancelled": "⚪", "in_progress": "🔄", "queued": "⏳", } lines = [] btns = [] for run in runs[:10]: icon = status_icons.get(run.get("conclusion") or run["status"], "❓") name = run.get("name", "workflow")[:30] branch = run.get("head_branch", "")[:15] date_ = fmt_date(run.get("created_at", "")) lines.append(f"{icon} `{name}`\n 🌿 {branch} 📅 {date_}") if run.get("conclusion") == "failure": btns.append([InlineKeyboardButton( f"🔄 Re-run: {name[:20]}", callback_data=f"rerun_{run['id']}")]) btns.append([InlineKeyboardButton("◀️ الرئيسية", callback_data="menu")]) await edit(f"⚡ *GitHub Actions في `{r}`:*\n\n" + "\n\n".join(lines), InlineKeyboardMarkup(btns)) elif data.startswith("rerun_"): run_id = int(data[6:]) await edit(f"🔄 جاري إعادة تشغيل run #{run_id}...") ok = await asyncio.to_thread(g.rerun_workflow, r, run_id) if ok: await edit(f"✅ تم إعادة تشغيل run #{run_id}", back_kb()) else: await edit(f"❌ فشل إعادة التشغيل — تحقق من الصلاحيات", back_kb()) # ── Search ───────────────────────────────────────── elif data == "search": await edit("🔍 أرسل كلمة البحث:", cancel_kb()) return WAIT_SEARCH_QUERY # ── Commit Diff ──────────────────────────────────── elif data.startswith("diff_"): sha = data[5:] await edit("⏳ جاري جلب التفاصيل...") commit = await asyncio.to_thread(g.get_commit, r, sha) if not commit: await edit("❌ تعذر جلب الـ commit", back_kb()); return msg_ = commit["commit"]["message"] author = commit["commit"]["author"]["name"] date_ = fmt_date(commit["commit"]["author"]["date"]) stats = commit.get("stats", {}) files_changed = commit.get("files", []) diff_lines = [] for f_ in files_changed[:15]: status_map = {"added": "➕", "removed": "➖", "modified": "✏️", "renamed": "📝"} icon = status_map.get(f_["status"], "📄") diff_lines.append(f"{icon} `{f_['filename'][:50]}` +{f_.get('additions',0)} -{f_.get('deletions',0)}") text = (f"📋 *Commit `{sha[:7]}`*\n\n" f"💬 {msg_}\n" f"👤 {author} 📅 {date_}\n\n" f"📊 +{stats.get('additions',0)} -{stats.get('deletions',0)} " f"({len(files_changed)} files)\n\n" + "\n".join(diff_lines)) await edit(text) # ── File Download ────────────────────────────────── elif data.startswith("dl_"): path = data[3:] content, _ = await asyncio.to_thread(g.get_file, r, path) if content is None: await edit("❌ تعذر تحميل الملف"); return doc = io.BytesIO(content.encode("utf-8")) doc.name = path.split("/")[-1] await q.message.reply_document(doc, caption=f"📄 {path}") # ── Delete Branch ────────────────────────────────── elif data.startswith("del_branch_"): branch = data[11:] kb = InlineKeyboardMarkup([ [InlineKeyboardButton("✅ نعم، احذف", callback_data=f"delbr_yes_{branch}"), InlineKeyboardButton("❌ إلغاء", callback_data="branches")], ]) await edit(f"🗑️ تأكيد حذف Branch `{branch}`؟", kb) elif data.startswith("delbr_yes_"): branch = data[10:] log.info("delete_branch | user=%s | branch=%s | repo=%s", username, branch, r) ok = await asyncio.to_thread(g.delete_branch, r, branch) await edit(f"{'✅ تم حذف' if ok else '❌ فشل حذف'} Branch `{branch}`", back_kb()) # ── Upload Multiple Files ────────────────────────── elif data == "upload_files": await edit( "📤 *رفع ملفات متعددة (JSON)*\n\n" "أرسل JSON بهذا الشكل:\n" '```\n[{"path":"src/file.py","content":"..."},\n' ' {"path":"README.md","content":"..."}]\n```\n\n' "اضغط إلغاء للرجوع:", cancel_kb() ) return WAIT_MULTI_FILES # ── New actions ──────────────────────────────────── elif data == "new_file": await edit("📄 أرسل مسار الملف:\n_مثال: src/utils.py_", cancel_kb()) return WAIT_FILE_PATH elif data == "new_branch": await edit("🌿 أرسل اسم الـ branch الجديد:", cancel_kb()) return WAIT_BRANCH_NAME elif data == "new_issue": await edit("🔖 أرسل عنوان الـ Issue:", cancel_kb()) return WAIT_ISSUE_TITLE elif data == "new_release": await edit("🚀 أرسل رقم الإصدار:\n_مثال: v1.0.0_", cancel_kb()) return WAIT_RELEASE_TAG elif data == "new_repo": await edit("📦 أرسل اسم الريبو الجديد:", cancel_kb()) return WAIT_REPO_NAME # ══════════════════════════════════════════════════════ # Conversation Receivers # ══════════════════════════════════════════════════════ @admin_only async def recv_pat(update: Update, ctx: ContextTypes.DEFAULT_TYPE): pat = update.message.text.strip() await update.message.delete() ctx.user_data["gh_token"] = pat _persist_env("GH_TOKEN", pat) await update.message.reply_text("✅ تم حفظ GitHub PAT 🔐", reply_markup=main_kb(repo(ctx))) return ConversationHandler.END @admin_only async def recv_groq_key(update: Update, ctx: ContextTypes.DEFAULT_TYPE): key = update.message.text.strip() await update.message.delete() ctx.user_data["groq_key"] = key _persist_env("GROQ_KEY", key) await update.message.reply_text("✅ تم حفظ Groq Key 🤖", reply_markup=main_kb(repo(ctx))) return ConversationHandler.END @admin_only async def recv_ai_key(update: Update, ctx: ContextTypes.DEFAULT_TYPE): key = update.message.text.strip() provider = ctx.user_data.get("ai_provider", "groq") prov = PROVIDERS.get(provider, PROVIDERS["groq"]) ctx.user_data[prov["user_key"]] = key _persist_env(prov["env_key"], key) log.info("recv_ai_key | provider=%s | user=%s", provider, update.effective_user.username or update.effective_user.id) await update.message.reply_text( f"✅ تم حفظ مفتاح *{prov['label']}*", parse_mode="Markdown", reply_markup=back_kb() ) return ConversationHandler.END @admin_only async def recv_repo_name(update: Update, ctx: ContextTypes.DEFAULT_TYPE): name = update.message.text.strip().replace(" ", "-") g = gh(ctx) _, code = await asyncio.to_thread(g.create_repo, name) if code == 201: ctx.user_data["current_repo"] = name log.info("create_repo | user=%s | repo=%s", update.effective_user.username, name) try: await update.message.reply_text(f"✅ تم إنشاء `{name}`!", parse_mode="Markdown", reply_markup=main_kb(name)) except Exception: await update.message.reply_text(f"✅ تم إنشاء {name}!", reply_markup=main_kb(name)) else: await update.message.reply_text("❌ فشل — الاسم مأخوذ أو خطأ في الـ PAT", reply_markup=back_kb()) return ConversationHandler.END @admin_only async def recv_file_content(update: Update, ctx: ContextTypes.DEFAULT_TYPE): ctx.user_data["new_content"] = update.message.text await update.message.reply_text("✏️ أرسل رسالة الـ commit:\n_مثال: add: utils module_", parse_mode="Markdown") return WAIT_COMMIT_MSG @admin_only async def recv_commit_msg(update: Update, ctx: ContextTypes.DEFAULT_TYPE): msg = update.message.text.strip() g = gh(ctx); r = repo(ctx) path = ctx.user_data.get("edit_path", "") content = ctx.user_data.get("new_content", "") sha = ctx.user_data.get("edit_sha") log.info("commit | user=%s | path=%s | repo=%s | msg=%.60s", update.effective_user.username, path, r, msg) ok, res = await asyncio.to_thread(g.create_or_update_file, r, path, content, msg, sha=sha) if ok: try: await update.message.reply_text(f"✅ تم حفظ `{path}`!", parse_mode="Markdown", reply_markup=main_kb(r)) except Exception: await update.message.reply_text(f"✅ تم حفظ {path}!", reply_markup=main_kb(r)) else: await update.message.reply_text(f"❌ فشل: {res.get('message', '')}", reply_markup=back_kb()) return ConversationHandler.END @admin_only async def recv_file_path(update: Update, ctx: ContextTypes.DEFAULT_TYPE): ctx.user_data["edit_path"] = update.message.text.strip() ctx.user_data["edit_sha"] = None await update.message.reply_text("📝 أرسل محتوى الملف:") return WAIT_FILE_CONTENT @admin_only async def recv_branch_name(update: Update, ctx: ContextTypes.DEFAULT_TYPE): name = update.message.text.strip().replace(" ", "-") g = gh(ctx); r = repo(ctx) ok, res = await asyncio.to_thread(g.create_branch, r, name) if ok: log.info("create_branch | user=%s | branch=%s | repo=%s", update.effective_user.username, name, r) try: await update.message.reply_text(f"✅ Branch `{name}` أُنشئ!", parse_mode="Markdown", reply_markup=main_kb(r)) except Exception: await update.message.reply_text(f"✅ Branch {name} أُنشئ!", reply_markup=main_kb(r)) else: err = res if isinstance(res, str) else res.get("message", "") await update.message.reply_text(f"❌ فشل: {err}", reply_markup=back_kb()) return ConversationHandler.END @admin_only async def recv_issue_title(update: Update, ctx: ContextTypes.DEFAULT_TYPE): ctx.user_data["issue_title"] = update.message.text.strip() await update.message.reply_text( "📝 أرسل تفاصيل الـ Issue\n_أرسل `-` للتخطي_", parse_mode="Markdown") return WAIT_ISSUE_BODY @admin_only async def recv_issue_body(update: Update, ctx: ContextTypes.DEFAULT_TYPE): body = update.message.text.strip() title = ctx.user_data.pop("issue_title", "") g = gh(ctx); r = repo(ctx) ok, res = await asyncio.to_thread(g.create_issue, r, title, "" if body == "-" else body) if ok: log.info("create_issue | user=%s | #%d | repo=%s", update.effective_user.username, res.get("number", 0), r) try: await update.message.reply_text(f"✅ Issue #{res['number']} أُنشئ!\n`{title}`", parse_mode="Markdown", reply_markup=main_kb(r)) except Exception: await update.message.reply_text(f"✅ Issue #{res.get('number')} أُنشئ!", reply_markup=main_kb(r)) else: await update.message.reply_text(f"❌ فشل: {res.get('message', '')}", reply_markup=back_kb()) return ConversationHandler.END @admin_only async def recv_release_tag(update: Update, ctx: ContextTypes.DEFAULT_TYPE): tag = update.message.text.strip() g = gh(ctx); r = repo(ctx) ok, res = await asyncio.to_thread(g.create_release, r, tag) if ok: log.info("create_release | user=%s | tag=%s | repo=%s", update.effective_user.username, tag, r) url = res.get("html_url", "") try: await update.message.reply_text(f"🚀 Release `{tag}` أُنشئ!\n🔗 {url}", parse_mode="Markdown", reply_markup=main_kb(r)) except Exception: await update.message.reply_text(f"🚀 Release {tag} أُنشئ!\n🔗 {url}", reply_markup=main_kb(r)) else: await update.message.reply_text(f"❌ فشل: {res.get('message', '')}", reply_markup=back_kb()) return ConversationHandler.END @admin_only async def recv_multi_files(update: Update, ctx: ContextTypes.DEFAULT_TYPE): text = update.message.text.strip() g = gh(ctx); r = repo(ctx) try: files = json.loads(text) if not isinstance(files, list): raise ValueError("expected a JSON array") except (json.JSONDecodeError, ValueError) as e: log.warning("recv_multi_files: invalid JSON from user=%s: %s", update.effective_user.username, e) await update.message.reply_text( "❌ صيغة JSON غير صحيحة\n" 'المثال:\n`[{"path":"file.py","content":"..."}]`', parse_mode="Markdown") return WAIT_MULTI_FILES await update.message.reply_text(f"⏳ رفع {len(files)} ملف...") log.info("upload_multiple | user=%s | count=%d | repo=%s", update.effective_user.username, len(files), r) results = await asyncio.to_thread(g.upload_multiple, r, files) ok_count = sum(1 for res in results if res["ok"]) lines = [f"{'✅' if res['ok'] else '❌'} `{res['path']}`" for res in results] try: await update.message.reply_text( f"📤 *النتيجة: {ok_count}/{len(files)} نجح*\n\n" + "\n".join(lines), parse_mode="Markdown", reply_markup=main_kb(r)) except Exception: await update.message.reply_text( f"📤 النتيجة: {ok_count}/{len(files)} نجح", reply_markup=main_kb(r)) return ConversationHandler.END @admin_only async def recv_pr_title(update: Update, ctx: ContextTypes.DEFAULT_TYPE): ctx.user_data["pr_title"] = update.message.text.strip() await update.message.reply_text("📝 أرسل تفاصيل الـ PR\n_أرسل `-` للتخطي_", parse_mode="Markdown") return WAIT_PR_BODY @admin_only async def recv_pr_body(update: Update, ctx: ContextTypes.DEFAULT_TYPE): body = update.message.text.strip() title = ctx.user_data.pop("pr_title", "") head = ctx.user_data.pop("pr_head", "") g = gh(ctx); r = repo(ctx) if not head: branches = await asyncio.to_thread(g.list_branches, r) names = [b["name"] for b in branches if b["name"] not in ("main", "master")] if not names: await update.message.reply_text( "❌ لا توجد branches غير main — أنشئ branch أولاً", reply_markup=back_kb()) return ConversationHandler.END head = names[0] ok, res = await asyncio.to_thread(g.create_pr, r, title, head, "main", "" if body == "-" else body) if ok: log.info("create_pr | user=%s | #%d | %s→main | repo=%s", update.effective_user.username, res.get("number", 0), head, r) url = res.get("html_url", "") try: await update.message.reply_text(f"✅ PR #{res['number']} أُنشئ!\n🔗 {url}", parse_mode="Markdown", reply_markup=main_kb(r)) except Exception: await update.message.reply_text(f"✅ PR أُنشئ!\n🔗 {url}", reply_markup=main_kb(r)) else: await update.message.reply_text(f"❌ فشل: {res.get('message', '')}", reply_markup=back_kb()) return ConversationHandler.END @admin_only async def recv_search_query(update: Update, ctx: ContextTypes.DEFAULT_TYPE): query = update.message.text.strip() g = gh(ctx) results = await asyncio.to_thread(g.search_repos, query) if not results: await _safe_send(update.message, f"🔍 لا توجد نتائج لـ `{query}`", back_kb()) return ConversationHandler.END lines = [] btns = [] for rp in results[:10]: vis = "🔒" if rp.get("private") else "🌐" stars = rp.get("stargazers_count", 0) lang = rp.get("language") or "—" desc = (rp.get("description") or "")[:60] lines.append(f"{vis} `{rp['name']}` ⭐{stars} {lang}\n {desc}") btns.append([InlineKeyboardButton( f"📂 {rp['name']}", callback_data=f"use_{rp['name']}")]) btns.append([InlineKeyboardButton("◀️ الرئيسية", callback_data="menu")]) await _safe_send(update.message, f"🔍 *نتائج البحث عن `{query}`:*\n\n" + "\n\n".join(lines), InlineKeyboardMarkup(btns)) return ConversationHandler.END @admin_only async def cancel(update: Update, ctx: ContextTypes.DEFAULT_TYPE): await update.message.reply_text("❌ إلغاء", reply_markup=back_kb()) return ConversationHandler.END # ══════════════════════════════════════════════════════ # Main # ══════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════ # HTTP Health Server (required for Hugging Face Spaces) # ══════════════════════════════════════════════════════ def _start_health_server() -> None: """HF Spaces keeps Docker containers alive only if they serve HTTP. This starts a minimal HTTP server on port 7860 in a background thread.""" import http.server class _Handler(http.server.BaseHTTPRequestHandler): def do_GET(self): self.send_response(200) self.send_header("Content-Type", "text/plain; charset=utf-8") self.end_headers() self.wfile.write("GitHub Bot is running ✅".encode()) def log_message(self, *args): pass # suppress access logs port = int(os.getenv("PORT", "7860")) httpd = http.server.HTTPServer(("0.0.0.0", port), _Handler) t = threading.Thread(target=httpd.serve_forever, daemon=True) t.start() log.info("Health server on port %s", port) async def _error_handler(update: object, ctx: ContextTypes.DEFAULT_TYPE) -> None: log.error("Unhandled exception | update=%s\n%s", update, "".join(traceback.format_exception(type(ctx.error), ctx.error, ctx.error.__traceback__))) if isinstance(update, Update) and update.effective_message: try: await update.effective_message.reply_text("❌ حدث خطأ غير متوقع. تم تسجيله.") except Exception: pass def main(): if not BOT_TOKEN: log.critical("BOT_TOKEN not set in githubbot.env — cannot start") print("❌ BOT_TOKEN غير موجود في githubbot.env"); return if ADMIN_ID == 0: log.critical("ADMIN_ID not set — cannot start") print("❌ ADMIN_ID غير موجود"); return if not GH_TOKEN: log.warning("GH_TOKEN not set — must be added via bot settings") print("⚠️ GH_TOKEN غير موجود — أضفه من داخل البوت") if not GROQ_KEY: log.warning("GROQ_KEY not set — must be added via bot settings") print("⚠️ GROQ_KEY غير موجود — أضفه من داخل البوت") log.info("Starting bot | GH_USER=%s | GH_REPO=%s | ADMIN_ID=%d | log=%s", GH_USER, GH_REPO, ADMIN_ID, _log_path) app = Application.builder().token(BOT_TOKEN).build() conv = ConversationHandler( entry_points=[CallbackQueryHandler(button)], states={ WAIT_PAT: [MessageHandler(filters.TEXT & ~filters.COMMAND, recv_pat)], WAIT_GROQ_KEY: [MessageHandler(filters.TEXT & ~filters.COMMAND, recv_groq_key)], WAIT_REPO_NAME: [MessageHandler(filters.TEXT & ~filters.COMMAND, recv_repo_name)], WAIT_FILE_CONTENT: [MessageHandler(filters.TEXT & ~filters.COMMAND, recv_file_content)], WAIT_FILE_PATH: [MessageHandler(filters.TEXT & ~filters.COMMAND, recv_file_path)], WAIT_COMMIT_MSG: [MessageHandler(filters.TEXT & ~filters.COMMAND, recv_commit_msg)], WAIT_BRANCH_NAME: [MessageHandler(filters.TEXT & ~filters.COMMAND, recv_branch_name)], WAIT_ISSUE_TITLE: [MessageHandler(filters.TEXT & ~filters.COMMAND, recv_issue_title)], WAIT_ISSUE_BODY: [MessageHandler(filters.TEXT & ~filters.COMMAND, recv_issue_body)], WAIT_RELEASE_TAG: [MessageHandler(filters.TEXT & ~filters.COMMAND, recv_release_tag)], WAIT_MULTI_FILES: [MessageHandler(filters.TEXT & ~filters.COMMAND, recv_multi_files)], WAIT_PR_TITLE: [MessageHandler(filters.TEXT & ~filters.COMMAND, recv_pr_title)], WAIT_PR_BODY: [MessageHandler(filters.TEXT & ~filters.COMMAND, recv_pr_body)], WAIT_AI_KEY: [MessageHandler(filters.TEXT & ~filters.COMMAND, recv_ai_key)], WAIT_SEARCH_QUERY: [MessageHandler(filters.TEXT & ~filters.COMMAND, recv_search_query)], }, fallbacks=[CommandHandler("cancel", cancel)], per_message=False, ) app.add_handler(CommandHandler("start", start)) app.add_handler(CommandHandler("help", help_cmd)) app.add_handler(conv) app.add_handler(MessageHandler(filters.VOICE, handle_voice)) # Free-text outside conversation states → AI app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_text)) app.add_error_handler(_error_handler) _start_health_server() # Keep HF Space alive log.info("Bot polling started") print(f"🤖 GitHub Bot يشتغل | {GH_USER}") app.run_polling(drop_pending_updates=True) if __name__ == "__main__": main()