From 5a40559ab65ba4ef95bbaf30dc7685d8b77bbb48 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 31 Aug 2025 18:18:25 +0000 Subject: [PATCH 1/2] Add Google Drive integration with OAuth and backup features Co-authored-by: amirbiron --- config.py | 10 ++ conversation_handlers.py | 2 +- database/manager.py | 16 ++ database/repository.py | 91 ++++++++++ handlers/drive/menu.py | 254 +++++++++++++++++++++++++++ i18n/strings_he.py | 2 +- main.py | 33 ++++ requirements.txt | 5 + services/__init__.py | 6 +- services/google_drive_service.py | 290 +++++++++++++++++++++++++++++++ 10 files changed, 706 insertions(+), 3 deletions(-) create mode 100644 handlers/drive/menu.py create mode 100644 services/google_drive_service.py 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..77eb97bf2 --- /dev/null +++ b/services/google_drive_service.py @@ -0,0 +1,290 @@ +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: + q = f"name = '{name.replace("'", "\\'")}' and mimeType = 'application/vnd.google-apps.folder' and trashed = false" + if parent_id: + q += f" and '{parent_id}' 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) + From bfb2371afb2131ef3b9053a7c73ad967c34e6a14 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 31 Aug 2025 18:38:20 +0000 Subject: [PATCH 2/2] Fix Google Drive folder search query escaping and formatting Co-authored-by: amirbiron --- __pycache__/bot_handlers.cpython-313.pyc | Bin 33111 -> 33111 bytes __pycache__/code_processor.cpython-313.pyc | Bin 36688 -> 36688 bytes __pycache__/config.cpython-313.pyc | Bin 3542 -> 4241 bytes .../conversation_handlers.cpython-313.pyc | Bin 139925 -> 139954 bytes __pycache__/file_manager.cpython-313.pyc | Bin 26891 -> 26891 bytes .../github_menu_handler.cpython-313.pyc | Bin 287645 -> 287521 bytes __pycache__/integrations.cpython-313.pyc | Bin 24780 -> 24780 bytes __pycache__/main.cpython-313.pyc | Bin 104311 -> 106263 bytes __pycache__/search_engine.cpython-313.pyc | Bin 29533 -> 29533 bytes __pycache__/utils.cpython-313.pyc | Bin 44315 -> 44315 bytes services/google_drive_service.py | 6 ++++-- 11 files changed, 4 insertions(+), 2 deletions(-) diff --git a/__pycache__/bot_handlers.cpython-313.pyc b/__pycache__/bot_handlers.cpython-313.pyc index 0e2271d5f255dd567caa307263ec9199811ec718..e7097cc47bbcf52169a2d40407c426095f0c3bd8 100644 GIT binary patch delta 21 bcmccK#B{xhiR&{jFBbz4*z|7X@@)hFOP>ZR delta 21 bcmccK#B{xhiR&{jFBbz4$fs`P@@)hFN+|{9 diff --git a/__pycache__/code_processor.cpython-313.pyc b/__pycache__/code_processor.cpython-313.pyc index 27bd4b3b0ca42a0bc16d9fd3ab6ed9199c148d7f..58d8da05d50b028ade88c7204b75736a7528326a 100644 GIT binary patch delta 21 bcmcaGkLkiZCa%xCyj%=GVAH#i%dH;(PMQW= delta 21 bcmcaGkLkiZCa%xCyj%=GAfLLC%dH;(O(X^u diff --git a/__pycache__/config.cpython-313.pyc b/__pycache__/config.cpython-313.pyc index 756c95e3d57f3a35637b358b92f495bab7e341ba..0fb8b7ceabc6c8d3af4261dd812fbce16bd5255e 100644 GIT binary patch delta 1355 zcmZ9L-A^M`7{<@EGt-uSE&Z5w+L?X<3saW1NZ4IgC<5DsrYjW&bn9Yj1r}<+;*?$7 z8;O_3Y+RTau-T+sZ%7t{7beCH{0HN9qS!0<%6NAaG{%_dIU}&uN#;H0`91H)Ois>s z{*AEh*lKM8=sEoK%f$z_4ciyGSBQ~;Nc@69Xx%>~{vy`v7e=$$(VMCK$jx+Wd@7&5UU&VMh^dju z)YPBR#nsi7^0jz;ZEY>KP%14f%@gu zCC>9K8nHAGtNg&2#L0HF>6CvrI50bCgKQPt9!{KIgi5sZ^ND&z9c1Q(TbV zKm^nBPx9a9>+8P)r-lB(;fH#(K6eEnh1L@qc-XDtW^u=^bPub{2sB`71WxysuqkY3 zR7VFiVbpY*k9fMYkY{17=*$S() ztoN$yWoXCn0`T}AMXEwY*vanQs`-*CGYFfp;{aUnkyO23xvvNVU)`$(Z>auJ=){%_ zu>Pv4Vp1YKdvB@U5$ML00l{#!SSczUg9n@%7>6EAz2{T4p)6(@9|(l1xk^rHA5i%u z^kc{YK3Gjx(#pk`m4Twly$gAa10dS9JNYE{IHz36DYxIQbrn?M9T>!32=$hqTRhok z>;w#B5_!=C0+3wModCeUuY)bvYy}sEUB?slF{|`UDB`V}kW<^7SuALO=b&R_uOel4WSGgdXc!7}@BBUjf5VGG)1Tex;ILk*xG?ApZ(#I1;_ zR3Tk&!Ks~nQExS^9VXx&>!A&V7u!*RA(DBcbuW4 zS&!yD^q`3P_*POaYL_%$oFU7QMrSF0O+Jd_@KDzRUBYRgDjS0i5srI^sMF2qUJ4&U z#nOK1yejB6D)1C`3I~OgLZEO_oSG`2ue=cC3$M|9}}jSxgtwMl4YZ;_yvU61{Ty`pWW%84MSP z3PZ-3%T)k+pD;B5Li3E-6kBtDkl)Eh$B3bgVKs@H?(KzK4z3DZEO+otRGWSS)m*U! zdhQDM?-Ke+aQurZwkwb3rWR(Wd-XdcK_4vBLr}Fq2)_e<7ese~Qsn?+|IIW*p$Z6U c77|tu!Z8?t-0S2|K+Ik@s25*`UL3Ce7j1~pOaK4? diff --git a/__pycache__/conversation_handlers.cpython-313.pyc b/__pycache__/conversation_handlers.cpython-313.pyc index 7897ba0d15e1282ba6e8633f441206d207beafdd..e76839749323ce4ce30a8cc4fe524443df20db1c 100644 GIT binary patch delta 1009 zcmYk*TTc@~6bJC#W!YIY6HS_?DRiZ=DqzuVw8jgl6)LqYw{oxwEYRCpsMX3Sz8d3$ zi7#qagCD?mqmn>;@`d;)AHnzqH1XN!a9lgHNs}|b|C!TvGxK`K{IFxvUzF}s`19(; z=E|p+G^{>*-}gm51V?Sn*4dzA9ai8N^g%xiobO;MIPP>o-20|kp}}sYp|cTNffEIl zjl!*V77Cqo5)g0_NfYe7u7=9epg_WFYF9h|Pl%CXdxu3&;j|l)oJP4RNfKpFk~1jt zLcFgX23ZhmdKNnzNzS1x`jXINWJ#pi3z#nRUptg9<;tuaRd_wj>k6x2eUaByR>k@f zuWPJ^^<`eW%;ojJfB2bC7VpHOS8(!}_%1qvl96N-<(edEl&lc%U8g~EVok@eBQHq? zW!#s8MvP2IX%^E-NpdJtlH^gQB^gI42=V?H2AL6SI*A=1$rQ@0FC`Kq*QK<8X^(9Ka!0J`9CqK8_RpiVgdA8v zxhJ3H9&whPy_BEjH8~H9e9_ODm`Im!;{CRS>}ALH??wr;2TrNTc|p49NxxR|Wh z)q(oQ&9379NPu6_?{6Oe*nS?(v|9D18%?v-hwi$?ePivdYCVYupT>jgRy?R3G1Sz? VbdUC%bnixncJ<+{LPSd&e*u+k2Oj_c delta 1013 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 G2mc?&Z%-ot delta 23107 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 G(EkU|E_cKL diff --git a/__pycache__/integrations.cpython-313.pyc b/__pycache__/integrations.cpython-313.pyc index e10935c38973eaf55827ba8ce368c67026b0f7f3..cbfff7d39ec89e408a75a487d517d195a7c2a414 100644 GIT binary patch delta 21 bcmX?eknzkxMy}7iyj%=GVAH#i>res!Q?mx- delta 21 bcmX?eknzkxMy}7iyj%=GAfLLC>res!QauKr diff --git a/__pycache__/main.cpython-313.pyc b/__pycache__/main.cpython-313.pyc index f48c1d9b5a2a96ad474cf7fa04fe2723df9ccf3c..243aa3a0a295a70d4eb8cabaa565731f0c695047 100644 GIT binary patch delta 9411 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%0Aw`^4gw29(T1;20osp|0f$ zbr}T?<|WWiz>h$&-~t@O&LHNn0N!NZJW+0>Ad6RP%guSfLyK>oG?eraM)0_ak}MmyK?y)LP-;(dt~V(_G*%Nl3G4mk0}sj z19Y)ut7%|v^P;~^8^xfC+fKCC62EF&k=IOQTL;QxV>l8}ZZS< zTM?CihUVWv{IHM-ZN*jr+mARKuzucs_o-|rc19^#)Reo>H|nhK9Xe$dBsf9+N z_YrOl-*tPs*C?AS>D6|r`^AAJW>KSh1mZz;y8Cvpm z!p2P6t#Px%(Z056qkT&`Gg4n$=3sqtj}F!tA0XwbI*J-Ckzb64w8>2d$OI^y++~D* zfDhzfN5LC0LZ*Hg&l6iW`M3%G1n{AJTQtatH_|H3kdQxP%cf%ka-9V(6}?LMUlRBg zg4rby;cJ9Fk6=XZi@^mwBcF+ZJ*sZcP3FWxAHaF}Kpflx$#P6Q)Ef^G+kwf=@i3-^ z-^;HqfTOTT-k1o>lC&sCv4n3S!7)56$&;rN;d21Dd?Fd{O%U;vf&!Z%uwP!20$$_a zN#cLYkEOu(p+>Gwg@eXRgt}jTF%?!Xm}OG*6hx5Alm?68sJtQ#cGWtG@*n{b%tc(J zEmutVMmCCdTX5c)5mU!uo>8mycKKo&v>8R^*d&*xL;h;9dG*Av!)pm%|J-ijdy3wR z?{kO@GDVLMnr_92H$!}8W#?(`Df#+zuv%Uxt*Qv*$WNw2GK|Qtq(g7a2Z+MV#UzQn zE0<*eOQ6SBkxv+1yx9Zt6B&@6*+^|pU^fYApq3iY<@Yk+TKHIAlLnlTml^=mL2;(>FLu5AMQ- z(Q{_Q8^@^2h10VHA~fI>W5+9i$k`<=|qgtNz!;j+jiQ30O^T`S0bEsy2Fel;?+ z%U|X}0UVsnvBL8!EG|;-Q?lY`@&{d5cT)DPqnb>uHS~d9o{`UJyQlf|-P5vD&>$!5F!|>mm;p$de0vMLtp}GpxgD+n=k0y4D(WGW z*@Zq(+XweoJVM4H71fH23M-s%HZZUx8sAUziCo$*$2(w~QIuZ0EjJ#O-^VGUjJRQeZY^JWvvsn<3CFcYu>n<+&%5A< zu;g{*dziwys5H#@LB->8qH_wzzwBa)=jEgXt6-h{GYJ;8QJuoRK%l^Wk)>=}jBLI! z#o+Rf`hqA?WKMApkEfGQA8SVYzNTi)n_tOAY!ah$}b4`Bp#-^^V zo33Ur5b{L=q$6HyPEecNmgOK&k`1GH;#kCozoI#kXBB};0%F>3YW;zL@TMlB z6FI?5Epd6FFA?g~QaiOe5GV_zfdPE-qIbr~=RG=-_?hVP+DFmJoeT;V(hL~UM%hNB z3K#U3yWS>QW*F82lg~2vSZ@)l7G8cXg zYxjd0(qAF@FB5o@z*z+JQLaVqKLD#Io+cc<2QXpML~1QXMHt;x*?I!k6KEws6)$0fq%_8%QQh6>=io8IVLS`kP21jDQ@~w@L40cI=^eANE0s7CQPz5hf z!ZBE(T{9p~$v@5zXn_onb0`PB7rLzmctBD7=&J}O?Z)yomh z7SIy)!abNCEyD~1NT<u_N+`A)JN4OY+EZ*okNT?c<>6u*iMpCg`2GiKHsir86lLB+yGeU1^o*X!{O&V9*MeSRMiBnWrMrxBlz&a&T!7eR5vJve&V9qVFzQZ6-3GVwJ( zz$iVkd*yStV!ZuYuH{(vJSFeokOs~2J`RhZPQDM38Xow7>mIZ0akiho|>?FVl93vq7Ot=%B zV>N^cM-b+p4JetSbWlt+coW2LDRrzEsd0^(z>eE*gFUcnGI$%@0I{xADEUM~@UN|< fvRQ_!p;F!`L)wC<*jByf7kXoBobDH~fbjnSjpeE3 delta 8271 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 diff --git a/__pycache__/search_engine.cpython-313.pyc b/__pycache__/search_engine.cpython-313.pyc index 7e6d3c0c9067accc490d945039b75396dd8551b6..3d5366c7ab5f1f127e695afb464c3f09c73dd4e4 100644 GIT binary patch delta 21 bcmccnjPdR>My}7iyj%=GVAH#iE4UZ{TG$5k delta 21 bcmccnjPdR>My}7iyj%=GAfLLCE4UZ{Sz-pS diff --git a/__pycache__/utils.cpython-313.pyc b/__pycache__/utils.cpython-313.pyc index c7c488e04170b0865da4ffc5f3b825f4b4e87f7b..4c9e74d853139c99456f72e2305ba7370d1844cf 100644 GIT binary patch delta 21 bcmbPzi)r>PCa%xCyj%=GVAH#iOK>d!PUi+v delta 21 bcmbPzi)r>PCa%xCyj%=GAfLLCOK>d!O>qVd diff --git a/services/google_drive_service.py b/services/google_drive_service.py index 77eb97bf2..8fbc79187 100644 --- a/services/google_drive_service.py +++ b/services/google_drive_service.py @@ -147,9 +147,11 @@ def ensure_folder(user_id: int, name: str, parent_id: Optional[str] = None) -> O if not service: return None try: - q = f"name = '{name.replace("'", "\\'")}' and mimeType = 'application/vnd.google-apps.folder' and trashed = false" + safe_name = name.replace("'", "\\'") + q = "name = '{0}' and mimeType = 'application/vnd.google-apps.folder' and trashed = false".format(safe_name) if parent_id: - q += f" and '{parent_id}' in parents" + 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: