diff --git a/__pycache__/bot_handlers.cpython-313.pyc b/__pycache__/bot_handlers.cpython-313.pyc index 0e2271d5f..e7097cc47 100644 Binary files a/__pycache__/bot_handlers.cpython-313.pyc and b/__pycache__/bot_handlers.cpython-313.pyc differ diff --git a/__pycache__/code_processor.cpython-313.pyc b/__pycache__/code_processor.cpython-313.pyc index 27bd4b3b0..58d8da05d 100644 Binary files a/__pycache__/code_processor.cpython-313.pyc and b/__pycache__/code_processor.cpython-313.pyc differ diff --git a/__pycache__/config.cpython-313.pyc b/__pycache__/config.cpython-313.pyc index 756c95e3d..0fb8b7cea 100644 Binary files a/__pycache__/config.cpython-313.pyc and b/__pycache__/config.cpython-313.pyc differ diff --git a/__pycache__/conversation_handlers.cpython-313.pyc b/__pycache__/conversation_handlers.cpython-313.pyc index 7897ba0d1..e76839749 100644 Binary files a/__pycache__/conversation_handlers.cpython-313.pyc and b/__pycache__/conversation_handlers.cpython-313.pyc differ diff --git a/__pycache__/file_manager.cpython-313.pyc b/__pycache__/file_manager.cpython-313.pyc index 5d1eecc31..94d73621f 100644 Binary files a/__pycache__/file_manager.cpython-313.pyc and b/__pycache__/file_manager.cpython-313.pyc differ diff --git a/__pycache__/github_menu_handler.cpython-313.pyc b/__pycache__/github_menu_handler.cpython-313.pyc index a63edbaf6..36365caf9 100644 Binary files a/__pycache__/github_menu_handler.cpython-313.pyc and b/__pycache__/github_menu_handler.cpython-313.pyc differ diff --git a/__pycache__/integrations.cpython-313.pyc b/__pycache__/integrations.cpython-313.pyc index e10935c38..cbfff7d39 100644 Binary files a/__pycache__/integrations.cpython-313.pyc and b/__pycache__/integrations.cpython-313.pyc differ diff --git a/__pycache__/main.cpython-313.pyc b/__pycache__/main.cpython-313.pyc index f48c1d9b5..243aa3a0a 100644 Binary files a/__pycache__/main.cpython-313.pyc and b/__pycache__/main.cpython-313.pyc differ diff --git a/__pycache__/search_engine.cpython-313.pyc b/__pycache__/search_engine.cpython-313.pyc index 7e6d3c0c9..3d5366c7a 100644 Binary files a/__pycache__/search_engine.cpython-313.pyc and b/__pycache__/search_engine.cpython-313.pyc differ diff --git a/__pycache__/utils.cpython-313.pyc b/__pycache__/utils.cpython-313.pyc index c7c488e04..4c9e74d85 100644 Binary files a/__pycache__/utils.cpython-313.pyc and b/__pycache__/utils.cpython-313.pyc differ diff --git a/config.py b/config.py index b2e2c3f30..155c14eac 100644 --- a/config.py +++ b/config.py @@ -39,6 +39,12 @@ class BotConfig: # קידומת לשם נקודת שמירה ב-Git (ל-tags ולענפים בגיבוי) GIT_CHECKPOINT_PREFIX: str = "checkpoint" + + # Google Drive OAuth (Desktop App / Device Flow) + GOOGLE_CLIENT_ID: Optional[str] = None + GOOGLE_CLIENT_SECRET: Optional[str] = None + GOOGLE_OAUTH_SCOPES: str = "https://www.googleapis.com/auth/drive.file" + GOOGLE_TOKEN_REFRESH_MARGIN_SECS: int = 120 def __post_init__(self): if self.SUPPORTED_LANGUAGES is None: @@ -71,6 +77,10 @@ def load_config() -> BotConfig: MAX_FILES_PER_USER=int(os.getenv('MAX_FILES_PER_USER', '1000')), HIGHLIGHT_THEME=os.getenv('HIGHLIGHT_THEME', 'github-dark'), GIT_CHECKPOINT_PREFIX=os.getenv('GIT_CHECKPOINT_PREFIX', 'checkpoint'), + GOOGLE_CLIENT_ID=os.getenv('GOOGLE_CLIENT_ID'), + GOOGLE_CLIENT_SECRET=os.getenv('GOOGLE_CLIENT_SECRET'), + GOOGLE_OAUTH_SCOPES=os.getenv('GOOGLE_OAUTH_SCOPES', 'https://www.googleapis.com/auth/drive.file'), + GOOGLE_TOKEN_REFRESH_MARGIN_SECS=int(os.getenv('GOOGLE_TOKEN_REFRESH_MARGIN_SECS', '120')), MAINTENANCE_MODE=os.getenv('MAINTENANCE_MODE', 'false').lower() == 'true', MAINTENANCE_MESSAGE=os.getenv('MAINTENANCE_MESSAGE', "🚀 אנחנו מעלים עדכון חדש!\nהבוט יחזור לפעול ממש בקרוב (1 - 3 דקות)"), MAINTENANCE_AUTO_WARMUP_SECS=int(os.getenv('MAINTENANCE_AUTO_WARMUP_SECS', '180')), diff --git a/conversation_handlers.py b/conversation_handlers.py index 1fa6d2997..8bf822e00 100644 --- a/conversation_handlers.py +++ b/conversation_handlers.py @@ -86,7 +86,7 @@ def _format_bytes(num: int) -> str: ["🗜️ יצירת ZIP", "➕ הוסף קוד חדש"], ["📚 הצג את כל הקבצים שלי", "🔧 GitHub"], ["⚡ עיבוד Batch", "📥 ייבוא ZIP מריפו"], - ["ℹ️ הסבר על הבוט"] + ["☁️ Google Drive", "ℹ️ הסבר על הבוט"] ] reporter = create_reporter( diff --git a/database/manager.py b/database/manager.py index 982c3312d..81aa5ad67 100644 --- a/database/manager.py +++ b/database/manager.py @@ -275,3 +275,19 @@ def get_selected_repo(self, user_id: int) -> Optional[str]: def save_user(self, user_id: int, username: str = None) -> bool: return self._get_repo().save_user(user_id, username) + # Google Drive tokens & preferences + def save_drive_tokens(self, user_id: int, token_data: Dict[str, Any]) -> bool: + return self._get_repo().save_drive_tokens(user_id, token_data) + + def get_drive_tokens(self, user_id: int) -> Optional[Dict[str, Any]]: + return self._get_repo().get_drive_tokens(user_id) + + def delete_drive_tokens(self, user_id: int) -> bool: + return self._get_repo().delete_drive_tokens(user_id) + + def save_drive_prefs(self, user_id: int, prefs: Dict[str, Any]) -> bool: + return self._get_repo().save_drive_prefs(user_id, prefs) + + def get_drive_prefs(self, user_id: int) -> Optional[Dict[str, Any]]: + return self._get_repo().get_drive_prefs(user_id) + diff --git a/database/repository.py b/database/repository.py index 87117bc68..e53bcefd4 100644 --- a/database/repository.py +++ b/database/repository.py @@ -353,6 +353,97 @@ def save_user(self, user_id: int, username: str = None) -> bool: logger.error(f"שגיאה בשמירת משתמש: {e}") return False + # --- Google Drive tokens & preferences --- + def save_drive_tokens(self, user_id: int, token_data: Dict[str, Any]) -> bool: + try: + users_collection = self.manager.db.users + # Encrypt sensitive fields + from secret_manager import encrypt_secret + enc_access = encrypt_secret(token_data.get("access_token", "") or "") + enc_refresh = encrypt_secret(token_data.get("refresh_token", "") or "") + stored = { + "access_token": enc_access if enc_access else token_data.get("access_token"), + "refresh_token": enc_refresh if enc_refresh else token_data.get("refresh_token"), + "token_type": token_data.get("token_type"), + "expiry": token_data.get("expiry"), + "scope": token_data.get("scope"), + "expires_in": token_data.get("expires_in"), + "obtained_at": datetime.now(timezone.utc).isoformat(), + } + result = users_collection.update_one( + {"user_id": user_id}, + {"$set": {"drive_tokens": stored, "updated_at": datetime.now(timezone.utc)}}, + upsert=True, + ) + return bool(result.acknowledged) + except Exception as e: + logger.error(f"Failed to save Drive tokens: {e}") + return False + + def get_drive_tokens(self, user_id: int) -> Optional[Dict[str, Any]]: + try: + users_collection = self.manager.db.users + user = users_collection.find_one({"user_id": user_id}) + if not user: + return None + data = user.get("drive_tokens") + if not data: + return None + # Decrypt + from secret_manager import decrypt_secret + acc = data.get("access_token") + ref = data.get("refresh_token") + acc_dec = decrypt_secret(acc) if acc else None + ref_dec = decrypt_secret(ref) if ref else None + out = dict(data) + if acc_dec: + out["access_token"] = acc_dec + if ref_dec: + out["refresh_token"] = ref_dec + return out + except Exception as e: + logger.error(f"Failed to get Drive tokens: {e}") + return None + + def delete_drive_tokens(self, user_id: int) -> bool: + try: + users_collection = self.manager.db.users + res = users_collection.update_one( + {"user_id": user_id}, {"$unset": {"drive_tokens": ""}, "$set": {"updated_at": datetime.now(timezone.utc)}} + ) + return bool(res.acknowledged) + except Exception as e: + logger.error(f"Failed to delete Drive tokens: {e}") + return False + + def save_drive_prefs(self, user_id: int, prefs: Dict[str, Any]) -> bool: + try: + users_collection = self.manager.db.users + # merge with existing prefs + existing = users_collection.find_one({"user_id": user_id}) or {} + merged = dict(existing.get("drive_prefs") or {}) + merged.update(prefs or {}) + res = users_collection.update_one( + {"user_id": user_id}, + {"$set": {"drive_prefs": merged, "updated_at": datetime.now(timezone.utc)}}, + upsert=True, + ) + return bool(res.acknowledged) + except Exception as e: + logger.error(f"Failed to save Drive prefs: {e}") + return False + + def get_drive_prefs(self, user_id: int) -> Optional[Dict[str, Any]]: + try: + users_collection = self.manager.db.users + user = users_collection.find_one({"user_id": user_id}) + if not user: + return None + return user.get("drive_prefs") + except Exception as e: + logger.error(f"Failed to get Drive prefs: {e}") + return None + # --- Backup ratings --- def save_backup_rating(self, user_id: int, backup_id: str, rating: str) -> bool: try: diff --git a/handlers/drive/menu.py b/handlers/drive/menu.py new file mode 100644 index 000000000..fd0cb2eea --- /dev/null +++ b/handlers/drive/menu.py @@ -0,0 +1,254 @@ +from typing import Any, Dict, Optional + +from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update +from telegram.ext import ContextTypes + +from services import google_drive_service as gdrive +from database import db + + +class GoogleDriveMenuHandler: + def __init__(self): + self.sessions: Dict[int, Dict[str, Any]] = {} + + def _session(self, user_id: int) -> Dict[str, Any]: + if user_id not in self.sessions: + self.sessions[user_id] = {} + return self.sessions[user_id] + + async def menu(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + query = update.callback_query if update.callback_query else None + if query: + await query.answer() + send = query.edit_message_text + else: + send = update.message.reply_text + + user_id = update.effective_user.id + tokens = db.get_drive_tokens(user_id) + + if not tokens: + kb = [[InlineKeyboardButton("🔐 התחבר ל‑Drive", callback_data="drive_auth")]] + await send("Google Drive\n\nלא מחובר. התחבר כדי לגבות לקבצי Drive.", reply_markup=InlineKeyboardMarkup(kb)) + return + + # Connected menu + kb = [ + [InlineKeyboardButton("📤 גבה עכשיו", callback_data="drive_backup_now")], + [InlineKeyboardButton("🗂 בחר תיקיית יעד", callback_data="drive_choose_folder")], + [InlineKeyboardButton("🗓 זמני גיבוי", callback_data="drive_schedule")], + [InlineKeyboardButton("⚙️ מתקדם", callback_data="drive_advanced")], + [InlineKeyboardButton("🚪 התנתק", callback_data="drive_logout")], + ] + await send("Google Drive — מחובר\nבחר פעולה:", reply_markup=InlineKeyboardMarkup(kb)) + + async def handle_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + query = update.callback_query + user_id = query.from_user.id + data = query.data + await query.answer() + + if data == "drive_menu": + await self.menu(update, context) + return + if data == "drive_auth": + flow = gdrive.start_device_authorization(user_id) + sess = self._session(user_id) + sess["device_code"] = flow.get("device_code") + sess["interval"] = flow.get("interval", 5) + text = ( + "🔐 התחברות ל‑Google Drive\n\n" + f"גש לכתובת: {flow.get('verification_url')}\n" + f"קוד: {flow.get('user_code')}\n\n" + "לאחר האישור, שלח כאן את הקוד החד‑פעמי כפי שקיבלת מגוגל." + ) + context.user_data["waiting_for_drive_code"] = True + await query.edit_message_text(text, parse_mode="HTML") + return + if data == "drive_backup_now": + # Show selection: ZIP, הכל, מתקדם + kb = [ + [InlineKeyboardButton("📦 קבצי ZIP", callback_data="drive_sel_zip")], + [InlineKeyboardButton("🧰 הכל", callback_data="drive_sel_all")], + [InlineKeyboardButton("⚙️ מתקדם", callback_data="drive_sel_adv")], + [InlineKeyboardButton("🔙 חזרה", callback_data="drive_menu")], + ] + await query.edit_message_text("בחר מה לגבות:", reply_markup=InlineKeyboardMarkup(kb)) + return + if data == "drive_sel_zip": + # Upload existing ZIP backups + count, ids = gdrive.upload_all_saved_zip_backups(user_id) + await query.edit_message_text(f"✅ הועלו {count} גיבויים ל‑Drive") + return + if data == "drive_sel_all": + fn, data_bytes = gdrive.create_full_backup_zip_bytes(user_id, category="all") + fid = gdrive.upload_bytes(user_id, fn, data_bytes) + await query.edit_message_text("✅ גיבוי מלא הועלה ל‑Drive" if fid else "❌ כשל בהעלאה") + return + if data == "drive_sel_adv": + multi_on = bool(self._session(user_id).get("adv_multi", False)) + kb = [ + [InlineKeyboardButton("לפי ריפו", callback_data="drive_adv_by_repo")], + [InlineKeyboardButton("קבצים גדולים", callback_data="drive_adv_large")], + [InlineKeyboardButton("שאר קבצים", callback_data="drive_adv_other")], + [InlineKeyboardButton(("✅ אפשרות מרובה" if multi_on else "⬜ אפשרות מרובה"), callback_data="drive_adv_multi_toggle")], + [InlineKeyboardButton("⬆️ העלה נבחרים", callback_data="drive_adv_upload_selected")], + [InlineKeyboardButton("🔙 חזרה", callback_data="drive_backup_now")], + ] + await query.edit_message_text("בחר קטגוריה מתקדמת:", reply_markup=InlineKeyboardMarkup(kb)) + return + if data in {"drive_adv_by_repo", "drive_adv_large", "drive_adv_other"}: + category = { + "drive_adv_by_repo": "by_repo", + "drive_adv_large": "large", + "drive_adv_other": "other", + }[data] + sess = self._session(user_id) + if sess.get("adv_multi"): + selected = sess.setdefault("adv_selected", set()) + selected.add(category) + await query.edit_message_text("✅ נוסף לבחירה. ניתן לבחור עוד אפשרויות או להעלות.") + else: + fn, data_bytes = gdrive.create_full_backup_zip_bytes(user_id, category=category) + fid = gdrive.upload_bytes(user_id, fn, data_bytes) + await query.edit_message_text("✅ גיבוי הועלה ל‑Drive" if fid else "❌ כשל בהעלאה") + return + if data == "drive_adv_multi_toggle": + sess = self._session(user_id) + sess["adv_multi"] = not bool(sess.get("adv_multi", False)) + await self.handle_callback(update, context) + return + if data == "drive_adv_upload_selected": + sess = self._session(user_id) + cats = list(sess.get("adv_selected", set()) or []) + if not cats: + await query.answer("לא נבחרו אפשרויות", show_alert=True) + return + uploaded_any = False + for c in cats: + fn, data_bytes = gdrive.create_full_backup_zip_bytes(user_id, category=c) + fid = gdrive.upload_bytes(user_id, fn, data_bytes) + uploaded_any = uploaded_any or bool(fid) + sess["adv_selected"] = set() + await query.edit_message_text("✅ הועלו הגיבויים שנבחרו" if uploaded_any else "❌ כשל בהעלאה") + return + if data == "drive_choose_folder": + kb = [ + [InlineKeyboardButton("📁 ברירת מחדל (CodeKeeper Backups)", callback_data="drive_folder_default")], + [InlineKeyboardButton("✏️ הגדר נתיב מותאם (שלח טקסט)", callback_data="drive_folder_set")], + [InlineKeyboardButton("🔙 חזרה", callback_data="drive_menu")], + ] + await query.edit_message_text("בחר דרך לקביעת תיקיית יעד:", reply_markup=InlineKeyboardMarkup(kb)) + return + if data == "drive_folder_default": + fid = gdrive.get_or_create_default_folder(user_id) + await query.edit_message_text("📁 נקבעה תיקיית יעד ברירת מחדל: CodeKeeper Backups" if fid else "❌ כשל בקביעת תיקייה") + return + if data == "drive_folder_set": + context.user_data["waiting_for_drive_folder_path"] = True + await query.edit_message_text("שלח נתיב תיקייה (למשל: Project/Backups/Code) — ניצור אם לא קיים") + return + if data == "drive_schedule": + current = (db.get_drive_prefs(user_id) or {}).get("schedule") + def label(key: str, text: str) -> str: + return ("✅ " + text) if current == key else text + kb = [ + [InlineKeyboardButton(label("daily", "כל יום"), callback_data="drive_set_schedule:daily")], + [InlineKeyboardButton(label("every3", "כל 3 ימים"), callback_data="drive_set_schedule:every3")], + [InlineKeyboardButton(label("weekly", "כל שבוע"), callback_data="drive_set_schedule:weekly")], + [InlineKeyboardButton(label("biweekly", "פעם בשבועיים"), callback_data="drive_set_schedule:biweekly")], + [InlineKeyboardButton(label("monthly", "פעם בחודש"), callback_data="drive_set_schedule:monthly")], + [InlineKeyboardButton("⛔ בטל תזמון", callback_data="drive_set_schedule:off")], + [InlineKeyboardButton("🔙 חזרה", callback_data="drive_menu")], + ] + await query.edit_message_text("בחר תדירות גיבוי אוטומטי:", reply_markup=InlineKeyboardMarkup(kb)) + return + if data.startswith("drive_set_schedule:"): + key = data.split(":", 1)[1] + # Save preference + if key == "off": + db.save_drive_prefs(user_id, {"schedule": None}) + # cancel job if exists + jobs = context.bot_data.setdefault("drive_schedule_jobs", {}) + job = jobs.pop(user_id, None) + if job: + try: + job.schedule_removal() + except Exception: + pass + await query.edit_message_text("⛔ תזמון בוטל") + return + db.save_drive_prefs(user_id, {"schedule": key}) + # schedule job + interval_map = { + "daily": 24 * 3600, + "every3": 3 * 24 * 3600, + "weekly": 7 * 24 * 3600, + "biweekly": 14 * 24 * 3600, + "monthly": 30 * 24 * 3600, + } + seconds = interval_map.get(key, 24 * 3600) + + async def _scheduled_backup_cb(ctx: ContextTypes.DEFAULT_TYPE): + try: + uid = ctx.job.data["user_id"] + ok = gdrive.perform_scheduled_backup(uid) + if ok: + await ctx.bot.send_message(chat_id=uid, text="☁️ גיבוי אוטומטי ל‑Drive הושלם בהצלחה") + except Exception: + pass + + try: + jobs = context.bot_data.setdefault("drive_schedule_jobs", {}) + # cancel existing + old = jobs.get(user_id) + if old: + try: + old.schedule_removal() + except Exception: + pass + job = context.application.job_queue.run_repeating( + _scheduled_backup_cb, interval=seconds, first=10, name=f"drive_{user_id}", data={"user_id": user_id} + ) + jobs[user_id] = job + except Exception: + pass + await query.edit_message_text("✅ תזמון נשמר") + return + if data == "drive_logout": + ok = db.delete_drive_tokens(user_id) + await query.edit_message_text("🚪נותקת מ‑Google Drive" if ok else "❌ לא בוצעה התנתקות") + return + + async def handle_text(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> bool: + text = (update.message.text or "").strip() + if context.user_data.get("waiting_for_drive_code"): + # User pasted one-time code; exchange by polling device flow once + context.user_data["waiting_for_drive_code"] = False + sess = self._session(update.effective_user.id) + device_code = sess.get("device_code") + if not device_code: + await update.message.reply_text("❌ פג תוקף הבקשה. נסה שוב.") + return True + tokens = gdrive.poll_device_token(device_code) + if not tokens: + await update.message.reply_text("⌛ עדיין ממתינים לאישור. אשר בדפדפן ונסה שוב לשלוח את הקוד.") + context.user_data["waiting_for_drive_code"] = True + return True + saved = gdrive.save_tokens(update.effective_user.id, tokens) + if saved: + await update.message.reply_text("✅ חיבור ל‑Drive הושלם! שלח /drive כדי להתחיל לגבות.") + else: + await update.message.reply_text("❌ לא ניתן לשמור את החיבור.") + return True + if context.user_data.get("waiting_for_drive_folder_path"): + context.user_data["waiting_for_drive_folder_path"] = False + path = text + fid = gdrive.ensure_path(update.effective_user.id, path) + if fid: + await update.message.reply_text("✅ תיקיית יעד עודכנה בהצלחה") + else: + await update.message.reply_text("❌ לא ניתן להגדיר את התיקייה. ודא בהרשאות Drive.") + return True + return False + diff --git a/i18n/strings_he.py b/i18n/strings_he.py index 5f0c6703f..86068d55c 100644 --- a/i18n/strings_he.py +++ b/i18n/strings_he.py @@ -2,7 +2,7 @@ ["🗜️ יצירת ZIP", "➕ הוסף קוד חדש"], ["📚 הצג את כל הקבצים שלי", "🔧 GitHub"], ["⚡ עיבוד Batch", "📥 ייבוא ZIP מריפו"], - ["ℹ️ הסבר על הבוט"], + ["☁️ Google Drive", "ℹ️ הסבר על הבוט"], ] MESSAGES = { diff --git a/main.py b/main.py index 724c42de5..1f42327ca 100644 --- a/main.py +++ b/main.py @@ -35,6 +35,7 @@ from activity_reporter import create_reporter from github_menu_handler import GitHubMenuHandler from backup_menu_handler import BackupMenuHandler +from handlers.drive.menu import GoogleDriveMenuHandler from file_manager import backup_manager from large_files_handler import large_files_handler from user_stats import user_stats @@ -367,6 +368,11 @@ async def _clear_handlers_cb(context: ContextTypes.DEFAULT_TYPE): backup_handler = BackupMenuHandler() self.application.bot_data['backup_handler'] = backup_handler logger.info("✅ BackupMenuHandler instance created and stored in bot_data") + + # יצירת GoogleDriveMenuHandler ושמירה + drive_handler = GoogleDriveMenuHandler() + self.application.bot_data['drive_handler'] = drive_handler + logger.info("✅ GoogleDriveMenuHandler instance created and stored in bot_data") # הוסף פקודת github self.application.add_handler(CommandHandler("github", github_handler.github_menu_command)) @@ -382,6 +388,14 @@ async def show_backup_menu(update: Update, context: ContextTypes.DEFAULT_TYPE): pattern=r'^(select_repo|upload_file|upload_saved|show_current|set_token|set_folder|close_menu|folder_|repo_|repos_page_|upload_saved_|back_to_menu|repo_manual|noop|analyze_repo|analyze_current_repo|analyze_other_repo|show_suggestions|show_full_analysis|download_analysis_json|back_to_analysis|back_to_analysis_menu|back_to_summary|choose_my_repo|enter_repo_url|suggestion_\d+|github_menu|logout_github|delete_file_menu|delete_repo_menu|confirm_delete_repo|confirm_delete_repo_step1|confirm_delete_file|danger_delete_menu|download_file_menu|browse_open:.*|browse_select_download:.*|browse_select_delete:.*|browse_page:.*|download_zip:.*|multi_toggle|multi_execute|multi_clear|safe_toggle|browse_toggle_select:.*|inline_download_file:.*|notifications_menu|notifications_toggle|notifications_toggle_pr|notifications_toggle_issues|notifications_interval_.*|notifications_check_now|share_folder_link:.*|share_selected_links|pr_menu|create_pr_menu|branches_page_.*|pr_select_head:.*|confirm_create_pr|merge_pr_menu|prs_page_.*|merge_pr:.*|confirm_merge_pr|validate_repo|git_checkpoint|git_checkpoint_doc:.*|git_checkpoint_doc_skip|restore_checkpoint_menu|restore_tags_page_.*|restore_select_tag:.*|restore_branch_from_tag:.*|restore_revert_pr_from_tag:.*|open_pr_from_branch:.*|choose_upload_branch|upload_branches_page_.*|upload_select_branch:.*|choose_upload_folder|upload_select_folder:.*|upload_folder_root|upload_folder_current|upload_folder_custom|upload_folder_create|create_folder|confirm_saved_upload|refresh_saved_checks|github_backup_menu|github_backup_help|github_backup_db_list|github_restore_zip_to_repo|github_restore_zip_setpurge:.*|github_restore_zip_list|github_restore_zip_from_backup:.*|github_repo_restore_backup_setpurge:.*|gh_upload_cat:.*|gh_upload_repo:.*|gh_upload_large:.*|backup_menu|github_create_repo_from_zip|github_new_repo_name|github_set_new_repo_visibility:.*|upload_paste_code|cancel_paste_flow|gh_upload_zip_browse:.*|gh_upload_zip_page:.*|gh_upload_zip_select:.*|gh_upload_zip_select_idx:.*|backup_add_note:.*)') ) + # הוסף את ה-callbacks של Google Drive + self.application.add_handler( + CallbackQueryHandler( + drive_handler.handle_callback, + pattern=r'^(drive_menu|drive_auth|drive_backup_now|drive_sel_zip|drive_sel_all|drive_sel_adv|drive_adv_by_repo|drive_adv_large|drive_adv_other|drive_choose_folder|drive_folder_default|drive_folder_set|drive_schedule|drive_set_schedule:.*|drive_adv_multi_toggle|drive_adv_upload_selected|drive_logout)$' + ) + ) + # Inline query handler self.application.add_handler(InlineQueryHandler(github_handler.handle_inline_query)) @@ -460,6 +474,15 @@ async def handle_github_text(update: Update, context: ContextTypes.DEFAULT_TYPE) MessageHandler(filters.TEXT & ~filters.COMMAND, handle_github_text), group=-1 # עדיפות גבוהה מאוד ) + # הוסף handler טקסט ל-Drive (קוד אישור) + async def handle_drive_text(update: Update, context: ContextTypes.DEFAULT_TYPE): + return await drive_handler.handle_text(update, context) + + self.application.add_handler( + MessageHandler(filters.TEXT & ~filters.COMMAND, handle_drive_text), + group=-1 + ) + logger.info("✅ GitHub handler נוסף בהצלחה") @@ -553,6 +576,16 @@ async def handle_github_logout(update: Update, context: ContextTypes.DEFAULT_TYP filters.Regex("^⚡ עיבוד Batch$"), handle_batch_button )) + # כפתור לתפריט Google Drive + async def show_drive_menu(update: Update, context: ContextTypes.DEFAULT_TYPE): + await drive_handler.menu(update, context) + self.application.add_handler(MessageHandler( + filters.Regex("^☁️ Google Drive$"), + show_drive_menu + )) + + # פקודה /drive + self.application.add_handler(CommandHandler("drive", show_drive_menu)) # כפתור חדש לתפריט גיבוי/שחזור # הוסר: כפתורי גיבוי/שחזור מהמקלדת הראשית. כעת תחת /github -> 🧰 גיבוי ושחזור # self.application.add_handler(MessageHandler( diff --git a/requirements.txt b/requirements.txt index ecd27d19e..b6502bcd3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,6 +21,11 @@ requests==2.31.0 aiohttp==3.9.4 httpx==0.25.2 +# Google APIs (Drive OAuth + Client) +google-api-python-client==2.141.0 +google-auth==2.34.0 +google-auth-oauthlib==1.2.1 + # GitHub Integration PyGithub==2.1.1 diff --git a/services/__init__.py b/services/__init__.py index fe16459e0..1bd01ba78 100644 --- a/services/__init__.py +++ b/services/__init__.py @@ -1,2 +1,6 @@ -__all__ = [] +__all__ = [ + "backup_service", + "code_service", + "github_service", +] diff --git a/services/google_drive_service.py b/services/google_drive_service.py new file mode 100644 index 000000000..8fbc79187 --- /dev/null +++ b/services/google_drive_service.py @@ -0,0 +1,292 @@ +from __future__ import annotations + +import io +import json +import time +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, Optional, Tuple, List + +import requests +try: + from googleapiclient.discovery import build # type: ignore + from googleapiclient.errors import HttpError # type: ignore + from googleapiclient.http import MediaIoBaseUpload # type: ignore + from google.oauth2.credentials import Credentials # type: ignore + from google.auth.transport.requests import Request # type: ignore +except Exception: # pragma: no cover - optional dependency + build = None # type: ignore[assignment] + HttpError = Exception # type: ignore[assignment] + MediaIoBaseUpload = None # type: ignore[assignment] + Credentials = None # type: ignore[assignment] + Request = None # type: ignore[assignment] + +from config import config +from database import db +from file_manager import backup_manager + + +DEVICE_CODE_URL = "https://oauth2.googleapis.com/device/code" +TOKEN_URL = "https://oauth2.googleapis.com/token" + + +def _now_utc() -> datetime: + return datetime.now(timezone.utc) + + +def start_device_authorization(user_id: int) -> Dict[str, Any]: + """Starts OAuth Device Authorization flow for Google Drive. + + Returns a dict with: verification_url, user_code, device_code, interval, expires_in. + """ + payload = { + "client_id": config.GOOGLE_CLIENT_ID, + "scope": config.GOOGLE_OAUTH_SCOPES, + } + response = requests.post(DEVICE_CODE_URL, data=payload, timeout=15) + response.raise_for_status() + data = response.json() + # Persist device flow state in memory is handled by caller; tokens saved on success. + return { + "verification_url": data.get("verification_url") or data.get("verification_uri"), + "user_code": data.get("user_code"), + "device_code": data.get("device_code"), + "interval": int(data.get("interval", 5)), + "expires_in": int(data.get("expires_in", 1800)), + } + + +def poll_device_token(device_code: str) -> Optional[Dict[str, Any]]: + """Attempts a single token exchange for a device_code. Returns token dict or None if authorization_pending. + Raises on fatal errors. + """ + payload = { + "client_id": config.GOOGLE_CLIENT_ID, + "client_secret": config.GOOGLE_CLIENT_SECRET or "", + "device_code": device_code, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + } + resp = requests.post(TOKEN_URL, data=payload, timeout=20) + if resp.status_code == 428 or resp.status_code == 400: + try: + err = resp.json().get("error") + except Exception: + err = None + if err in {"authorization_pending", "slow_down"}: + return None + resp.raise_for_status() + tokens = resp.json() + # Normalize fields + tokens["expiry"] = (_now_utc() + timedelta(seconds=int(tokens.get("expires_in", 3600)))).isoformat() + return tokens + + +def save_tokens(user_id: int, tokens: Dict[str, Any]) -> bool: + # Ensure we persist refresh_token if available + return db.save_drive_tokens(user_id, tokens) + + +def _load_tokens(user_id: int) -> Optional[Dict[str, Any]]: + return db.get_drive_tokens(user_id) + + +def _credentials_from_tokens(tokens: Dict[str, Any]) -> Credentials: + if Credentials is None: + raise RuntimeError("Google libraries are not installed") + return Credentials( + token=tokens.get("access_token"), + refresh_token=tokens.get("refresh_token"), + token_uri=TOKEN_URL, + client_id=config.GOOGLE_CLIENT_ID, + client_secret=config.GOOGLE_CLIENT_SECRET or None, + scopes=(tokens.get("scope") or config.GOOGLE_OAUTH_SCOPES).split(), + ) + + +def _ensure_valid_credentials(user_id: int) -> Optional[Credentials]: + tokens = _load_tokens(user_id) + if not tokens: + return None + try: + creds = _credentials_from_tokens(tokens) + except Exception: + return None + if creds.expired and creds.refresh_token: + try: + if Request is None: + return None + creds.refresh(Request()) # type: ignore[misc] + # Persist updated access token and expiry + updated = { + "access_token": creds.token, + "refresh_token": creds.refresh_token, + "token_type": "Bearer", + "scope": " ".join(creds.scopes or []), + "expires_in": int((creds.expiry - _now_utc()).total_seconds()) if creds.expiry else 3600, + "expiry": creds.expiry.isoformat() if creds.expiry else (_now_utc() + timedelta(hours=1)).isoformat(), + } + save_tokens(user_id, updated) + except Exception: + return None + return creds + + +def get_drive_service(user_id: int): + if build is None: + return None + creds = _ensure_valid_credentials(user_id) + if not creds: + return None + try: + return build("drive", "v3", credentials=creds, cache_discovery=False) + except Exception: + return None + + +def ensure_folder(user_id: int, name: str, parent_id: Optional[str] = None) -> Optional[str]: + service = get_drive_service(user_id) + if not service: + return None + try: + safe_name = name.replace("'", "\\'") + q = "name = '{0}' and mimeType = 'application/vnd.google-apps.folder' and trashed = false".format(safe_name) + if parent_id: + safe_parent = str(parent_id).replace("'", "\\'") + q += f" and '{safe_parent}' in parents" + results = service.files().list(q=q, fields="files(id, name)").execute() + files = results.get("files", []) + if files: + return files[0]["id"] + metadata = { + "name": name, + "mimeType": "application/vnd.google-apps.folder", + } + if parent_id: + metadata["parents"] = [parent_id] + folder = service.files().create(body=metadata, fields="id").execute() + return folder.get("id") + except HttpError: + return None + + +def get_or_create_default_folder(user_id: int) -> Optional[str]: + prefs = db.get_drive_prefs(user_id) or {} + folder_id = prefs.get("target_folder_id") + if folder_id: + return folder_id + # Create "CodeKeeper Backups" at root + fid = ensure_folder(user_id, "CodeKeeper Backups", None) + if fid: + db.save_drive_prefs(user_id, {"target_folder_id": fid}) + return fid + + +def ensure_path(user_id: int, path: str) -> Optional[str]: + """Ensure nested folders path like "Parent/Sub1/Sub2" exists; returns last folder id.""" + if not path: + return get_or_create_default_folder(user_id) + parts = [p.strip() for p in path.split('/') if p.strip()] + parent: Optional[str] = None + for part in parts: + parent = ensure_folder(user_id, part, parent) + if not parent: + return None + # Save as target folder + db.save_drive_prefs(user_id, {"target_folder_id": parent}) + return parent + + +def upload_bytes(user_id: int, filename: str, data: bytes, folder_id: Optional[str] = None) -> Optional[str]: + service = get_drive_service(user_id) + if not service: + return None + if not folder_id: + folder_id = get_or_create_default_folder(user_id) + if not folder_id: + return None + if MediaIoBaseUpload is None: + return None + media = MediaIoBaseUpload(io.BytesIO(data), mimetype="application/zip", resumable=False) + body: Dict[str, Any] = {"name": filename} + if folder_id: + body["parents"] = [folder_id] + try: + file = service.files().create(body=body, media_body=media, fields="id").execute() + return file.get("id") + except HttpError: + return None + + +def upload_all_saved_zip_backups(user_id: int) -> Tuple[int, List[str]]: + backups = backup_manager.list_backups(user_id) + uploaded = 0 + ids: List[str] = [] + for b in backups: + try: + path = getattr(b, "file_path", None) + if not path or not str(path).endswith(".zip"): + continue + with open(path, "rb") as f: + data = f.read() + fid = upload_bytes(user_id, filename=f"{getattr(b, 'backup_id', 'backup')}.zip", data=data) + if fid: + uploaded += 1 + ids.append(fid) + except Exception: + continue + return uploaded, ids + + +def create_full_backup_zip_bytes(user_id: int, category: str = "all") -> Tuple[str, bytes]: + """Creates a ZIP of user data by category and returns (filename, bytes).""" + # Collect content according to category + from database import db as _db + import zipfile + + backup_id = f"backup_{user_id}_{int(time.time())}_{category}" + buf = io.BytesIO() + with zipfile.ZipFile(buf, 'w', compression=zipfile.ZIP_DEFLATED) as zf: + files = _db.get_user_files(user_id, limit=10000) or [] + if category == "by_repo": + files = [d for d in files if any((t or '').startswith('repo:') for t in (d.get('tags') or []))] + elif category == "large": + large_files, _ = _db.get_user_large_files(user_id, page=1, per_page=10000) + for lf in large_files: + name = lf.get('file_name') or f"large_{lf.get('_id')}" + code = lf.get('code') or '' + zf.writestr(name, code) + # Also write metadata for large + metadata = { + "backup_id": backup_id, + "user_id": user_id, + "created_at": _now_utc().isoformat(), + "backup_type": "drive_manual_large", + "file_count": len(large_files), + } + zf.writestr('metadata.json', json.dumps(metadata, ensure_ascii=False, indent=2)) + buf.seek(0) + return f"{backup_id}.zip", buf.getvalue() + elif category == "other": + files = [d for d in files if not any((t or '').startswith('repo:') for t in (d.get('tags') or []))] + # default include files + for doc in files: + name = doc.get('file_name') or f"file_{doc.get('_id')}" + code = doc.get('code') or '' + zf.writestr(name, code) + metadata = { + "backup_id": backup_id, + "user_id": user_id, + "created_at": _now_utc().isoformat(), + "backup_type": f"drive_manual_{category}", + "file_count": len(files), + } + zf.writestr('metadata.json', json.dumps(metadata, ensure_ascii=False, indent=2)) + buf.seek(0) + return f"{backup_id}.zip", buf.getvalue() + + +def perform_scheduled_backup(user_id: int) -> bool: + """Runs a scheduled full backup to Drive.""" + fn, data = create_full_backup_zip_bytes(user_id, category="all") + fid = upload_bytes(user_id, fn, data) + return bool(fid) +