diff --git a/conversation_handlers.py b/conversation_handlers.py index 1675f6f0a..780754cf0 100644 --- a/conversation_handlers.py +++ b/conversation_handlers.py @@ -22,7 +22,7 @@ filters, ) -from file_manager import backup_manager +from file_manager import backup_manager, skill_manager # Reporter מוזרק בזמן ריצה כדי להימנע מפתיחת חיבור בעת import class _NoopReporter: def report_activity(self, user_id): @@ -877,6 +877,89 @@ def _cleanup_zip_state(context: ContextTypes.DEFAULT_TYPE) -> None: context.user_data.pop(key, None) +async def _handle_zip_route(update: Update, context: ContextTypes.DEFAULT_TYPE, data: str) -> None: + """מטפל בבחירת יעד ל-ZIP שהועלה: '📝 סקיל' (אחסון קבוע as-is) או '📦 גיבוי' (רשימת הגיבויים). + + ה-bytes נטענים מהקובץ הזמני שנשמר ב-_maybe_store_zip_copy; השמירה הכבדה רצה ב-thread נפרד + כדי לא לחסום את לולאת האירועים. + """ + query = update.callback_query + await query.answer() + from utils import load_pending_zip_bytes, cleanup_pending_zip + + token = data.partition(":")[2] + user_id = update.effective_user.id + pending = context.user_data.get("pending_zip") or {} + entry = pending.get(token) + raw = None + if entry: + raw = await asyncio.to_thread(load_pending_zip_bytes, (entry or {}).get("path", "")) + + if not entry or raw is None: + # הטוקן פג/נוקה (או שהבוט אותחל) — אין bytes לשחזר + await TelegramUtils.safe_edit_message_text( + query, "⌛ הקובץ פג. שלח/י אותו שוב כדי לבחור סקיל או גיבוי." + ) + if entry: + cleanup_pending_zip((entry or {}).get("path", "")) + pending.pop(token, None) + return + + original_name = entry.get("original_name") or "upload.zip" + + if data.startswith("zip_route_skill:"): + # ספירת קבצים לתצוגה בלבד (קריאה; אינה משנה את ה-bytes הנשמרים) + file_count = 0 + try: + import zipfile as _zipfile + with _zipfile.ZipFile(BytesIO(raw)) as _zf: + file_count = sum(1 for n in _zf.namelist() if not n.endswith("/")) + except Exception: + file_count = 0 + md = {"user_id": user_id, "original_name": original_name, "file_count": file_count} + skill_id = await asyncio.to_thread(skill_manager.save_skill_bytes, raw, md) + if skill_id: + # ניקוי רק לאחר שמירה מוצלחת — בכשל שומרים את ה-token/bytes כדי לאפשר retry + cleanup_pending_zip(entry.get("path", "")) + pending.pop(token, None) + await TelegramUtils.safe_edit_message_text( + query, + f"✅ נשמר כסקיל: {html_escape(original_name)}\n" + "🔎 ניתן למצוא אותו תחת: '📚' ← '📝 סקילים'.", + parse_mode=ParseMode.HTML, + ) + else: + await TelegramUtils.safe_edit_message_text( + query, "❌ שמירת הסקיל נכשלה. נסה/י שוב מאוחר יותר." + ) + return + + # zip_route_backup — לוגיקת הגיבוי המקורית (save_backup_bytes מזריק metadata.json בעצמו) + backup_id = f"upload_{user_id}_{int(time.time())}" + md = { + "backup_id": backup_id, + "backup_type": "generic_zip", + "user_id": user_id, + "created_at": datetime.now(timezone.utc).isoformat(), + "original_filename": original_name, + "source": "uploaded_document", + } + result_id = await asyncio.to_thread(backup_manager.save_backup_bytes, raw, md) + if result_id: + # ניקוי רק לאחר שמירה מוצלחת — בכשל שומרים את ה-token/bytes כדי לאפשר retry + cleanup_pending_zip(entry.get("path", "")) + pending.pop(token, None) + await TelegramUtils.safe_edit_message_text( + query, + "✅ קובץ ZIP נשמר בהצלחה לרשימת ה‑ZIP השמורים.\n" + "📦 ניתן למצוא אותו תחת: '📚' ← '📦 קבצי ZIP' או ב‑Batch/GitHub." + ) + else: + await TelegramUtils.safe_edit_message_text( + query, "❌ שמירת הגיבוי נכשלה. נסה/י שוב מאוחר יותר." + ) + + async def finalize_zip_create(update: Update, context: ContextTypes.DEFAULT_TYPE, zip_name: Optional[str] = None) -> None: """בונה ZIP מהקבצים שנאספו ושולח למשתמש. @@ -987,6 +1070,7 @@ async def show_all_files(update: Update, context: ContextTypes.DEFAULT_TYPE) -> [InlineKeyboardButton("🔎 חפש קובץ", callback_data="search_files")], [InlineKeyboardButton("🗂 לפי ריפו", callback_data="by_repo_menu")], [InlineKeyboardButton("📦 קבצי ZIP", callback_data="backup_list")], + [InlineKeyboardButton("📝 סקילים", callback_data="skill_list")], [InlineKeyboardButton("📂 קבצים גדולים", callback_data="show_large_files")], [InlineKeyboardButton("📁 שאר הקבצים", callback_data="show_regular_files")], [InlineKeyboardButton("⭐ מועדפים", callback_data="show_favorites")], @@ -1057,6 +1141,7 @@ async def show_all_files_callback(update: Update, context: ContextTypes.DEFAULT_ keyboard = [ [InlineKeyboardButton("🗂 לפי ריפו", callback_data="by_repo_menu")], [InlineKeyboardButton("📦 קבצי ZIP", callback_data="backup_list")], + [InlineKeyboardButton("📝 סקילים", callback_data="skill_list")], [InlineKeyboardButton("📂 קבצים גדולים", callback_data="show_large_files")], [InlineKeyboardButton("📁 שאר הקבצים", callback_data="show_regular_files")], [InlineKeyboardButton("⭐ מועדפים", callback_data="show_favorites")], @@ -3822,6 +3907,10 @@ async def handle_callback_query(update: Update, context: ContextTypes.DEFAULT_TY await query.edit_message_text("⏳ יוצר ZIP…") await finalize_zip_create(update, context, zip_name=None) return ConversationHandler.END + elif data.startswith("zip_route_skill:") or data.startswith("zip_route_backup:"): + # בחירת יעד ל-ZIP שהועלה: סקיל (אחסון קבוע) או גיבוי (רשימת הגיבויים) + await _handle_zip_route(update, context, data) + return ConversationHandler.END elif data.startswith("replace_") or data == "rename_file" or data == "cancel_save": return await handle_duplicate_callback(update, context) diff --git a/docs/environment-variables.rst b/docs/environment-variables.rst index 91d1deab4..a91ebed05 100644 --- a/docs/environment-variables.rst +++ b/docs/environment-variables.rst @@ -17,11 +17,11 @@ - דוגמה - רכיב * - ``BOT_TOKEN`` - - טוקן הבוט מ-BotFather + - טוקן הבוט מ-BotFather (נדרש גם ב-WebApp לאימות Telegram Login) - כן - - - ``123456:ABC-DEF...`` - - Bot + - Bot/WebApp * - ``MONGODB_URL`` - חיבור ל-MongoDB - כן @@ -899,11 +899,11 @@ - ``srv-xxxx`` - Reporter * - ``ENABLE_INTERNAL_SHARE_WEB`` - - הפעלת שירות שיתוף פנימי + - הפעלת שירות שיתוף פנימי בתוך תהליך הבוט (נקרא ב-main.py; היסטורי — ההרצה הפנימית בוטלה וה-webserver רץ כשירות נפרד) - לא - ``false`` - ``true`` - - WebApp + - Bot * - ``PORT`` - פורט להרצת שירותים פנימיים/בדיקות - לא @@ -1145,13 +1145,13 @@ - לא - "" (ריק) - ``secret123`` - - WebApp + - Webserver * - ``SENTRY_WEBHOOK_DEDUP_WINDOW_SECONDS`` - חלון דה-דופליקציה (בשניות) להתראות Sentry שמגיעות ב-Webhook כדי למנוע burst (``0`` מנטרל). - לא - ``300`` - ``600`` - - WebApp + - Webserver * - ``ALERTMANAGER_IP_ALLOWLIST`` - רשימת IPs (מופרדים בפסיק) שמורשים לצרוך את ה-webhook; נבדק מול ``X-Forwarded-For``/``remote_addr``. - לא @@ -1819,7 +1819,7 @@ - לא - ``dummy_token`` - ``test_token`` - - Bot/WebApp + - Bot * - ``DRIVE_RESCHEDULE_BOOTSTRAP_DELAY`` - כמה שניות להמתין לפני תחילת משימות ה-Drive rescheduler. - לא diff --git a/file_manager.py b/file_manager.py index 7bf55d8de..d770c2a83 100644 --- a/file_manager.py +++ b/file_manager.py @@ -11,6 +11,7 @@ import re import shutil import time +import uuid try: import gridfs # from pymongo @@ -1272,4 +1273,184 @@ def delete_backup(self, backup_id: str, user_id: int) -> bool: logger.error(f"שגיאה במחיקת גיבוי: {e}") return False + +class SkillInfo: + """מידע על סקיל שמור (ארכיון קוד לטווח ארוך, נפרד לגמרי מגיבויים)""" + def __init__(self, skill_id: str, user_id: int, created_at: datetime, file_count: int, + total_size: int, original_name: str, file_name: str, + metadata: Optional[Dict[str, Any]]): + self.skill_id = skill_id + self.user_id = user_id + self.created_at = created_at + self.file_count = file_count + self.total_size = total_size + self.original_name = original_name # השם המקורי המלא (לתצוגה) + self.file_name = file_name # שם ה-GridFS הייחודי בפועל + self.metadata = metadata + + +class SkillManager: + """מנהל אחסון סקילים — קולקציית GridFS נפרדת ("skills"), ללא retention/cleanup/restore. + + בניגוד ל-BackupManager: + - שומר את ה-bytes as-is (fs.put ישיר) בלי לפתוח/לדחוס מחדש את ה-ZIP ובלי הזרקת metadata.json. + - תמיד מונגו (GridFS), ללא תלות ב-BACKUPS_STORAGE וללא משתנה סביבה מקביל. + - אין מחיקת retention ואין restore — סקיל לא נמחק לבד לעולם. + """ + + def _get_skills_gridfs(self): + """מחזיר GridFS על קולקציית "skills" (מבודדת מ-"backups"), או None אם אין חיבור מונגו.""" + if gridfs is None: + return None + try: + mongo_db = None + if get_files_facade is not None: + mongo_db = get_files_facade().get_mongo_db() + if not mongo_db: + return None + # אוסף ייעודי "skills" — מבודד לחלוטין; cleanup_expired_backups לעולם לא נוגע בו + return gridfs.GridFS(mongo_db, collection="skills") + except Exception: + return None + + @staticmethod + def _unique_filename(original_name: str) -> str: + """שם קובץ ייחודי לאחסון: השם המקורי אחרי סניטציה + סיומת ייחוד קצרה. + + סיומת הייחוד מבטיחה ששני סקילים עם אותו שם לא ידרסו זה את זה. + """ + try: + from utils import TextUtils + cleaned = TextUtils.clean_filename(original_name or "") + except Exception: + cleaned = re.sub(r'[^\w.\-]+', '_', original_name or "").strip('._') + stem, ext = os.path.splitext(cleaned or "") + if not stem: + stem = "skill" + if not ext: + ext = ".zip" + short = uuid.uuid4().hex[:6] + return f"{stem}_{short}{ext}" + + def save_skill_bytes(self, data: bytes, metadata: Dict[str, Any]) -> Optional[str]: + """שומר סקיל (ZIP) as-is ב-GridFS "skills" ומחזיר skill_id, או None בכשל. + + לא פותח/דוחס מחדש את ה-ZIP ולא מזריק metadata.json — ה-bytes נשמרים בדיוק כפי שהתקבלו, + כך שהורדה מחזירה את הקובץ byte-for-byte. + """ + try: + fs = self._get_skills_gridfs() + if fs is None: + logger.warning("save_skill_bytes: GridFS 'skills' לא זמין") + return None + # נרמול user_id ל-int כדי ש-list_skills (שאילתת metadata.user_id כ-int) תמצא את הסקיל + raw_uid = metadata.get("user_id") + try: + user_id = int(raw_uid) + except (TypeError, ValueError): + logger.warning("save_skill_bytes: user_id לא תקין (%r)", raw_uid) + return None + original_name = metadata.get("original_name") or "skill.zip" + # מזהה לוגי ייחודי מובטח: timestamp לקריאות + uuid קצר למניעת התנגשות (כולל אותה מילישנייה) + skill_id = metadata.get("skill_id") or f"skill_{user_id}_{int(time.time())}_{uuid.uuid4().hex[:6]}" + filename = self._unique_filename(original_name) + # מטאדטה סופית — נשמרת בשכבת GridFS בלבד (לא בתוך הארכיון) + final_md = dict(metadata or {}) + final_md["user_id"] = user_id # int מנורמל (לא ה-str המקורי אם הגיע כך) + final_md["skill_id"] = skill_id + final_md["kind"] = "skill" + if not final_md.get("created_at"): + final_md["created_at"] = datetime.now(timezone.utc).isoformat() + # שמירה byte-for-byte — בלי מחיקת filename קיים (הייחודיות מובטחת ע"י סיומת הייחוד) + fs.put(data, filename=filename, metadata=final_md) + return skill_id + except Exception as e: + logger.warning(f"save_skill_bytes failed: {e}") + return None + + def list_skills(self, user_id: int) -> List[SkillInfo]: + """מחזיר את כל הסקילים של המשתמש (מטא-דאטה בלבד — Smart Projection, בלי משיכת bytes).""" + results: List[SkillInfo] = [] + try: + fs = self._get_skills_gridfs() + if fs is None: + return results + # שאילתה ממוקדת לפי בעלים (user_id נשמר תמיד כ-int בשמירה) + for fdoc in fs.find({"metadata.user_id": user_id}): + try: + md = getattr(fdoc, 'metadata', None) or {} + skill_id = md.get("skill_id") or str(getattr(fdoc, "_id", "")) + if not skill_id: + continue + created_at = None + created_str = md.get("created_at") + if created_str: + with suppress(Exception): + created_at = datetime.fromisoformat(created_str) + if not created_at: + created_at = getattr(fdoc, 'uploadDate', None) + if not created_at: + created_at = datetime.now(timezone.utc) + if created_at.tzinfo is None: + created_at = created_at.replace(tzinfo=timezone.utc) + results.append(SkillInfo( + skill_id=skill_id, + user_id=user_id, + created_at=created_at, + file_count=int(md.get("file_count") or 0), + total_size=int(getattr(fdoc, 'length', 0) or 0), + original_name=md.get("original_name") or (getattr(fdoc, 'filename', None) or skill_id), + file_name=getattr(fdoc, 'filename', None) or "", + metadata=md, + )) + except Exception: + continue + results.sort(key=lambda s: s.created_at, reverse=True) + except Exception as e: + logger.warning(f"list_skills failed: {e}") + return results + + def get_skill_bytes(self, user_id: int, skill_id: str) -> Optional[bytes]: + """מחזיר את ה-bytes המדויקים של הסקיל (אחרי אימות בעלות), או None אם לא נמצא/לא שייך.""" + try: + fs = self._get_skills_gridfs() + if fs is None: + return None + for fdoc in fs.find({"metadata.skill_id": skill_id}): + md = getattr(fdoc, 'metadata', None) or {} + owner = md.get("user_id") + if isinstance(owner, str) and owner.isdigit(): + owner = int(owner) + if owner != user_id: + continue + return fs.get(fdoc._id).read() + return None + except Exception as e: + logger.warning(f"get_skill_bytes failed: {e}") + return None + + def delete_skills(self, user_id: int, skill_ids: List[str]) -> Dict[str, Any]: + """מוחק סקילים לפי skill_id (רק של המשתמש הנוכחי). מחזיר {deleted, errors}.""" + result: Dict[str, Any] = {"deleted": 0, "errors": []} + try: + fs = self._get_skills_gridfs() + if fs is None: + result["errors"].append("GridFS 'skills' unavailable") + return result + wanted = set(skill_ids or []) + for fdoc in list(fs.find({"metadata.user_id": user_id})): + try: + md = getattr(fdoc, 'metadata', None) or {} + if md.get("skill_id") not in wanted: + continue + fs.delete(fdoc._id) + result["deleted"] += 1 + except Exception as e: + result["errors"].append(str(e)) + except Exception as e: + result["errors"].append(str(e)) + return result + + backup_manager = BackupManager() +skill_manager = SkillManager() diff --git a/handlers/documents.py b/handlers/documents.py index 1a7cbe80a..68734eaf9 100644 --- a/handlers/documents.py +++ b/handlers/documents.py @@ -997,54 +997,71 @@ async def _maybe_store_zip_copy( if not (is_zip_hint and is_zip_actual): return False - backup_id = f"upload_{update.effective_user.id}_{int(datetime.now(timezone.utc).timestamp())}" - target_path = backup_manager.backup_dir / f"{backup_id}.zip" + # ZIP זוהה — במקום שמירה אוטומטית לגיבוי, בקש מהמשתמש לבחור יעד: סקיל או גיבוי. + # ה-bytes נשמרים כמו שהם בקובץ זמני; הבחירה בפועל מתבצעת ב-callback (בחירה מפורשת בלבד). + from utils import ( + stash_pending_zip_bytes, + cleanup_pending_zip, + cleanup_stale_pending_zips, + PENDING_ZIP_TTL_SECONDS, + ) + import uuid as _uuid + + original_name = document.file_name or "upload.zip" + + # ניקוי עצל: קבצים ממתינים ישנים (שלא נבחרו) — גם על הדיסק וגם ב-user_data try: - try: - with zipfile.ZipFile(BytesIO(raw_bytes), "r") as ztest: - try: - ztest.getinfo("metadata.json") - md_bytes = raw_bytes - except KeyError: - md = { - "backup_id": backup_id, - "backup_type": "generic_zip", - "user_id": update.effective_user.id, - "created_at": datetime.now(timezone.utc).isoformat(), - "original_filename": document.file_name, - "source": "uploaded_document", - } - out_buf = BytesIO() - with zipfile.ZipFile(out_buf, "w", compression=zipfile.ZIP_DEFLATED) as zout: - for name in ztest.namelist(): - zout.writestr(name, ztest.read(name)) - zout.writestr("metadata.json", json.dumps(md, indent=2)) - md_bytes = out_buf.getvalue() - except Exception: - md_bytes = raw_bytes + cleanup_stale_pending_zips() + except Exception: + pass + pending = context.user_data.setdefault("pending_zip", {}) + try: + now_ts = int(datetime.now(timezone.utc).timestamp()) + stale_tokens = [ + t for t, m in pending.items() + if now_ts - int((m or {}).get("ts", 0)) > PENDING_ZIP_TTL_SECONDS + ] + for _old in stale_tokens: + cleanup_pending_zip((pending.get(_old) or {}).get("path", "")) + pending.pop(_old, None) + # הגבלת מספר ה-ZIP הממתינים למשתמש — הסרת הישנים ביותר מעבר לקיבולת + MAX_PENDING = 5 + if len(pending) >= MAX_PENDING: + oldest = sorted(pending.items(), key=lambda kv: int((kv[1] or {}).get("ts", 0))) + for _old, _meta in oldest[: len(pending) - MAX_PENDING + 1]: + cleanup_pending_zip((_meta or {}).get("path", "")) + pending.pop(_old, None) + except Exception: + pass - try: - backup_manager.save_backup_bytes( - md_bytes, - { - "backup_id": backup_id, - "backup_type": "generic_zip", - "user_id": update.effective_user.id, - "created_at": datetime.now(timezone.utc).isoformat(), - "original_filename": document.file_name, - "source": "uploaded_document", - }, - ) - except Exception: - with open(target_path, "wb") as fzip: - fzip.write(md_bytes) - await update.message.reply_text( - "✅ קובץ ZIP נשמר בהצלחה לרשימת ה‑ZIP השמורים.\n" - "📦 ניתן למצוא אותו תחת: '📚' > '📦 קבצי ZIP' או ב‑Batch/GitHub." - ) - return True + token = _uuid.uuid4().hex + try: + path = stash_pending_zip_bytes(raw_bytes, token) except Exception as err: - logger.warning("Failed to persist uploaded ZIP: %s", err) + logger.warning("Failed to stash pending ZIP: %s", err) + return False + pending[token] = { + "path": path, + "original_name": original_name, + "size": len(raw_bytes), + "ts": int(datetime.now(timezone.utc).timestamp()), + } + + keyboard = InlineKeyboardMarkup([ + [ + InlineKeyboardButton("📝 סקיל", callback_data=f"zip_route_skill:{token}"), + InlineKeyboardButton("📦 גיבוי", callback_data=f"zip_route_backup:{token}"), + ] + ]) + await update.message.reply_text( + f"📦 קיבלתי קובץ ZIP: {html_escape(original_name)}\n" + "איך לשמור אותו?\n\n" + "📝 סקיל — אחסון קבוע, בדיוק כמו שהוא (byte-for-byte), בלי מחיקה אוטומטית.\n" + "📦 גיבוי — נשמר לרשימת הגיבויים (כפוף למדיניות ניקוי).", + reply_markup=keyboard, + parse_mode=ParseMode.HTML, + ) + return True except Exception: pass return False diff --git a/main.py b/main.py index 7603f5bf5..6f77f1d95 100644 --- a/main.py +++ b/main.py @@ -148,6 +148,7 @@ def _observability_attr(name: str, default): # ייבוא דחוי של ה-activity_reporter בתוך ה-run-time בלבד כדי למנוע יצירת חיבורים בזמן import from github_menu_handler import GitHubMenuHandler from backup_menu_handler import BackupMenuHandler +from skill_menu_handler import SkillMenuHandler from handlers.drive.menu import GoogleDriveMenuHandler from handlers.drive.utils import extract_schedule_key as drive_extract_schedule_key def get_drive_handler_from_application(application: Application) -> tuple[Any, bool]: @@ -3487,6 +3488,11 @@ async def show_backup_menu(update: Update, context: ContextTypes.DEFAULT_TYPE): await backup_handler.show_backup_menu(update, context) self.application.add_handler(CommandHandler("backup", show_backup_menu)) self.application.add_handler(CallbackQueryHandler(backup_handler.handle_callback_query, pattern=r'^(backup_|backup_add_note:.*)')) + + # יצירת SkillMenuHandler ורישום ה-callbacks שלו (סוג קובץ "סקיל" — אחסון נפרד מגיבויים) + skill_handler = SkillMenuHandler() + self.application.bot_data['skill_handler'] = skill_handler + self.application.add_handler(CallbackQueryHandler(skill_handler.handle_callback_query, pattern=r'^skill_')) # הוסף את ה-callbacks של GitHub - חשוב! לפני ה-handler הגלובלי self.application.add_handler( @@ -3585,8 +3591,28 @@ async def handle_github_text(update: Update, context: ContextTypes.DEFAULT_TYPE) context.user_data['suppress_code_hint_once'] = True else: await update.message.reply_text("❌ שמירת ההערה נכשלה") - except Exception as e: - await update.message.reply_text(f"❌ שגיאה בשמירת ההערה: {e}") + except Exception: + logger.exception("שמירת הערה נכשלה") + await update.message.reply_text("❌ שמירת ההערה נכשלה, נסה שוב מאוחר יותר") + return True + # זרימת הוספת הערה לסקיל (אותו מנגנון גנרי כמו הגיבויים, עם skill_id) + if context.user_data.get('waiting_for_skill_note_for'): + skill_id = context.user_data.pop('waiting_for_skill_note_for') + try: + from database import db + ok = db.save_backup_note(update.effective_user.id, skill_id, (text or '')[:1000]) + if ok: + await update.message.reply_text( + "✅ ההערה נשמרה!", + reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("🔙 חזרה", callback_data=f"skill_details:{skill_id}")]]) + ) + # מנע הודעת "נראה שזה קטע קוד!" עבור ההודעה הזו + context.user_data['suppress_code_hint_once'] = True + else: + await update.message.reply_text("❌ שמירת ההערה נכשלה") + except Exception: + logger.exception("שמירת הערה נכשלה") + await update.message.reply_text("❌ שמירת ההערה נכשלה, נסה שוב מאוחר יותר") return True # קלט נתיב יעד ידני לסביבת העלאה (upload_folder_custom) if context.user_data.get('waiting_for_upload_folder'): diff --git a/services/config_inspector_service.py b/services/config_inspector_service.py index 698fc59f0..07305d7ba 100644 --- a/services/config_inspector_service.py +++ b/services/config_inspector_service.py @@ -15,12 +15,16 @@ from enum import Enum from typing import Any, Dict, List, Optional +# כתובת URL עם credentials מוטמעים: scheme://user:pass@host — סוד גם אם שם המשתנה אינו רגיש +_CREDENTIAL_URL_RE = re.compile(r"://[^/\s:@]+:[^/\s:@]+@") + class ConfigStatus(str, Enum): """סטטוס של משתנה קונפיגורציה.""" DEFAULT = "Default" # משתמש בערך ברירת המחדל - MODIFIED = "Modified" # ערך שונה מברירת המחדל + MODIFIED = "Modified" # ערך שונה מברירת מחדל שקיימת בקוד + SET = "Set" # הוגדר בסביבה (למשל ברנדר) כשאין ברירת מחדל בקוד — לא "שונה" MISSING = "Missing" # משתנה לא מוגדר וגם אין דיפולט @@ -41,6 +45,12 @@ class ConfigDefinition: category: str = "general" sensitive: bool = False # האם להסתיר את הערך required: bool = False # האם המשתנה הכרחי + # לאילו שירותי Render המשתנה שייך (משתנה יכול להיות מוגדר בכמה שירותים במקביל). + # "webapp" ⇒ מוצג בעמוד הראשי עם Status/Active Value (ה-inspector רץ בתהליך ה-webapp). + # כל שירות אחר ("bot"/"mcp"/"webserver"/"scripts") ⇒ השורה מופיעה גם בעמוד + # "שירותים אחרים" עם ציון השירותים — בלי Status/Active Value, כי הערכים שם חיים + # בתהליכים נפרדים. הערה: ה-webserver הוא שירות Render נפרד (לא חלק מתהליך הבוט). + services: tuple[str, ...] = ("webapp",) @dataclass @@ -65,6 +75,7 @@ class ConfigOverview: generated_at: str = "" total_count: int = 0 modified_count: int = 0 + set_count: int = 0 missing_count: int = 0 default_count: int = 0 categories: List[str] = field(default_factory=list) @@ -80,13 +91,15 @@ class ConfigService: """ # מילים רגישות בשמות משתנים - ערכים אלו יוסתרו + # הערה: "URL" הוסר בכוונה — כתובת ציבורית (MCP_SERVER_URL, WEBAPP_URL, PROMETHEUS_URL...) + # אינה סוד ומיסוכה מטעה. URL שמכיל credentials (כמו MONGODB_URL) מסומן sensitive=True + # מפורשות בהגדרתו. "URI" נשאר כי URI-חיבור בדרך כלל מגלם סיסמה. SENSITIVE_PATTERNS: tuple[str, ...] = ( "TOKEN", "KEY", "PASSWORD", "SECRET", "URI", - "URL", "CREDENTIALS", "API_KEY", "AUTH", @@ -104,6 +117,7 @@ class ConfigService: CONFIG_DEFINITIONS: Dict[str, ConfigDefinition] = { "MONGODB_URL": ConfigDefinition( key="MONGODB_URL", + services=("webapp", "bot", "mcp", "webserver"), default="", description="כתובת חיבור ל-MongoDB (חובה)", category="database", @@ -112,78 +126,91 @@ class ConfigService: ), "DATABASE_NAME": ConfigDefinition( key="DATABASE_NAME", + services=("webapp", "bot", "mcp", "webserver"), default="code_keeper_bot", description="שם מסד הנתונים ב-MongoDB", category="database", ), "MONGODB_MAX_POOL_SIZE": ConfigDefinition( key="MONGODB_MAX_POOL_SIZE", + services=("webapp", "bot", "mcp", "webserver"), default="50", description="מספר חיבורים מקסימלי לפול MongoDB", category="database", ), "MONGODB_MIN_POOL_SIZE": ConfigDefinition( key="MONGODB_MIN_POOL_SIZE", + services=("webapp", "bot", "mcp", "webserver"), default="5", description="מספר חיבורים מינימלי לפול MongoDB", category="database", ), "MONGODB_MAX_IDLE_TIME_MS": ConfigDefinition( key="MONGODB_MAX_IDLE_TIME_MS", + services=("webapp", "bot", "mcp", "webserver"), default="30000", description="זמן סרק מקסימלי לחיבור MongoDB (מילישניות)", category="database", ), "MONGODB_WAIT_QUEUE_TIMEOUT_MS": ConfigDefinition( key="MONGODB_WAIT_QUEUE_TIMEOUT_MS", + services=("webapp", "bot", "mcp", "webserver"), default="8000", description="זמן המתנה בתור לחיבור MongoDB (מילישניות)", category="database", ), "MONGODB_SERVER_SELECTION_TIMEOUT_MS": ConfigDefinition( key="MONGODB_SERVER_SELECTION_TIMEOUT_MS", + services=("webapp", "bot", "mcp", "webserver"), default="5000", description="זמן בחירת שרת MongoDB (מילישניות)", category="database", ), "MONGODB_SOCKET_TIMEOUT_MS": ConfigDefinition( key="MONGODB_SOCKET_TIMEOUT_MS", + services=("webapp", "bot", "mcp", "webserver"), default="45000", description="טיימאאוט סוקט MongoDB (מילישניות)", category="database", ), "MONGODB_CONNECT_TIMEOUT_MS": ConfigDefinition( key="MONGODB_CONNECT_TIMEOUT_MS", + services=("webapp", "bot", "mcp", "webserver"), default="5000", description="טיימאאוט התחברות ל-MongoDB (מילישניות)", category="database", ), "MONGODB_RETRY_WRITES": ConfigDefinition( key="MONGODB_RETRY_WRITES", + services=("webapp", "bot", "mcp", "webserver"), default="true", description="הפעלת ניסיונות כתיבה חוזרים ב-MongoDB", category="database", ), "MONGODB_RETRY_READS": ConfigDefinition( key="MONGODB_RETRY_READS", + services=("webapp", "bot", "mcp", "webserver"), default="true", description="הפעלת ניסיונות קריאה חוזרים ב-MongoDB", category="database", ), "MONGODB_APPNAME": ConfigDefinition( key="MONGODB_APPNAME", + services=("webapp", "bot", "mcp", "webserver"), default="", description="שם האפליקציה למטא-דאטה MongoDB", category="database", ), "MONGODB_COMPRESSORS": ConfigDefinition( key="MONGODB_COMPRESSORS", + services=("webapp", "bot", "mcp", "webserver"), default="", description="דחיסנים נתמכים (zstd,snappy,zlib)", category="database", ), "DB_HEALTH_TOKEN": ConfigDefinition( key="DB_HEALTH_TOKEN", + services=("webapp", "bot", "webserver"), default="", description="טוקן אימות לבדיקות בריאות DB", category="database", @@ -191,30 +218,44 @@ class ConfigService: ), "DB_RECONNECT_WAIT_BEFORE_POLL": ConfigDefinition( key="DB_RECONNECT_WAIT_BEFORE_POLL", + services=("bot",), default="120", description="זמן המתנה ראשוני (שניות) להתחברות מחדש ל-DB בעלייה לפני מעבר ל-poll פסיבי", category="database", ), "DB_RECONNECT_POLL_INTERVAL": ConfigDefinition( key="DB_RECONNECT_POLL_INTERVAL", + services=("bot",), default="30", description="מרווח (שניות) בין בדיקות חיבור ב-poll פסיבי לאחר שחלון ההמתנה הראשוני פג", category="database", ), "DB_HEALTH_SLOW_THRESHOLD_MS": ConfigDefinition( key="DB_HEALTH_SLOW_THRESHOLD_MS", + services=("webapp", "bot", "webserver"), default="1000", description="סף לזיהוי שאילתות איטיות (מילישניות)", category="database", ), "DB_HEALTH_COLLECTIONS_COOLDOWN_SEC": ConfigDefinition( key="DB_HEALTH_COLLECTIONS_COOLDOWN_SEC", + services=("webapp", "bot", "webserver"), default="30", description="זמן קירור בין בדיקות בריאות (שניות)", category="database", ), + "BOT_TOKEN": ConfigDefinition( + key="BOT_TOKEN", + services=("webapp", "bot", "mcp", "webserver"), + default="", + description="טוקן הבוט מ-BotFather. נדרש גם בשירות ה-webapp (אימות Telegram Login ב-auth_routes).", + category="telegram", + sensitive=True, + required=True, + ), "BOT_USERNAME": ConfigDefinition( key="BOT_USERNAME", + services=("webapp", "bot", "webserver"), default="my_code_keeper_bot", description="שם המשתמש של הבוט בטלגרם", category="telegram", @@ -222,54 +263,63 @@ class ConfigService: # --- Telegram Polling / Network timeouts (stability against getUpdates conflicts) --- "TELEGRAM_CONNECT_TIMEOUT_SECS": ConfigDefinition( key="TELEGRAM_CONNECT_TIMEOUT_SECS", + services=("bot",), default="10.0", description="טיימאאוט התחברות ל-Telegram Bot API (שניות).", category="telegram", ), "TELEGRAM_POOL_TIMEOUT_SECS": ConfigDefinition( key="TELEGRAM_POOL_TIMEOUT_SECS", + services=("bot",), default="10.0", description="טיימאאוט המתנה ל-connection מה-pool (שניות) בעת קריאה ל-Telegram Bot API.", category="telegram", ), "TELEGRAM_READ_TIMEOUT_SECS": ConfigDefinition( key="TELEGRAM_READ_TIMEOUT_SECS", + services=("bot",), default="30.0", description="טיימאאוט קריאה ל-Telegram Bot API (שניות). מומלץ להיות גבוה מ-TELEGRAM_LONG_POLL_TIMEOUT_SECS.", category="telegram", ), "TELEGRAM_WRITE_TIMEOUT_SECS": ConfigDefinition( key="TELEGRAM_WRITE_TIMEOUT_SECS", + services=("bot",), default="30.0", description="טיימאאוט כתיבה ל-Telegram Bot API (שניות).", category="telegram", ), "TELEGRAM_LONG_POLL_TIMEOUT_SECS": ConfigDefinition( key="TELEGRAM_LONG_POLL_TIMEOUT_SECS", + services=("bot",), default="20", description="timeout של long-polling עבור getUpdates (שניות).", category="telegram", ), "TELEGRAM_POLL_INTERVAL_SECS": ConfigDefinition( key="TELEGRAM_POLL_INTERVAL_SECS", + services=("bot",), default="0.0", description="poll_interval בין סבבי polling (שניות). 0 = ברירת מחדל של PTB.", category="telegram", ), "TELEGRAM_CONFLICT_BACKOFF_SECS": ConfigDefinition( key="TELEGRAM_CONFLICT_BACKOFF_SECS", + services=("bot",), default="30", description="זמן המתנה (שניות) לפני retry כאשר מתקבלת שגיאת 409 Conflict ב-getUpdates.", category="telegram", ), "TELEGRAM_CONFLICT_MAX_RETRIES": ConfigDefinition( key="TELEGRAM_CONFLICT_MAX_RETRIES", + services=("bot",), default="5", description="כמה פעמים לנסות שוב (retry) אחרי 409 Conflict ב-getUpdates לפני יציאה מהתהליך כדי לשחרר lock ולאפשר recovery. 0/שלילי = ללא הגבלה (לא מומלץ).", category="telegram", ), "TELEGRAM_CONFLICT_MAX_SECONDS": ConfigDefinition( key="TELEGRAM_CONFLICT_MAX_SECONDS", + services=("bot",), default="300", description="חלון זמן מקסימלי (שניות) לרצף conflicts לפני יציאה מהתהליך כדי לשחרר lock ולאפשר recovery. 0/שלילי = ללא הגבלה (לא מומלץ).", category="telegram", @@ -277,96 +327,112 @@ class ConfigService: # --- Distributed Lock (Mongo Lease + Heartbeat) --- "SERVICE_ID": ConfigDefinition( key="SERVICE_ID", + services=("bot",), default="", description="מזהה ייחודי לשירות/סביבה עבור נעילה מבוזרת (key של מסמך הלוק). אם ריק, נופל ל-LOCK_ID המובנה.", category="locking", ), "RENDER_INSTANCE_ID": ConfigDefinition( key="RENDER_INSTANCE_ID", + services=("bot",), default="", description="מזהה אינסטנס ב-Render (נשמר במסמך הלוק לצורכי תחקור). ה-owner בפועל הוא מזהה תהליך ייחודי (RENDER_INSTANCE_ID:pid). אם ריק, owner נופל ל-hostname:pid.", category="locking", ), "RENDER_SERVICE_NAME": ConfigDefinition( key="RENDER_SERVICE_NAME", + services=("bot",), default="", description="שם השירות (label) לצורכי תחקור בלוק (host). אם ריק, נופל ל-HOSTNAME/hostname.", category="locking", ), "LOCK_LEASE_SECONDS": ConfigDefinition( key="LOCK_LEASE_SECONDS", + services=("bot",), default="10", description="משך ה-lease של הלוק (שניות).", category="locking", ), "LOCK_HEARTBEAT_INTERVAL": ConfigDefinition( key="LOCK_HEARTBEAT_INTERVAL", + services=("bot",), default="3", description="תדירות heartbeat (שניות) לרענון ה-lease. ברירת מחדל: 3 (מינימום 3).", category="locking", ), "LOCK_WAIT_FOR_ACQUIRE": ConfigDefinition( key="LOCK_WAIT_FOR_ACQUIRE", + services=("bot",), default="false", description="אם true: המתנה אקטיבית ללוק עם retries קצרים. אם false: המתנה פסיבית עם jitter (ברירת מחדל).", category="locking", ), "LOCK_ACQUIRE_MAX_WAIT": ConfigDefinition( key="LOCK_ACQUIRE_MAX_WAIT", + services=("bot",), default="0", description="מגבלת זמן (שניות) במצב המתנה אקטיבית. 0 = ללא מגבלה. (אליאס תאימות: LOCK_MAX_WAIT_SECONDS).", category="locking", ), "LOCK_WAIT_MIN_SECONDS": ConfigDefinition( key="LOCK_WAIT_MIN_SECONDS", + services=("bot",), default="15", description="מינימום זמן המתנה פסיבית עם jitter (שניות).", category="locking", ), "LOCK_WAIT_MAX_SECONDS": ConfigDefinition( key="LOCK_WAIT_MAX_SECONDS", + services=("bot",), default="45", description="מקסימום זמן המתנה פסיבית עם jitter (שניות).", category="locking", ), "LOCK_RETRY_INTERVAL_SECONDS": ConfigDefinition( key="LOCK_RETRY_INTERVAL_SECONDS", + services=("bot",), default="1", description="זמן המתנה בין ניסיונות במצב המתנה אקטיבית. (Legacy/תאימות לאחור: שימש גם קודם).", category="locking", ), "LOCK_FAIL_OPEN": ConfigDefinition( key="LOCK_FAIL_OPEN", + services=("bot",), default="false", description="אם true: במקרה חריגות ברכישת לוק, מאפשר עלייה 'ללא לוק' (לא מומלץ). ברירת מחדל false (fail-closed).", category="locking", ), "LOCK_WAIT_HEALTH_SERVER_ENABLED": ConfigDefinition( key="LOCK_WAIT_HEALTH_SERVER_ENABLED", + services=("bot",), default="true", description="אם true: בעת המתנה ללוק ותוך קיום PORT, מפעיל שרת HTTP מינימלי ל-/health כדי לעבור health checks.", category="locking", ), "LOCK_PORT_GUARD_ENABLED": ConfigDefinition( key="LOCK_PORT_GUARD_ENABLED", + services=("bot",), default="false", description="אם true: תופס פורט לוקאלי כדי למנוע שני תהליכים באותו worker. אם הפורט תפוס → יציאה.", category="locking", ), "LOCK_PORT_GUARD_PORT": ConfigDefinition( key="LOCK_PORT_GUARD_PORT", + services=("bot",), default="9999", description="פורט לוקאלי לשמירה על בלעדיות תהליך (נדרש רק אם LOCK_PORT_GUARD_ENABLED=true).", category="locking", ), "LOCK_COLLECTION": ConfigDefinition( key="LOCK_COLLECTION", + services=("bot",), default="locks", description="שם קולקציית הלוקים ב-MongoDB (ברירת מחדל legacy: locks).", category="locking", ), "ADMIN_USER_IDS": ConfigDefinition( key="ADMIN_USER_IDS", + services=("webapp", "bot", "mcp", "webserver"), default="", description="רשימת מזהי אדמינים (מופרדים בפסיקים)", category="telegram", @@ -374,12 +440,14 @@ class ConfigService: ), "PREMIUM_USER_IDS": ConfigDefinition( key="PREMIUM_USER_IDS", + services=("webapp", "bot", "webserver"), default="", description="רשימת מזהי משתמשי פרימיום", category="telegram", ), "ALERT_TELEGRAM_BOT_TOKEN": ConfigDefinition( key="ALERT_TELEGRAM_BOT_TOKEN", + services=("webapp", "bot", "mcp", "webserver"), default="", description="טוקן בוט התראות טלגרם", category="alerts", @@ -387,6 +455,7 @@ class ConfigService: ), "ALERT_TELEGRAM_CHAT_ID": ConfigDefinition( key="ALERT_TELEGRAM_CHAT_ID", + services=("webapp", "bot", "mcp", "webserver"), default="", description="מזהה צ'אט להתראות טלגרם", category="alerts", @@ -394,12 +463,14 @@ class ConfigService: ), "ALERT_TELEGRAM_MIN_SEVERITY": ConfigDefinition( key="ALERT_TELEGRAM_MIN_SEVERITY", + services=("webapp", "bot", "mcp", "webserver"), default="info", description="רמת חומרה מינימלית להתראות טלגרם", category="alerts", ), "ALERT_TELEGRAM_SUPPRESS_ALERTS": ConfigDefinition( key="ALERT_TELEGRAM_SUPPRESS_ALERTS", + services=("webapp", "bot", "mcp", "webserver"), default="AppLatencyEWMARegression", description=( "שמות alerts (מופרדים בפסיקים) שלא יישלחו לטלגרם. " @@ -409,72 +480,84 @@ class ConfigService: ), "ALERT_STARTUP_GRACE_PERIOD_SECONDS": ConfigDefinition( key="ALERT_STARTUP_GRACE_PERIOD_SECONDS", + services=("webapp", "bot", "mcp", "webserver"), default="1200", description="חלון חסד (שניות) לאחר אתחול שבו מושתקים רק alerts רועשים מתוך allowlist (Mongo/Latency/EWMA)", category="alerts", ), "ALERTS_TEXT_INCLUDE_DASHBOARD_LINK_TELEGRAM": ConfigDefinition( key="ALERTS_TEXT_INCLUDE_DASHBOARD_LINK_TELEGRAM", + services=("webapp", "bot"), default="false", description="אם true מוסיף שורת 📊 Dashboard לגוף ההודעה בטלגרם (ברירת מחדל כבוי כי יש כפתור Inline)", category="alerts", ), "ALERTS_TEXT_INCLUDE_DASHBOARD_LINK_SLACK": ConfigDefinition( key="ALERTS_TEXT_INCLUDE_DASHBOARD_LINK_SLACK", + services=("webapp", "bot"), default="true", description="אם true מוסיף שורת 📊 Dashboard לגוף ההודעה ב-Slack (ברירת מחדל פעיל)", category="alerts", ), "REDIS_MAX_CONNECTIONS": ConfigDefinition( key="REDIS_MAX_CONNECTIONS", + services=("webapp", "bot", "mcp", "webserver"), default="50", description="מספר חיבורים מקסימלי ל-Redis", category="cache", ), "REDIS_CONNECT_TIMEOUT": ConfigDefinition( key="REDIS_CONNECT_TIMEOUT", + services=("webapp", "bot", "mcp", "webserver"), default="3", description="טיימאאוט התחברות ל-Redis (שניות)", category="cache", ), "REDIS_SOCKET_TIMEOUT": ConfigDefinition( key="REDIS_SOCKET_TIMEOUT", + services=("webapp", "bot", "mcp", "webserver"), default="5", description="טיימאאוט סוקט Redis (שניות)", category="cache", ), "CACHE_ENABLED": ConfigDefinition( key="CACHE_ENABLED", + services=("webapp", "bot", "mcp", "webserver"), default="false", description="הפעלת קאשינג גלובלי", category="cache", ), "CACHE_CLEAR_BUDGET_SECONDS": ConfigDefinition( key="CACHE_CLEAR_BUDGET_SECONDS", + services=("webapp", "bot", "mcp", "webserver"), default="5", description="תקציב זמן לניקוי קאש (שניות)", category="cache", ), "CACHE_DELETE_PATTERN_BUDGET_SECONDS": ConfigDefinition( key="CACHE_DELETE_PATTERN_BUDGET_SECONDS", + services=("webapp", "bot", "mcp", "webserver"), default="5", description="תקציב זמן למחיקת תבנית מפתחות בקאש (שניות) – SCAN+DEL, למניעת תקיעה ב-Redis גדול", category="cache", ), "DISABLE_CACHE_MAINTENANCE": ConfigDefinition( key="DISABLE_CACHE_MAINTENANCE", + services=("webapp", "bot", "mcp", "webserver"), default="false", description="השבתת תחזוקת קאש אוטומטית", category="cache", ), "PORT": ConfigDefinition( key="PORT", + services=("webapp", "bot", "mcp", "webserver"), default="5000", description="פורט השרת (Render/Heroku)", category="webserver", ), "SECRET_KEY": ConfigDefinition( key="SECRET_KEY", + services=("webapp", "bot", "mcp", "webserver"), default="dev-secret-key-change-in-production", description=( "מפתח סודי לסשנים ו-CSRF. במצב OAuth של ה-MCP: חותם את זהות המשתמש בין " @@ -486,6 +569,7 @@ class ConfigService: ), "WEBAPP_LOGIN_SECRET": ConfigDefinition( key="WEBAPP_LOGIN_SECRET", + services=("webapp", "bot", "webserver"), default="", description="מפתח סודי נוסף ל-login", category="webserver", @@ -493,24 +577,28 @@ class ConfigService: ), "DEBUG": ConfigDefinition( key="DEBUG", + services=("webapp", "bot", "mcp", "webserver"), default="false", description="מצב דיבאג (true/false)", category="webserver", ), "PUBLIC_BASE_URL": ConfigDefinition( key="PUBLIC_BASE_URL", + services=("webapp", "bot", "mcp", "webserver"), default="", description="כתובת URL בסיסית לשיתוף קישורים", category="webserver", ), "PUBLIC_URL": ConfigDefinition( key="PUBLIC_URL", + services=("webapp", "bot", "mcp", "webserver"), default="https://code-keeper-webapp.onrender.com", description="כתובת בסיס ציבורית של ה-WebApp (משמשת ליצירת קישור ציבורי ל-Observability Dashboard בהתראות)", category="webserver", ), "WEBAPP_URL": ConfigDefinition( key="WEBAPP_URL", + services=("webapp", "bot", "mcp", "webserver"), default="", description="כתובת WebApp (אם שונה מ-public)", category="webserver", @@ -519,6 +607,7 @@ class ConfigService: # --- MCP Server (Claude integration) --- "MCP_SERVER_URL": ConfigDefinition( key="MCP_SERVER_URL", + services=("webapp", "bot", "mcp", "webserver"), default="", description=( "ה-URL הציבורי (https) של שירות ה-MCP. בבוט: בניית פקודת /connect_claude. " @@ -529,24 +618,28 @@ class ConfigService: ), "MCP_SERVER_NAME": ConfigDefinition( key="MCP_SERVER_NAME", + services=("mcp",), default="CodeKeeper", description="שם התצוגה של שרת ה-MCP (שם ה-Connector שמוצג ללקוח).", category="mcp", ), "MCP_ALLOWED_HOSTS": ConfigDefinition( key="MCP_ALLOWED_HOSTS", + services=("mcp",), default="", description="Host מותרים לשרת ה-MCP (CSV, תומך wildcard). ריק = הגנת DNS-rebinding כבויה (מתאים לשרת ציבורי מוגן-טוקן).", category="mcp", ), "MCP_ALLOWED_ORIGINS": ConfigDefinition( key="MCP_ALLOWED_ORIGINS", + services=("mcp",), default="", description="Origin מותרים לשרת ה-MCP (CSV). רלוונטי רק כשמפעילים הגנה דרך MCP_ALLOWED_HOSTS.", category="mcp", ), "MCP_REPO_DENYLIST_EXTRA": ConfigDefinition( key="MCP_REPO_DENYLIST_EXTRA", + services=("mcp",), default="", description=( "תבניות glob נוספות (CSV) ל-denylist הסודות של כלי דפדפן הריפו ב-MCP, " @@ -556,6 +649,7 @@ class ConfigService: ), "MCP_REPO_AUTOSYNC": ConfigDefinition( key="MCP_REPO_AUTOSYNC", + services=("mcp",), default="1", description=( "רענון אוטומטי של ה-mirrors המקומיים בשירות ה-MCP (thread רקע): " @@ -565,6 +659,7 @@ class ConfigService: ), "MCP_REPO_AUTOSYNC_INTERVAL": ConfigDefinition( key="MCP_REPO_AUTOSYNC_INTERVAL", + services=("mcp",), default="300", description="מרווח בשניות בין מעברי ה-autosync של דפדפן הריפו ב-MCP (מינימום 30).", category="mcp", @@ -573,18 +668,21 @@ class ConfigService: # --- Repo Sync Engine (Git Mirror) --- "REPO_NAME": ConfigDefinition( key="REPO_NAME", + services=("webapp", "bot", "webserver"), default="CodeBot", description="שם ריפו לוגי לשימוש ב-Repo Sync (מפתח ל-mirror בדיסק ול-metadata ב-DB).", category="repo_sync", ), "REPO_MIRROR_PATH": ConfigDefinition( key="REPO_MIRROR_PATH", + services=("webapp", "bot", "mcp", "webserver"), default="/var/data/repos", description="נתיב בסיסי בדיסק לשמירת Bare Mirror של הריפו (Repo Sync Engine).", category="repo_sync", ), "GITHUB_WEBHOOK_SECRET": ConfigDefinition( key="GITHUB_WEBHOOK_SECRET", + services=("webapp", "bot", "webserver"), default="", description="סוד לאימות GitHub Webhook (HMAC SHA256) עבור POST /api/webhooks/github (Repo Sync).", category="repo_sync", @@ -592,6 +690,7 @@ class ConfigService: ), "GITHUB_TOKEN": ConfigDefinition( key="GITHUB_TOKEN", + services=("webapp", "bot", "mcp", "webserver"), default="", description="טוקן GitHub לשימוש בפעולות API וגם לאימות clone/fetch של Repo Sync בריפו פרטי (אם רלוונטי).", category="repo_sync", @@ -599,6 +698,7 @@ class ConfigService: ), "GITHUB_TOKENS": ConfigDefinition( key="GITHUB_TOKENS", + services=("webapp", "bot", "mcp", "webserver"), default="", description=( "מיפוי בעלים(owner/org)→טוקן לסנכרון ריפואים ממספר ארגונים, כשטוקן יחיד לא מכסה את כולם. " @@ -610,12 +710,14 @@ class ConfigService: ), "BOT_JOBS_API_BASE_URL": ConfigDefinition( key="BOT_JOBS_API_BASE_URL", + services=("webapp", "bot", "webserver"), default="", description="בסיס URL ל-API הפנימי של הבוט עבור Trigger של Jobs ממסך המוניטור (WebApp -> Bot).", category="jobs_monitor", ), "BOT_API_BASE_URL": ConfigDefinition( key="BOT_API_BASE_URL", + services=("webapp", "bot", "webserver"), default="", description="Alias/תאימות לאחור ל-BOT_JOBS_API_BASE_URL (נבדק רק אם BOT_JOBS_API_BASE_URL ריק).", category="jobs_monitor", @@ -658,6 +760,7 @@ class ConfigService: ), "WEB_CONCURRENCY": ConfigDefinition( key="WEB_CONCURRENCY", + services=("webapp", "bot", "webserver"), default="1", description="מספר ה-workers של Gunicorn ב-WebApp; אם מוגדר, גובר על ברירת המחדל ומקטין queue_delay תחת עומס", category="gunicorn", @@ -706,60 +809,70 @@ class ConfigService: ), "AIOHTTP_POOL_LIMIT": ConfigDefinition( key="AIOHTTP_POOL_LIMIT", + services=("webapp", "bot", "mcp", "webserver"), default="50", description="מגבלת חיבורים ב-TCPConnector של aiohttp", category="http", ), "AIOHTTP_TIMEOUT_TOTAL": ConfigDefinition( key="AIOHTTP_TIMEOUT_TOTAL", + services=("webapp", "bot", "mcp", "webserver"), default="10", description="טיימאאוט כולל ל-aiohttp (שניות)", category="http", ), "AIOHTTP_LIMIT_PER_HOST": ConfigDefinition( key="AIOHTTP_LIMIT_PER_HOST", + services=("webapp", "bot", "mcp", "webserver"), default="25", description="מגבלת חיבורים לכל host", category="http", ), "REQUESTS_POOL_CONNECTIONS": ConfigDefinition( key="REQUESTS_POOL_CONNECTIONS", + services=("webapp", "bot", "mcp", "webserver"), default="20", description="חיבורי פול עבור requests", category="http", ), "REQUESTS_POOL_MAXSIZE": ConfigDefinition( key="REQUESTS_POOL_MAXSIZE", + services=("webapp", "bot", "mcp", "webserver"), default="100", description="גודל מקסימלי לפול requests", category="http", ), "REQUESTS_TIMEOUT": ConfigDefinition( key="REQUESTS_TIMEOUT", + services=("webapp", "bot", "mcp", "webserver"), default="8.0", description="טיימאאוט ברירת מחדל ל-requests (שניות)", category="http", ), "REQUESTS_RETRIES": ConfigDefinition( key="REQUESTS_RETRIES", + services=("webapp", "bot", "mcp", "webserver"), default="2", description="מספר ניסיונות חוזרים ב-requests", category="http", ), "REQUESTS_RETRY_BACKOFF": ConfigDefinition( key="REQUESTS_RETRY_BACKOFF", + services=("webapp", "bot", "mcp", "webserver"), default="0.2", description="פקטור backoff בין ניסיונות", category="http", ), "PUSH_NOTIFICATIONS_ENABLED": ConfigDefinition( key="PUSH_NOTIFICATIONS_ENABLED", + services=("webapp", "bot", "webserver"), default="true", description="הפעלת התראות Push", category="push", ), "VAPID_PUBLIC_KEY": ConfigDefinition( key="VAPID_PUBLIC_KEY", + services=("webapp", "bot", "webserver"), default="", description="מפתח VAPID ציבורי ל-Push", category="push", @@ -767,6 +880,7 @@ class ConfigService: ), "VAPID_PRIVATE_KEY": ConfigDefinition( key="VAPID_PRIVATE_KEY", + services=("webapp", "bot", "webserver"), default="", description="מפתח VAPID פרטי ל-Push", category="push", @@ -774,24 +888,28 @@ class ConfigService: ), "VAPID_SUB_EMAIL": ConfigDefinition( key="VAPID_SUB_EMAIL", + services=("webapp", "bot", "webserver"), default="", description="כתובת אימייל ל-VAPID", category="push", ), "SUPPORT_EMAIL": ConfigDefinition( key="SUPPORT_EMAIL", + services=("webapp", "bot", "webserver"), default="", description="כתובת אימייל תמיכה", category="push", ), "PUSH_REMOTE_DELIVERY_ENABLED": ConfigDefinition( key="PUSH_REMOTE_DELIVERY_ENABLED", + services=("webapp", "bot", "mcp", "webserver"), default="false", description="הפעלת משלוח Push מרוחק", category="push", ), "PUSH_DELIVERY_URL": ConfigDefinition( key="PUSH_DELIVERY_URL", + services=("webapp", "bot", "mcp", "webserver"), default="", description="כתובת URL למשלוח Push", category="push", @@ -799,6 +917,7 @@ class ConfigService: ), "PUSH_DELIVERY_TOKEN": ConfigDefinition( key="PUSH_DELIVERY_TOKEN", + services=("webapp", "bot", "mcp", "webserver"), default="", description="טוקן אימות למשלוח Push", category="push", @@ -806,30 +925,35 @@ class ConfigService: ), "PUSH_DELIVERY_TIMEOUT_SECONDS": ConfigDefinition( key="PUSH_DELIVERY_TIMEOUT_SECONDS", + services=("webapp", "bot", "mcp", "webserver"), default="3", description="טיימאאוט למשלוח Push (שניות)", category="push", ), "PUSH_DELIVERY_URGENCY": ConfigDefinition( key="PUSH_DELIVERY_URGENCY", + services=("webapp", "bot", "webserver"), default="high", description="רמת דחיפות ברירת מחדל ל-Push", category="push", ), "PUSH_SEND_INTERVAL_SECONDS": ConfigDefinition( key="PUSH_SEND_INTERVAL_SECONDS", + services=("webapp", "bot", "webserver"), default="60", description="מרווח שליחת Push (שניות)", category="push", ), "PUSH_CLAIM_TTL_SECONDS": ConfigDefinition( key="PUSH_CLAIM_TTL_SECONDS", + services=("webapp", "bot", "webserver"), default="60", description="TTL להחזקת Push (שניות)", category="push", ), "PASTEBIN_API_KEY": ConfigDefinition( key="PASTEBIN_API_KEY", + services=("webapp", "bot", "mcp", "webserver"), default="", description="מפתח API ל-Pastebin", category="external", @@ -837,6 +961,7 @@ class ConfigService: ), "SENTRY_DSN": ConfigDefinition( key="SENTRY_DSN", + services=("webapp", "bot", "mcp", "webserver"), default="", description="DSN ל-Sentry לניטור שגיאות", category="monitoring", @@ -844,24 +969,28 @@ class ConfigService: ), "SENTRY_DASHBOARD_URL": ConfigDefinition( key="SENTRY_DASHBOARD_URL", + services=("webapp", "bot", "mcp", "webserver"), default="", description="כתובת לוח הבקרה של Sentry", category="monitoring", ), "SENTRY_TRACES_SAMPLE_RATE": ConfigDefinition( key="SENTRY_TRACES_SAMPLE_RATE", + services=("webapp", "bot", "mcp", "webserver"), default="0.1", description="שיעור דגימת Traces ב-Sentry", category="monitoring", ), "SENTRY_PROFILES_SAMPLE_RATE": ConfigDefinition( key="SENTRY_PROFILES_SAMPLE_RATE", + services=("webapp", "bot", "mcp", "webserver"), default="0.1", description="שיעור דגימת Profiles ב-Sentry", category="monitoring", ), "SENTRY_WEBHOOK_SECRET": ConfigDefinition( key="SENTRY_WEBHOOK_SECRET", + services=("webserver",), default="", description="סוד ל-Sentry Webhook", category="monitoring", @@ -869,66 +998,77 @@ class ConfigService: ), "SENTRY_WEBHOOK_DEDUP_WINDOW_SECONDS": ConfigDefinition( key="SENTRY_WEBHOOK_DEDUP_WINDOW_SECONDS", + services=("webserver",), default="300", description="חלון dedup ל-Sentry Webhooks (שניות)", category="monitoring", ), "OTEL_EXPORTER_OTLP_ENDPOINT": ConfigDefinition( key="OTEL_EXPORTER_OTLP_ENDPOINT", + services=("webapp", "bot", "webserver"), default="", description="Endpoint ל-OTLP Exporter", category="monitoring", ), "OTEL_EXPORTER_INSECURE": ConfigDefinition( key="OTEL_EXPORTER_INSECURE", + services=("webapp", "bot", "webserver"), default="false", description="שימוש בחיבור לא מאובטח ל-OTLP", category="monitoring", ), "OBS_AI_EXPLAIN_TIMEOUT": ConfigDefinition( key="OBS_AI_EXPLAIN_TIMEOUT", + services=("webapp", "bot", "mcp", "webserver"), default="10", description="טיימאאוט לבקשות AI (שניות)", category="ai", ), "OBS_AI_EXPLAIN_CACHE_TTL": ConfigDefinition( key="OBS_AI_EXPLAIN_CACHE_TTL", + services=("webapp", "bot", "mcp", "webserver"), default="600", description="TTL לקאש הסברי AI (שניות)", category="ai", ), "LOG_LEVEL": ConfigDefinition( key="LOG_LEVEL", + services=("webapp", "bot", "mcp", "webserver"), default="INFO", description="רמת הלוגים (DEBUG/INFO/WARNING/ERROR/CRITICAL או ערך מספרי כמו 10/20/30)", category="logging", ), "LOG_FORMAT": ConfigDefinition( key="LOG_FORMAT", + services=("webapp", "bot", "mcp", "webserver"), default="json", description="פורמט הלוגים (json/console)", category="logging", ), "LOG_INFO_SAMPLE_RATE": ConfigDefinition( key="LOG_INFO_SAMPLE_RATE", + services=("webapp", "bot", "mcp", "webserver"), default="1.0", description="שיעור דגימת לוגים ברמת INFO", category="logging", ), "LOG_INFO_SAMPLE_ALLOWLIST": ConfigDefinition( key="LOG_INFO_SAMPLE_ALLOWLIST", + services=("webapp", "bot", "mcp", "webserver"), default="", description="רשימת אירועים שלא יידגמו (מופרדים בפסיקים)", category="logging", ), "ALERT_QUICK_FIX_PATH": ConfigDefinition( key="ALERT_QUICK_FIX_PATH", + services=("webapp", "bot", "mcp", "webserver"), default="config/alert_quick_fixes.json", description="נתיב לקובץ תיקונים מהירים", category="alerts", ), "ALERTMANAGER_WEBHOOK_SECRET": ConfigDefinition( key="ALERTMANAGER_WEBHOOK_SECRET", + services=("webapp", "bot", "webserver"), default="", description="סוד Webhook ל-Alertmanager", category="alerts", @@ -936,210 +1076,245 @@ class ConfigService: ), "ALERTMANAGER_IP_ALLOWLIST": ConfigDefinition( key="ALERTMANAGER_IP_ALLOWLIST", + services=("webapp", "bot", "webserver"), default="", description="רשימת IP מותרים ל-Alertmanager", category="alerts", ), "ALLOWED_WEBHOOK_HOSTS": ConfigDefinition( key="ALLOWED_WEBHOOK_HOSTS", + services=("webapp", "bot", "mcp", "webserver"), default="", description="Allowlist אופציונלי ליעדי webhook (Visual Rule Engine) לפי hostnames (CSV)", category="alerts", ), "ALLOWED_WEBHOOK_SUFFIXES": ConfigDefinition( key="ALLOWED_WEBHOOK_SUFFIXES", + services=("webapp", "bot", "mcp", "webserver"), default="", description="Allowlist אופציונלי ליעדי webhook (Visual Rule Engine) לפי סיומות דומיין (CSV, למשל .example.com)", category="alerts", ), "OBSERVABILITY_RUNBOOK_PATH": ConfigDefinition( key="OBSERVABILITY_RUNBOOK_PATH", + services=("webapp", "bot", "mcp", "webserver"), default="config/observability_runbooks.yml", description="נתיב לקובץ Runbooks", category="observability", ), "ALERT_TAGS_COLLECTION": ConfigDefinition( key="ALERT_TAGS_COLLECTION", + services=("webapp", "bot", "mcp", "webserver"), default="alert_tags", description="שם ה-Collection לתגיות התראות (Manual Alert Tagging) ב-Observability", category="observability", ), "ALERT_TAGS_DB_DISABLED": ConfigDefinition( key="ALERT_TAGS_DB_DISABLED", + services=("webapp", "bot", "mcp", "webserver"), default="false", description="אם true מכבה שמירה/שליפה של תגיות להתראות (Manual Alert Tagging) מה-DB", category="observability", ), "OBS_RUNBOOK_STATE_TTL": ConfigDefinition( key="OBS_RUNBOOK_STATE_TTL", + services=("webapp", "bot", "mcp", "webserver"), default="14400", description="TTL למצב Runbook (שניות)", category="observability", ), "OBS_RUNBOOK_EVENT_TTL": ConfigDefinition( key="OBS_RUNBOOK_EVENT_TTL", + services=("webapp", "bot", "mcp", "webserver"), default="900", description="TTL לאירועי Runbook (שניות)", category="observability", ), "OBSERVABILITY_WARMUP_RANGES": ConfigDefinition( key="OBSERVABILITY_WARMUP_RANGES", + services=("webapp", "bot", "webserver"), default="24h,7d,30d", description="רשימת טווחי זמן (CSV) לחימום /api/observability/aggregations", category="observability", ), "OBSERVABILITY_WARMUP_ENABLED": ConfigDefinition( key="OBSERVABILITY_WARMUP_ENABLED", + services=("webapp", "bot", "webserver"), default="true", description="הפעלה/כיבוי של Warmup כבד לדוחות Observability ברקע אחרי עליית התהליך", category="observability", ), "OBSERVABILITY_WARMUP_DELAY_SECONDS": ConfigDefinition( key="OBSERVABILITY_WARMUP_DELAY_SECONDS", + services=("webapp", "bot", "webserver"), default="5", description="השהייה (שניות) לפני תחילת Warmup הדוחות כדי לא להעמיס בזמן העלייה", category="observability", ), "OBSERVABILITY_WARMUP_BUDGET_SECONDS": ConfigDefinition( key="OBSERVABILITY_WARMUP_BUDGET_SECONDS", + services=("webapp", "bot", "webserver"), default="20", description="תקציב זמן מקסימלי (שניות) ל-Warmup הדוחות ברקע; מעבר לתקציב נעצור מוקדם", category="observability", ), "OBSERVABILITY_WARMUP_SLOW_LIMIT": ConfigDefinition( key="OBSERVABILITY_WARMUP_SLOW_LIMIT", + services=("webapp", "bot", "webserver"), default="5", description="ערך slow_endpoints_limit עבור החימום (ברירת מחדל כמו ב-API)", category="observability", ), "SAFE_MODE": ConfigDefinition( key="SAFE_MODE", + services=("webapp", "bot", "mcp", "webserver"), default="false", description="מצב בטוח - משבית פעולות מסוכנות", category="predictive", ), "DISABLE_PREEMPTIVE_ACTIONS": ConfigDefinition( key="DISABLE_PREEMPTIVE_ACTIONS", + services=("webapp", "bot", "mcp", "webserver"), default="false", description="השבתת פעולות מנע אוטומטיות", category="predictive", ), "RATE_LIMIT_SHADOW_MODE": ConfigDefinition( key="RATE_LIMIT_SHADOW_MODE", + services=("webapp", "bot", "mcp", "webserver"), default="false", description="מצב צל - ספירה בלבד ללא חסימה", category="rate_limit", ), "RATE_LIMIT_PER_MINUTE": ConfigDefinition( key="RATE_LIMIT_PER_MINUTE", + services=("webapp", "bot", "mcp", "webserver"), default="30", description="מגבלת בקשות לדקה", category="rate_limit", ), "ENABLE_METRICS": ConfigDefinition( key="ENABLE_METRICS", + services=("webapp", "bot", "webserver"), default="false", description="הפעלת יצוא Metrics דרך OTLP (OpenTelemetry Metrics). כדי לפעול בפועל צריך גם OTEL_EXPORTER_OTLP_ENDPOINT.", category="metrics", ), "ENABLE_PROMETHEUS_METRICS": ConfigDefinition( key="ENABLE_PROMETHEUS_METRICS", + services=("webapp", "bot", "webserver"), default="false", description="הפעלת OpenTelemetry Prometheus exporter (scrape דרך /metrics).", category="metrics", ), "ENABLE_PROMETHEUS_OTEL_METRICS": ConfigDefinition( key="ENABLE_PROMETHEUS_OTEL_METRICS", + services=("webapp", "bot", "webserver"), default="false", description="Alias ל-ENABLE_PROMETHEUS_METRICS (תאימות לאחור).", category="metrics", ), "PROMETHEUS_URL": ConfigDefinition( key="PROMETHEUS_URL", + services=("webapp", "bot", "mcp", "webserver"), default="", description="בסיס URL ל-Prometheus HTTP API. כשמוגדר, דשבורד Observability יקרא timeseries מ-Prometheus במקום מה-DB.", category="observability", ), "PROMETHEUS_RATE_WINDOW": ConfigDefinition( key="PROMETHEUS_RATE_WINDOW", + services=("webapp", "bot", "mcp", "webserver"), default="5m", description="חלון ברירת מחדל ל-rate()/histogram_quantile() ב-PromQL (למשל 5m).", category="observability", ), "HTTP_SAMPLE_BUFFER": ConfigDefinition( key="HTTP_SAMPLE_BUFFER", + services=("webapp", "bot", "mcp", "webserver"), default="2000", description="גודל באפר דגימות HTTP", category="metrics", ), "QUEUE_DELAY_WARN_MS": ConfigDefinition( key="QUEUE_DELAY_WARN_MS", + services=("webapp", "bot", "webserver"), default="500", description="סף אזהרת עיכוב תור (מילישניות)", category="performance", ), "SLOW_MS": ConfigDefinition( key="SLOW_MS", + services=("webapp", "bot", "webserver"), default="0", description="סף לוגינג בקשות איטיות (מילישניות)", category="performance", ), "COLLECTIONS_API_ITEMS_SLOW_MS": ConfigDefinition( key="COLLECTIONS_API_ITEMS_SLOW_MS", + services=("webapp", "bot", "webserver"), default="", description="סף איטיות ל-Collections API", category="performance", ), "ANOMALY_IGNORE_ENDPOINTS": ConfigDefinition( key="ANOMALY_IGNORE_ENDPOINTS", + services=("webapp", "bot", "mcp", "webserver"), default="", description="נקודות קצה להתעלמות בזיהוי אנומליות", category="performance", ), "DRIVE_MENU_V2": ConfigDefinition( key="DRIVE_MENU_V2", + services=("webapp", "bot", "mcp", "webserver"), default="true", description="הפעלת תפריט Drive v2", category="features", ), "RECYCLE_TTL_DAYS": ConfigDefinition( key="RECYCLE_TTL_DAYS", + services=("webapp", "bot", "mcp", "webserver"), default="7", description="ימים לשמירת פריטים בסל המיחזור", category="limits", ), "PUBLIC_SHARE_TTL_DAYS": ConfigDefinition( key="PUBLIC_SHARE_TTL_DAYS", + services=("webapp", "bot", "webserver"), default="7", description="ימים לתוקף שיתוף ציבורי", category="limits", ), "PERSISTENT_LOGIN_DAYS": ConfigDefinition( key="PERSISTENT_LOGIN_DAYS", + services=("webapp", "bot", "webserver"), default="180", description="ימים לשמירת התחברות קבועה", category="limits", ), "SEARCH_PAGE_SIZE": ConfigDefinition( key="SEARCH_PAGE_SIZE", + services=("webapp", "bot", "mcp", "webserver"), default="200", description="גודל עמוד חיפוש", category="limits", ), "UI_PAGE_SIZE": ConfigDefinition( key="UI_PAGE_SIZE", + services=("webapp", "bot", "mcp", "webserver"), default="10", description="גודל עמוד בממשק משתמש", category="limits", ), "UPTIME_PROVIDER": ConfigDefinition( key="UPTIME_PROVIDER", + services=("webapp", "bot", "webserver"), default="", description="ספק Uptime (betteruptime וכו')", category="uptime", ), "UPTIME_API_KEY": ConfigDefinition( key="UPTIME_API_KEY", + services=("webapp", "bot", "webserver"), default="", description="מפתח API ל-Uptime", category="uptime", @@ -1147,120 +1322,140 @@ class ConfigService: ), "UPTIME_MONITOR_ID": ConfigDefinition( key="UPTIME_MONITOR_ID", + services=("webapp", "bot", "webserver"), default="", description="מזהה Monitor ב-Uptime", category="uptime", ), "UPTIME_STATUS_URL": ConfigDefinition( key="UPTIME_STATUS_URL", + services=("webapp", "bot", "webserver"), default="", description="כתובת דף סטטוס Uptime", category="uptime", ), "UPTIME_WIDGET_SCRIPT_URL": ConfigDefinition( key="UPTIME_WIDGET_SCRIPT_URL", + services=("webapp", "bot", "webserver"), default="https://uptime.betterstack.com/widgets/announcement.js", description="כתובת סקריפט Widget", category="uptime", ), "UPTIME_WIDGET_ID": ConfigDefinition( key="UPTIME_WIDGET_ID", + services=("webapp", "bot", "webserver"), default="", description="מזהה Widget ב-Uptime", category="uptime", ), "UPTIME_CACHE_TTL_SECONDS": ConfigDefinition( key="UPTIME_CACHE_TTL_SECONDS", + services=("webapp", "bot", "webserver"), default="120", description="TTL לקאש Uptime (שניות)", category="uptime", ), "ENVIRONMENT": ConfigDefinition( key="ENVIRONMENT", + services=("webapp", "bot", "mcp", "webserver"), default="production", description="שם הסביבה (production/staging/dev)", category="environment", ), "ENV": ConfigDefinition( key="ENV", + services=("webapp", "bot", "mcp", "webserver"), default="production", description="שם סביבה מקוצר", category="environment", ), "DEPLOYMENT_TYPE": ConfigDefinition( key="DEPLOYMENT_TYPE", + services=("webapp", "bot", "webserver"), default="render", description="סוג הפריסה (render/heroku/k8s)", category="environment", ), "HOSTNAME": ConfigDefinition( key="HOSTNAME", + services=("webapp", "bot", "webserver"), default="", description="שם ה-Host הנוכחי", category="environment", ), "APP_VERSION": ConfigDefinition( key="APP_VERSION", + services=("webapp", "bot", "webserver"), default="", description="גרסת האפליקציה", category="versioning", ), "ASSET_VERSION": ConfigDefinition( key="ASSET_VERSION", + services=("webapp", "bot", "webserver"), default="", description="גרסת הנכסים הסטטיים", category="versioning", ), "GIT_COMMIT": ConfigDefinition( key="GIT_COMMIT", + services=("webapp", "bot", "webserver"), default="", description="Git Commit Hash", category="versioning", ), "FA_SRI_HASH": ConfigDefinition( key="FA_SRI_HASH", + services=("webapp", "bot", "webserver"), default="", description="Hash SRI של FontAwesome", category="versioning", ), "MAINTENANCE_MODE": ConfigDefinition( key="MAINTENANCE_MODE", + services=("webapp", "bot", "mcp", "webserver"), default="false", description="מצב תחזוקה פעיל", category="maintenance", ), "MAINTENANCE_MESSAGE": ConfigDefinition( key="MAINTENANCE_MESSAGE", + services=("webapp", "bot", "mcp", "webserver"), default="🚀 אנחנו מעלים עדכון חדש!\nהבוט יחזור לפעול ממש בקרוב", description="הודעת תחזוקה למשתמשים", category="maintenance", ), "MAINTENANCE_AUTO_WARMUP_SECS": ConfigDefinition( key="MAINTENANCE_AUTO_WARMUP_SECS", + services=("webapp", "bot", "mcp", "webserver"), default="30", description="שניות חימום אחרי תחזוקה", category="maintenance", ), "MAINTENANCE_WARMUP_GRACE_SECS": ConfigDefinition( key="MAINTENANCE_WARMUP_GRACE_SECS", + services=("webapp", "bot", "mcp", "webserver"), default="0.75", description="שניות גרייס נוספות לחימום", category="maintenance", ), "BACKUPS_STORAGE": ConfigDefinition( key="BACKUPS_STORAGE", + services=("webapp", "bot", "webserver"), default="mongo", description="בחירת מנגנון גיבוי: mongo (GridFS) או fs (מערכת קבצים מקומית)", category="backups", ), "BACKUPS_DIR": ConfigDefinition( key="BACKUPS_DIR", + services=("webapp", "bot", "webserver"), default="/app/backups", description="נתיב גיבויים בלוקאל (אם BACKUPS_STORAGE=fs)", category="backups", ), "ENCRYPTION_KEY": ConfigDefinition( key="ENCRYPTION_KEY", + services=("webapp", "bot"), default="", description="מפתח הצפנה לנתונים רגישים (32 בתים)", category="security", @@ -1268,42 +1463,49 @@ class ConfigService: ), "PYTEST": ConfigDefinition( key="PYTEST", + services=("webapp", "bot", "webserver"), default="", description="דגל pytest פעיל", category="testing", ), "DISABLE_DB": ConfigDefinition( key="DISABLE_DB", + services=("webapp", "bot", "mcp", "webserver"), default="", description="השבתת DB בטסטים", category="testing", ), "HIGHLIGHT_THEME": ConfigDefinition( key="HIGHLIGHT_THEME", + services=("webapp", "bot", "mcp", "webserver"), default="github-dark", description="ערכת נושא להדגשת תחביר", category="display", ), "DEFAULT_UI_THEME": ConfigDefinition( key="DEFAULT_UI_THEME", + services=("webapp", "bot", "webserver"), default="classic", description="ערכת ברירת מחדל ל-UI ב-WebApp. תומך בערכת builtin או בערכה ציבורית בפורמט shared: (ללא רווחים).", category="display", ), "DOCUMENTATION_URL": ConfigDefinition( key="DOCUMENTATION_URL", + services=("webapp", "bot", "mcp", "webserver"), default="https://amirbiron.github.io/CodeBot/", description="כתובת אתר התיעוד", category="display", ), "BOT_LABEL": ConfigDefinition( key="BOT_LABEL", + services=("webapp", "bot", "mcp", "webserver"), default="CodeBot", description="תווית הבוט בממשק", category="display", ), "ALERT_EXTERNAL_SERVICES": ConfigDefinition( key="ALERT_EXTERNAL_SERVICES", + services=("webapp", "bot", "mcp", "webserver"), default="uptime,uptimerobot,uptime_robot,betteruptime,statuscake,pingdom,external_monitor,github api,github_api", description="רשימת מחרוזות (CSV) של שירותים חיצוניים שיזוהו כ-``external`` במדד High Error Rate (למשל ``uptimerobot``/``github api``); שגיאות מהמקורות האלה ייצרו רק התרעת Warning ולא יריצו Auto-Remediation.", category="alerts", @@ -1322,6 +1524,7 @@ class ConfigService: ), "DB_SLOW_MS": ConfigDefinition( key="DB_SLOW_MS", + services=("webapp", "bot", "mcp", "webserver"), default="0", description="סף מילישניות ללוג \"slow_mongo\" (MongoDB CommandListener)", category="database", @@ -1329,24 +1532,28 @@ class ConfigService: # --- Query Performance Profiler --- "PROFILER_ENABLED": ConfigDefinition( key="PROFILER_ENABLED", + services=("webapp", "bot", "mcp", "webserver"), default="true", description="הפעלת Query Performance Profiler (true/false). הערה: כרגע הפרופיילר מנוטרל קשיח בקוד (DatabaseManager.ENABLE_PROFILING=False), כך שה-ENV לא ישפיע בפועל.", category="profiler", ), "PROFILER_SLOW_THRESHOLD_MS": ConfigDefinition( key="PROFILER_SLOW_THRESHOLD_MS", + services=("webapp", "bot", "mcp", "webserver"), default="100", description="סף זמן לשאילתה איטית בפרופיילר (מילישניות)", category="profiler", ), "PROFILER_MAX_BUFFER_SIZE": ConfigDefinition( key="PROFILER_MAX_BUFFER_SIZE", + services=("webapp", "bot", "mcp", "webserver"), default="1000", description="מספר מקסימלי של רשומות slow queries שנשמרות בזיכרון", category="profiler", ), "PROFILER_AUTH_TOKEN": ConfigDefinition( key="PROFILER_AUTH_TOKEN", + services=("webapp", "bot", "webserver"), default="", description="טוקן גישה ל-API של הפרופיילר (X-Profiler-Token)", category="profiler", @@ -1354,18 +1561,21 @@ class ConfigService: ), "PROFILER_ALLOWED_IPS": ConfigDefinition( key="PROFILER_ALLOWED_IPS", + services=("webapp", "bot", "webserver"), default="", description="Allowlist של כתובות IP מורשות ל-API של הפרופיילר (CSV)", category="profiler", ), "PROFILER_RATE_LIMIT": ConfigDefinition( key="PROFILER_RATE_LIMIT", + services=("webapp", "bot", "webserver"), default="60", description="מגבלת בקשות לדקה ל-endpoints של הפרופיילר (Rate Limiting)", category="profiler", ), "PROFILER_METRICS_ENABLED": ConfigDefinition( key="PROFILER_METRICS_ENABLED", + services=("webapp", "bot", "mcp", "webserver"), default="true", description="הפעלת מטריקות Prometheus לפרופיילר", category="profiler", @@ -1373,36 +1583,42 @@ class ConfigService: # --- Diagnostics / sanity checks --- "SANITY_USER_ID": ConfigDefinition( key="SANITY_USER_ID", + services=("scripts",), default="123", description="משתנה עזר לסקריפט scripts/db_manager_sanity_check.py (לא משפיע על ריצה רגילה)", category="dev", ), "DRILLS_COLLECTION": ConfigDefinition( key="DRILLS_COLLECTION", + services=("webapp", "bot", "webserver"), default="drill_history", description="שם הקולקשן שבו נשמרת היסטוריית Drill Mode (תרגולים).", category="drills", ), "DRILLS_DB_ENABLED": ConfigDefinition( key="DRILLS_DB_ENABLED", + services=("webapp", "bot", "webserver"), default="", description="מפעיל שמירת היסטוריית Drill ב-MongoDB (ברירת מחדל נסמכת על ``ALERTS_DB_ENABLED``/``METRICS_DB_ENABLED``).", category="drills", ), "DRILLS_TTL_DAYS": ConfigDefinition( key="DRILLS_TTL_DAYS", + services=("webapp", "bot", "webserver"), default="90", description="כמה ימים נשמרת היסטוריית Drill לפני מחיקה אוטומטית (TTL index).", category="drills", ), "DRILL_MODE_ENABLED": ConfigDefinition( key="DRILL_MODE_ENABLED", + services=("webapp", "bot", "webserver"), default="false", description="מפעיל Drill Mode (תרגולים) ב-WebApp/API. כאשר כבוי, ``/api/observability/drills/run`` יחזיר ``drill_disabled``.", category="drills", ), "DUMMY_BOT_TOKEN": ConfigDefinition( key="DUMMY_BOT_TOKEN", + services=("bot",), default="dummy_token", description="טוקן בדיקה שמשמש סביבות שבהן אין צורך להתחבר לטלגרם (למשל docs build).", category="general", @@ -1410,24 +1626,28 @@ class ConfigService: ), "ENABLE_INTERNAL_SHARE_WEB": ConfigDefinition( key="ENABLE_INTERNAL_SHARE_WEB", + services=("bot",), default="false", description="הפעלת שירות שיתוף פנימי", category="features", ), "HTTP_SAMPLE_RETENTION_SECONDS": ConfigDefinition( key="HTTP_SAMPLE_RETENTION_SECONDS", + services=("webapp", "bot", "mcp", "webserver"), default="600", description="זמן שמירת הדגימות (שניות) לפני שמנקים אותן.", category="http", ), "HTTP_SLOW_MS": ConfigDefinition( key="HTTP_SLOW_MS", + services=("webapp", "bot", "mcp", "webserver"), default="0", description="סף מילישניות ללוג \"slow_http\" ב‑http_sync (requests)", category="http", ), "OBS_AI_EXPLAIN_TOKEN": ConfigDefinition( key="OBS_AI_EXPLAIN_TOKEN", + services=("webapp", "bot", "mcp", "webserver"), default="", description="אסימון Bearer שנשלח ב-Header ``Authorization`` כאשר השירות מוגן (אופציונלי).", category="observability", @@ -1435,6 +1655,7 @@ class ConfigService: ), "OBS_AI_EXPLAIN_URL": ConfigDefinition( key="OBS_AI_EXPLAIN_URL", + services=("webapp", "bot", "mcp", "webserver"), default="", description="Endpoint לשירות ההסבר החכם של הדשבורד (מקבל ``POST`` עם ``context`` ומחזיר ``root_cause``/``actions``/``signals``). בפריסה מאוחדת (WebApp + AI Explain באותו קונטיינר) זה לרוב ``http://127.0.0.1:11000/api/ai/explain``.", category="observability", @@ -1448,30 +1669,35 @@ class ConfigService: ), "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT": ConfigDefinition( key="OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + services=("webapp", "bot", "webserver"), default="", description="כתובת ייעודית למטריקות OTLP (אם שונה מה-endpoint הראשי).", category="monitoring", ), "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT": ConfigDefinition( key="OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + services=("webapp", "bot", "webserver"), default="", description="כתובת ייעודית ל-traces OTLP.", category="monitoring", ), "JOBS_STUCK_THRESHOLD_MINUTES": ConfigDefinition( key="JOBS_STUCK_THRESHOLD_MINUTES", + services=("bot",), default="20", description="סף (בדקות) לזיהוי הרצות Jobs תקועות והפקת אירוע job_stuck.", category="jobs_monitor", ), "JOBS_STUCK_MONITOR_INTERVAL_SECS": ConfigDefinition( key="JOBS_STUCK_MONITOR_INTERVAL_SECS", + services=("webapp", "bot", "webserver"), default="60", description="תדירות (שניות) של מוניטור Jobs תקועות (job_stuck).", category="jobs_monitor", ), "JOB_TRIGGERS_POLL_INTERVAL_SECS": ConfigDefinition( key="JOB_TRIGGERS_POLL_INTERVAL_SECS", + services=("webapp", "bot", "webserver"), default="60", description="תדירות polling (שניות) של processor בבוט שמטפל בבקשות trigger שנוצרו מה-WebApp (job_trigger_requests). מינימום 60.", category="jobs_monitor", @@ -1484,90 +1710,105 @@ class ConfigService: ), "WEEKLY_TIP_ENABLED": ConfigDefinition( key="WEEKLY_TIP_ENABLED", + services=("webapp", "bot", "webserver"), default="true", description="מתג כללי להצגת רכיב ההכרזות (on/off)", category="features", ), "FEATURE_CODE_EXECUTION": ConfigDefinition( key="FEATURE_CODE_EXECUTION", + services=("webapp", "bot", "webserver"), default="false", description="הפעלת הרצת קוד (Playground) ב-WebApp: /api/code/run", category="features", ), "FEATURE_COLLECTIONS_TAGS": ConfigDefinition( key="FEATURE_COLLECTIONS_TAGS", + services=("webapp", "bot", "mcp", "webserver"), default="true", description="הפעלת תגיות לפריטים ב'אוספים שלי' (API/UI)", category="features", ), "CODE_EXEC_USE_DOCKER": ConfigDefinition( key="CODE_EXEC_USE_DOCKER", + services=("webapp", "bot", "webserver"), default="true", description="האם להריץ קוד בתוך Docker sandbox (מומלץ/חובה בפרודקשן)", category="code_execution", ), "CODE_EXEC_ALLOW_FALLBACK": ConfigDefinition( key="CODE_EXEC_ALLOW_FALLBACK", + services=("webapp", "bot", "webserver"), default="false", description="אם true מאפשר fallback ל-subprocess (לפיתוח בלבד; בפרודקשן מומלץ false=fail-closed)", category="code_execution", ), "CODE_EXEC_MAX_TIMEOUT": ConfigDefinition( key="CODE_EXEC_MAX_TIMEOUT", + services=("webapp", "bot", "webserver"), default="30", description="timeout מקסימלי להרצת קוד (שניות)", category="code_execution", ), "CODE_EXEC_MAX_MEMORY_MB": ConfigDefinition( key="CODE_EXEC_MAX_MEMORY_MB", + services=("webapp", "bot", "webserver"), default="128", description="זיכרון מקסימלי להרצת קוד (MB)", category="code_execution", ), "CODE_EXEC_MAX_OUTPUT_BYTES": ConfigDefinition( key="CODE_EXEC_MAX_OUTPUT_BYTES", + services=("webapp", "bot", "webserver"), default="102400", description="כמות מקסימלית של stdout/stderr (bytes) לפני עצירה/קיצוץ", category="code_execution", ), "CODE_EXEC_MAX_CODE_LENGTH": ConfigDefinition( key="CODE_EXEC_MAX_CODE_LENGTH", + services=("webapp", "bot", "webserver"), default="51200", description="אורך קוד מקסימלי (bytes) שמותר לשלוח להרצה", category="code_execution", ), "CODE_EXEC_DOCKER_IMAGE": ConfigDefinition( key="CODE_EXEC_DOCKER_IMAGE", + services=("webapp", "bot", "webserver"), default="python:3.11-slim", description="Docker image להרצת קוד (למשל python:3.11-slim)", category="code_execution", ), "EMBEDDING_MIN_INTERVAL_SECONDS": ConfigDefinition( key="EMBEDDING_MIN_INTERVAL_SECONDS", + services=("webapp", "bot", "webserver"), default="1.2", description="מרווח מינימלי (שניות) בין קריאות ל-Gemini Embeddings (שער גלובלי)", category="ai", ), "EMBEDDING_RATE_LIMIT_COOLDOWN_SECONDS": ConfigDefinition( key="EMBEDDING_RATE_LIMIT_COOLDOWN_SECONDS", + services=("webapp", "bot", "webserver"), default="30", description="Cooldown גלובלי (שניות) שמוחל על כל הקוראים לאחר HTTP 429", category="ai", ), "EMBEDDING_WORKER_BATCH_SIZE": ConfigDefinition( key="EMBEDDING_WORKER_BATCH_SIZE", + services=("bot",), default="5", description="כמות snippets שה-embedding worker מעבד בכל סבב", category="ai", ), "EMBEDDING_WORKER_POLL_INTERVAL": ConfigDefinition( key="EMBEDDING_WORKER_POLL_INTERVAL", + services=("bot",), default="300", description="זמן המתנה (שניות) בין סריקות של ה-embedding worker כשהתור ריק", category="ai", ), "EMBEDDING_WORKER_BATCH_COOLDOWN": ConfigDefinition( key="EMBEDDING_WORKER_BATCH_COOLDOWN", + services=("bot",), default="30", description="זמן המתנה (שניות) בין באצ'ים שעובדו בהצלחה", category="ai", @@ -1622,6 +1863,10 @@ def mask_value(self, value: str, key: str) -> str: if self.is_sensitive_key(key): return self.MASKED_VALUE + # מיסוך מבוסס-ערך: URL עם credentials מוטמעים (user:pass@) הוא סוד גם אם השם אינו רגיש + if _CREDENTIAL_URL_RE.search(value): + return self.MASKED_VALUE + return value def get_env_value(self, key: str, default: Any = None) -> Optional[str]: @@ -1684,6 +1929,11 @@ def determine_status( # אין דיפולט אבל לא הכרחי - נחשב Default (ריק) return ConfigStatus.DEFAULT + # יש ערך בסביבה כשאין ברירת מחדל בקוד — המשתנה "הוגדר" (למשל ברנדר), + # לא "שונה": אין דיפולט שממנו אפשר לסטות (למשל GITHUB_TOKENS, MCP_SERVER_URL) + if default_is_empty: + return ConfigStatus.SET + # יש ערך בסביבה - השוואה לדיפולט default_str = str(default_value) if default_value is not None else "" if env_value == default_str: @@ -1770,6 +2020,10 @@ def get_config_overview( categories_set: set[str] = set() for definition in self.CONFIG_DEFINITIONS.values(): + # עמוד ראשי: רק משתנים ששייכים (גם) לשירות ה-webapp — ה-inspector רץ + # בתהליך ה-webapp ולכן Status/Active Value של שירותים אחרים יהיו מטעים + if "webapp" not in definition.services: + continue entry = self.get_config_entry(definition) categories_set.add(entry.category) @@ -1786,6 +2040,7 @@ def get_config_overview( # חישוב סטטיסטיקות modified_count = sum(1 for e in entries if e.status == ConfigStatus.MODIFIED) + set_count = sum(1 for e in entries if e.status == ConfigStatus.SET) missing_count = sum(1 for e in entries if e.status == ConfigStatus.MISSING) default_count = sum(1 for e in entries if e.status == ConfigStatus.DEFAULT) @@ -1794,11 +2049,38 @@ def get_config_overview( generated_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), total_count=len(entries), modified_count=modified_count, + set_count=set_count, missing_count=missing_count, default_count=default_count, categories=sorted(categories_set), ) + def get_other_services_entries(self) -> List[Dict[str, Any]]: + """משתנים ששייכים לשירותים שאינם webapp (bot/mcp/webserver/scripts) — עמוד 2. + + כולל גם משתנים משותפים (שמופיעים בנוסף בעמוד ה-webapp) — כל שורה מציינת + לאילו שירותים היא מתייחסת. בלי Status ובלי Active Value: הערכים חיים + בתהליכים אחרים (שירותי Render נפרדים) ואינם נגישים מכאן. + """ + rows: List[Dict[str, Any]] = [] + for definition in self.CONFIG_DEFINITIONS.values(): + other = [s for s in definition.services if s != "webapp"] + if not other: + continue + default_str = str(definition.default) if definition.default is not None else "" + is_sensitive = self.is_sensitive_key(definition.key) or definition.sensitive + rows.append({ + "key": definition.key, + "service": " + ".join(other), + "also_webapp": "webapp" in definition.services, + "category": definition.category, + "default_value": self.mask_value(default_str, definition.key) if is_sensitive else default_str, + "description": definition.description, + "is_sensitive": is_sensitive, + }) + rows.sort(key=lambda r: (r["service"], r["category"], r["key"])) + return rows + def get_category_summary(self) -> Dict[str, Dict[str, int]]: """ קבלת סיכום לפי קטגוריות. @@ -1813,11 +2095,13 @@ def get_category_summary(self) -> Dict[str, Dict[str, int]]: for entry in overview.entries: cat = entry.category if cat not in summary: - summary[cat] = {"total": 0, "modified": 0, "missing": 0, "default": 0} + summary[cat] = {"total": 0, "modified": 0, "set": 0, "missing": 0, "default": 0} summary[cat]["total"] += 1 if entry.status == ConfigStatus.MODIFIED: summary[cat]["modified"] += 1 + elif entry.status == ConfigStatus.SET: + summary[cat]["set"] += 1 elif entry.status == ConfigStatus.MISSING: summary[cat]["missing"] += 1 else: @@ -1837,6 +2121,9 @@ def validate_required(self) -> List[str]: for definition in self.CONFIG_DEFINITIONS.values(): if not definition.required: continue + # משתנה שלא שייך ל-webapp לא נבדק כאן — הערך שלו לא בסביבת ה-webapp + if "webapp" not in definition.services: + continue env_value = self.get_env_value(definition.key) default_str = str(definition.default) if definition.default is not None else None diff --git a/skill_menu_handler.py b/skill_menu_handler.py new file mode 100644 index 000000000..f2866a638 --- /dev/null +++ b/skill_menu_handler.py @@ -0,0 +1,305 @@ +"""SkillMenuHandler — תפריט הסקילים בבוט (סוג קובץ נפרד לגמרי מגיבויים). + +מבנה מפושט של BackupMenuHandler: רשימה + פעולות (הורדה/מחיקה/תיוג/הערה) על סקיל בודד, +בלי לוגיקת repo/versioning/github שסקילים אינם צריכים. + +- שכבת האחסון היא SkillManager (GridFS "skills", שמירה byte-for-byte, בלי retention/restore). +- תיוג והערות נשמרים דרך אותו facade גנרי של הגיבויים (מפתח: user_id + id) — skill_id ייחודי + ולכן אין התנגשות עם backup_id. +""" + +import asyncio +import logging +from io import BytesIO +from typing import Optional + +from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, InputFile +from telegram.ext import ContextTypes + +from file_manager import skill_manager +from handlers.pagination import build_pagination_row +# שימוש חוזר בעזרי תצוגה טהורים מ-BackupMenuHandler (בלי שכפול קוד) +from backup_menu_handler import ( + _format_bytes, + _format_date, + _truncate_middle, + _rating_to_emoji, + _get_files_facade, +) +from utils import TelegramUtils + +logger = logging.getLogger(__name__) + + +def _collect_ratings(user_id: int, skill_ids: list) -> dict: + """אוסף דירוגים עבור רשימת skill_ids בקריאה אחת (מיועד להרצה ב-thread).""" + facade = _get_files_facade() + out: dict = {} + if facade is None: + return out + for sid in skill_ids: + try: + out[sid] = (facade.get_backup_rating(user_id, sid) or "") + except Exception: + out[sid] = "" + return out + +_RATING_MAP = { + "excellent": "🏆 מצוין", + "good": "👍 טוב", + "ok": "🤷 סביר", +} + +PAGE_SIZE = 10 + + +class SkillMenuHandler: + """תפריט סקילים — רשימה ופעולות על סקיל בודד (הורדה/מחיקה/תיוג/הערה).""" + + async def _get_skills(self, user_id: int): + # I/O של GridFS — מריצים ב-thread כדי לא לחסום את לולאת האירועים + return await asyncio.to_thread(skill_manager.list_skills, user_id) + + async def _find_skill(self, user_id: int, skill_id: str): + skills = await self._get_skills(user_id) + return next((s for s in skills if s.skill_id == skill_id), None) + + async def _show_skills_list(self, update: Update, context: ContextTypes.DEFAULT_TYPE, + page: Optional[int] = None): + query = update.callback_query + user_id = query.from_user.id + await query.answer() + skills = await self._get_skills(user_id) + if not skills: + keyboard = [[InlineKeyboardButton("🔙 חזור", callback_data="files")]] + await TelegramUtils.safe_edit_message_text( + query, + "ℹ️ אין סקילים שמורים.\n" + "כדי לשמור סקיל: שלח/י קובץ ZIP ובחר/י '📝 סקיל'.", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + total = len(skills) + if page is None: + try: + page = int(context.user_data.get("skill_list_page", 1) or 1) + except Exception: + page = 1 + if page < 1: + page = 1 + total_pages = (total + PAGE_SIZE - 1) // PAGE_SIZE if total > 0 else 1 + if page > total_pages: + page = total_pages + context.user_data["skill_list_page"] = page + start = (page - 1) * PAGE_SIZE + items = skills[start:min(start + PAGE_SIZE, total)] + + # איסוף דירוגים לפריטי העמוד בקריאה אחת ב-thread (לא חוסם את ה-event loop) + ratings = await asyncio.to_thread(_collect_ratings, user_id, [s.skill_id for s in items]) + + lines = [f"📝 סקילים שמורים — סה\"כ: {total}\n📄 עמוד {page} מתוך {total_pages}\n"] + keyboard = [] + for info in items: + name = info.original_name or info.skill_id + rating = ratings.get(info.skill_id, "") + emoji = _rating_to_emoji(rating) + lines.append(f"• {name} — {_format_date(info.created_at)}") + second = f" ↳ גודל: {_format_bytes(info.total_size)} | קבצים: {info.file_count}" + if emoji: + second += f" | {emoji}" + lines.append(second) + btn = f"📝 {_truncate_middle(name, 28)} - {_format_date(info.created_at)}" + if emoji: + btn = f"{emoji} {btn}" + keyboard.append([InlineKeyboardButton(btn[:64], callback_data=f"skill_details:{info.skill_id}")]) + + nav = build_pagination_row(page, total, PAGE_SIZE, "skill_page_") + if nav: + keyboard.append(nav) + keyboard.append([InlineKeyboardButton("🔙 חזור", callback_data="files")]) + await TelegramUtils.safe_edit_message_text( + query, "\n".join(lines), reply_markup=InlineKeyboardMarkup(keyboard) + ) + + async def _show_skill_details(self, update: Update, context: ContextTypes.DEFAULT_TYPE, skill_id: str): + query = update.callback_query + await query.answer() + user_id = query.from_user.id + match = await self._find_skill(user_id, skill_id) + if not match: + await TelegramUtils.safe_edit_message_text(query, "❌ הסקיל לא נמצא") + return + try: + facade = _get_files_facade() + rating = (facade.get_backup_rating(user_id, skill_id) if facade is not None else "") or "" + except Exception: + rating = "" + try: + facade = _get_files_facade() + note_text = (facade.get_backup_note(user_id, skill_id) if facade is not None else "") or "" + except Exception: + note_text = "" + lines = [ + f"📝 סקיל: {match.original_name or skill_id}", + f"📅 נוצר: {_format_date(match.created_at)}", + f"📁 קבצים: {match.file_count}", + f"📏 גודל: {_format_bytes(match.total_size)}", + ] + if rating: + lines.append(f"🏷 תיוג: {rating}") + if note_text: + lines.append(f"📝 הערה: {note_text}") + kb = [ + [InlineKeyboardButton("⬇️ הורדה", callback_data=f"skill_download_id:{skill_id}")], + [InlineKeyboardButton("🗑 מחק", callback_data=f"skill_delete_one_confirm:{skill_id}")], + [InlineKeyboardButton("🏷 ערוך תיוג", callback_data=f"skill_rate_menu:{skill_id}")], + [InlineKeyboardButton("📝 ערוך הערה" if note_text else "📝 הוסף הערה", + callback_data=f"skill_add_note:{skill_id}")], + [InlineKeyboardButton("🔙 חזור לרשימה", callback_data="skill_list")], + ] + await TelegramUtils.safe_edit_message_text( + query, "\n".join(lines), reply_markup=InlineKeyboardMarkup(kb) + ) + + async def _download_by_id(self, update: Update, context: ContextTypes.DEFAULT_TYPE, skill_id: str): + query = update.callback_query + user_id = query.from_user.id + await query.answer() + # ה-bytes נמשכים ישירות מ-GridFS (אין עותק מקומי כמו בגיבויים) — byte-for-byte + raw = await asyncio.to_thread(skill_manager.get_skill_bytes, user_id, skill_id) + if raw is None: + await TelegramUtils.safe_edit_message_text(query, "❌ הסקיל לא נמצא") + return + # רק לאחר שיש bytes — סריקה נוספת לשם המקורי (חוסך סריקת GridFS כפולה בכל לחיצה) + match = await self._find_skill(user_id, skill_id) + try: + filename = (match.original_name if match else None) or f"{skill_id}.zip" + await query.message.reply_document( + document=InputFile(BytesIO(raw), filename=filename), + caption=f"📝 {filename} — {_format_bytes(len(raw))}", + ) + try: + await self._show_skills_list(update, context) + except Exception as e: + if "message is not modified" not in str(e).lower(): + raise + except Exception: + logger.exception("שליחת סקיל נכשלה") + await TelegramUtils.safe_edit_message_text(query, "❌ שגיאה בשליחת הסקיל") + + async def send_rating_prompt(self, update: Update, context: ContextTypes.DEFAULT_TYPE, skill_id: str): + """שולח הודעת תיוג עם 3 כפתורים עבור סקיל מסוים.""" + try: + keyboard = [ + [InlineKeyboardButton("🏆 מצוין", callback_data=f"skill_rate:{skill_id}:excellent")], + [InlineKeyboardButton("👍 טוב", callback_data=f"skill_rate:{skill_id}:good")], + [InlineKeyboardButton("🤷 סביר", callback_data=f"skill_rate:{skill_id}:ok")], + ] + await context.bot.send_message( + chat_id=update.effective_chat.id, + text="תיוג:", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception: + pass + + async def _ask_skill_note(self, update: Update, context: ContextTypes.DEFAULT_TYPE, skill_id: str): + """מבקש מהמשתמש להזין הערה לסקיל; הטקסט נתפס ב-main.handle_text_message.""" + query = update.callback_query + await query.answer() + user_id = query.from_user.id + try: + facade = _get_files_facade() + existing = (facade.get_backup_note(user_id, skill_id) if facade is not None else "") or "" + except Exception: + existing = "" + try: + context.user_data['waiting_for_skill_note_for'] = skill_id + prompt = "✏️ הקלד/י הערה לסקיל (עד 1000 תווים).\nשלח/י טקסט עכשיו.\n\n" + if existing: + prompt += f"הערה נוכחית: {existing}\n" + await TelegramUtils.safe_edit_message_text( + query, + prompt, + reply_markup=InlineKeyboardMarkup( + [[InlineKeyboardButton("🔙 חזרה", callback_data=f"skill_details:{skill_id}")]] + ), + ) + except Exception: + logger.exception("פתיחת עריכת הערה נכשלה") + await TelegramUtils.safe_edit_message_text(query, "❌ שגיאה בפתיחת עריכת הערה") + + async def handle_callback_query(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """מרכז ניתוב לכל כפתורי הסקילים (prefix: skill_).""" + query = update.callback_query + user_id = query.from_user.id + data = query.data or "" + + if data == "skill_list": + await self._show_skills_list(update, context) + elif data.startswith("skill_page_"): + try: + page = int(data.split("_")[-1]) + except Exception: + page = 1 + await self._show_skills_list(update, context, page=page) + elif data.startswith("skill_details:"): + await self._show_skill_details(update, context, data.split(":", 1)[1]) + elif data.startswith("skill_download_id:"): + await self._download_by_id(update, context, data.split(":", 1)[1]) + elif data.startswith("skill_rate_menu:"): + await self.send_rating_prompt(update, context, data.split(":", 1)[1]) + elif data.startswith("skill_add_note:"): + await self._ask_skill_note(update, context, data.split(":", 1)[1]) + elif data.startswith("skill_delete_one_confirm:"): + skill_id = data.split(":", 1)[1] + kb = [ + [InlineKeyboardButton("✅ אישור מחיקה", callback_data=f"skill_delete_one_execute:{skill_id}")], + [InlineKeyboardButton("🔙 ביטול", callback_data=f"skill_details:{skill_id}")], + ] + await TelegramUtils.safe_edit_message_text( + query, "האם למחוק לצמיתות את הסקיל?", reply_markup=InlineKeyboardMarkup(kb) + ) + elif data.startswith("skill_delete_one_execute:"): + skill_id = data.split(":", 1)[1] + try: + res = await asyncio.to_thread(skill_manager.delete_skills, user_id, [skill_id]) + # ניקוי תיוג נלווה (best-effort) — אותו מנגנון גנרי של הגיבויים + try: + facade = _get_files_facade() + if facade is not None: + facade.delete_backup_ratings(user_id, [skill_id]) + except Exception: + pass + if res.get("deleted", 0): + await TelegramUtils.safe_edit_message_text(query, "✅ הסקיל נמחק") + await self._show_skills_list(update, context) + else: + await TelegramUtils.safe_edit_message_text(query, "❌ המחיקה נכשלה") + except Exception: + logger.exception("מחיקת סקיל נכשלה") + await TelegramUtils.safe_edit_message_text(query, "❌ שגיאה במחיקה") + elif data.startswith("skill_rate:"): + # פורמט: skill_rate:: + try: + _, s_id, rating_key = data.split(":", 2) + except Exception: + await query.answer("בקשה לא תקפה", show_alert=True) + return + rating_value = _RATING_MAP.get(rating_key, rating_key) + try: + facade = _get_files_facade() + ok = bool(facade.save_backup_rating(user_id, s_id, rating_value)) if facade is not None else False + if ok: + await TelegramUtils.safe_edit_message_text( + query, + f"✅ התיוג נשמר: {rating_value}", + reply_markup=InlineKeyboardMarkup( + [[InlineKeyboardButton("🔙 לפרטי הסקיל", callback_data=f"skill_details:{s_id}")]] + ), + ) + else: + await query.answer("שמירת התיוג נכשלה", show_alert=True) + except Exception: + await query.answer("שגיאה בשמירת התיוג", show_alert=True) diff --git a/tests/handlers/test_documents.py b/tests/handlers/test_documents.py index 7b9501e8e..156535259 100644 --- a/tests/handlers/test_documents.py +++ b/tests/handlers/test_documents.py @@ -293,11 +293,23 @@ async def test_handle_document_stores_zip_copy(handler_env): await handler_env["handler"].handle_document(update, context) - assert handler_env["backup"].saved_bytes, "ZIP צריך להישמר כמטען גיבוי" - assert any("ZIP" in msg for msg, _ in replies.messages) - assert not handler_env["errors"], "שמירת ZIP לא אמורה להשפיע על error counters" + # ZIP שהועלה ללא מצב מיוחד: לא נשמר אוטומטית, אלא מציג בחירה סקיל/גיבוי (בחירה מפורשת) + assert not handler_env["backup"].saved_bytes, "ZIP לא אמור להישמר אוטומטית — נדרשת בחירה מפורשת" + assert any("ZIP" in text for text, _ in replies.messages) + markups = [kw.get("reply_markup") for _, kw in replies.messages if kw.get("reply_markup")] + assert markups, "צפוי reply_markup עם כפתורי בחירה סקיל/גיבוי" + callbacks = [ + btn.callback_data + for mk in markups + for row in mk.inline_keyboard + for btn in row + ] + assert any(cb.startswith("zip_route_skill:") for cb in callbacks) + assert any(cb.startswith("zip_route_backup:") for cb in callbacks) + assert context.user_data.get("pending_zip"), "צפוי pending_zip ב-user_data" + assert not handler_env["errors"], "קבלת ZIP לא אמורה להשפיע על error counters" assert not any(evt[0] == "file_read_unreadable" for evt in handler_env["events"] if evt), ( - "לא אמורה לצאת התראה על קובץ לא קריא לאחר שמירת ZIP" + "לא אמורה לצאת התראה על קובץ לא קריא לאחר קבלת ZIP" ) diff --git a/tests/test_config_inspector_service.py b/tests/test_config_inspector_service.py index 6a7fadfc6..31941e8f5 100644 --- a/tests/test_config_inspector_service.py +++ b/tests/test_config_inspector_service.py @@ -236,6 +236,136 @@ def test_category_summary(self): for _cat, stats in summary.items(): assert "total" in stats assert "modified" in stats + assert "set" in stats assert "missing" in stats assert "default" in stats + +class TestConfigStatusSet: + """סטטוס Set: ערך שהוגדר בסביבה (למשל ברנדר) כשאין ברירת מחדל בקוד — אינו 'Modified'.""" + + def setup_method(self): + self.service = ConfigService() + + def test_env_without_default_is_set_not_modified(self): + assert self.service.determine_status("some-value", "") == ConfigStatus.SET + assert self.service.determine_status("some-value", None) == ConfigStatus.SET + + def test_env_with_different_default_is_modified(self): + assert self.service.determine_status("custom", "default") == ConfigStatus.MODIFIED + + def test_user_reported_keys_show_set_when_env_configured(self): + # המקרים שדווחו: הוגדרו ברנדר, אין דיפולט בקוד ⇒ Set (לא Modified) + keys = ( + "MCP_SERVER_URL", + "GITHUB_TOKENS", + "GITHUB_WEBHOOK_SECRET", + "ALERTMANAGER_WEBHOOK_SECRET", + "ALERT_TELEGRAM_BOT_TOKEN", + ) + for key in keys: + definition = self.service.CONFIG_DEFINITIONS[key] + with patch.dict(os.environ, {key: "value-set-in-render"}, clear=False): + entry = self.service.get_config_entry(definition) + assert entry.status == ConfigStatus.SET, f"{key}: {entry.status}" + + def test_overview_counts_set(self): + with patch.dict(os.environ, {"MCP_SERVER_URL": "https://mcp.example.com"}, clear=False): + overview = self.service.get_config_overview() + assert overview.set_count >= 1 + assert overview.set_count == sum(1 for e in overview.entries if e.status == ConfigStatus.SET) + + +class TestMaskingUrls: + """מיסוך: URL ציבורי אינו סוד; URL עם credentials ממוסך דרך sensitive=True מפורש.""" + + def setup_method(self): + self.service = ConfigService() + + def test_public_urls_not_masked(self): + assert self.service.mask_value("https://mcp.example.com", "MCP_SERVER_URL") == "https://mcp.example.com" + assert self.service.mask_value("https://app.example.com", "WEBAPP_URL") == "https://app.example.com" + assert self.service.mask_value("https://prom.example.com", "PROMETHEUS_URL") == "https://prom.example.com" + + def test_credential_bearing_values_still_masked(self): + # MONGODB_URL מסומן sensitive=True מפורשות (מכיל סיסמה ב-URI) + assert self.service.mask_value("mongodb://u:p@h/db", "MONGODB_URL") == "********" + # תבניות TOKEN/SECRET/URI ממשיכות לתפוס + assert self.service.mask_value("ghp_x", "GITHUB_TOKENS") == "********" + assert self.service.mask_value("x", "SOME_SECRET") == "********" + assert self.service.mask_value("mongodb://u:p@h", "MONGODB_URI") == "********" + + def test_url_with_embedded_credentials_masked_even_if_key_not_sensitive(self): + # מפתח לא-רגיש (URL הוסר מהתבניות) אך הערך מכיל credentials מוטמעים → ממוסך (ממצא review) + assert self.service.mask_value("https://user:pass@host.com/x", "SOME_PUBLIC_URL") == "********" + assert self.service.mask_value("redis://admin:secret@10.0.0.1:6379", "CACHE_URL") == "********" + # URL ציבורי רגיל (כולל host:port) אינו ממוסך + assert self.service.mask_value("https://host.com:8080/path", "SOME_PUBLIC_URL") == "https://host.com:8080/path" + + def test_entry_active_value_visible_for_public_url(self): + definition = self.service.CONFIG_DEFINITIONS["MCP_SERVER_URL"] + with patch.dict(os.environ, {"MCP_SERVER_URL": "https://mcp.example.com"}, clear=False): + entry = self.service.get_config_entry(definition) + assert entry.active_value == "https://mcp.example.com" + assert entry.is_sensitive is False + + +class TestServiceSplit: + """הפרדה לפי שירותים: עמוד 1 = כל מה ששייך ל-webapp (כולל משותפים); + עמוד 2 = כל מה ששייך לשירות אחר (bot/mcp/webserver/scripts), כולל משותפים, + עם ציון השירותים בכל שורה — ובלי Status/Active Value.""" + + def setup_method(self): + self.service = ConfigService() + + def test_overview_contains_webapp_definitions(self): + overview = self.service.get_config_overview() + webapp_keys = {k for k, d in self.service.CONFIG_DEFINITIONS.items() if "webapp" in d.services} + assert {e.key for e in overview.entries} == webapp_keys + + def test_other_services_rows_cover_non_webapp_services(self): + rows = self.service.get_other_services_entries() + other_keys = { + k for k, d in self.service.CONFIG_DEFINITIONS.items() + if any(s != "webapp" for s in d.services) + } + assert {r["key"] for r in rows} == other_keys + allowed = {"bot", "mcp", "webserver", "scripts"} + for r in rows: + assert set(r["service"].split(" + ")) <= allowed, r["key"] + # אין Status/Active Value בעמוד 2 — רק מטא-דאטה + assert all("status" not in r and "active_value" not in r for r in rows) + + def test_shared_variable_appears_in_both_pages(self): + # משתנה משותף (למשל MONGODB_URL) מופיע בעמוד ה-webapp עם ערך, וגם בעמוד 2 + # עם ציון השירותים האחרים שלו + overview = self.service.get_config_overview() + rows = self.service.get_other_services_entries() + assert any(e.key == "MONGODB_URL" for e in overview.entries) + shared = next(r for r in rows if r["key"] == "MONGODB_URL") + assert shared["also_webapp"] is True + assert "bot" in shared["service"] + + def test_known_service_assignments(self): + defs = self.service.CONFIG_DEFINITIONS + # לפי אישור המשתמש: BOT_TOKEN/BOT_USERNAME שייכים (גם) ל-webapp + assert "webapp" in defs["BOT_TOKEN"].services and "bot" in defs["BOT_TOKEN"].services + assert "webapp" in defs["BOT_USERNAME"].services + # MCP_SERVER_URL נקרא בוובאפ (oauth identify) — נשאר בעמוד 1 (וגם משותף) + assert "webapp" in defs["MCP_SERVER_URL"].services + # ה-webserver הוא שירות Render נפרד — לא חלק מהבוט + assert defs["SENTRY_WEBHOOK_SECRET"].services == ("webserver",) + assert defs["SENTRY_WEBHOOK_DEDUP_WINDOW_SECONDS"].services == ("webserver",) + # בלעדיים לשירות אחד + assert defs["TELEGRAM_LONG_POLL_TIMEOUT_SECS"].services == ("bot",) + assert defs["LOCK_LEASE_SECONDS"].services == ("bot",) + assert defs["MCP_SERVER_NAME"].services == ("mcp",) + assert defs["SANITY_USER_ID"].services == ("scripts",) + assert defs["WEBAPP_GUNICORN_WORKERS"].services == ("webapp",) + + def test_sensitive_default_masked_in_other_services(self): + rows = self.service.get_other_services_entries() + dummy = next(r for r in rows if r["key"] == "DUMMY_BOT_TOKEN") + # יש לו default לא-ריק והוא TOKEN ⇒ הדיפולט חייב להיות ממוסך + assert dummy["default_value"] in ("********", "") + diff --git a/tests/test_patch_coverage.py b/tests/test_patch_coverage.py index be7ca40a3..0e9185289 100644 --- a/tests/test_patch_coverage.py +++ b/tests/test_patch_coverage.py @@ -44,10 +44,11 @@ def __init__(self): rows = rm.inline_keyboard callbacks = [row[0].callback_data for row in rows] # Expect order: search_files, by_repo_menu, backup_list, show_large_files, show_regular_files - assert callbacks[:5] == [ + assert callbacks[:6] == [ "search_files", "by_repo_menu", "backup_list", + "skill_list", "show_large_files", "show_regular_files", ] @@ -91,9 +92,10 @@ def __init__(self): assert rm is not None rows = rm.inline_keyboard callbacks = [row[0].callback_data for row in rows] - assert callbacks[:4] == [ + assert callbacks[:5] == [ "by_repo_menu", "backup_list", + "skill_list", "show_large_files", "show_regular_files", ] diff --git a/tests/test_skill_manager.py b/tests/test_skill_manager.py new file mode 100644 index 000000000..21b13f931 --- /dev/null +++ b/tests/test_skill_manager.py @@ -0,0 +1,170 @@ +"""טסטים לשכבת אחסון הסקילים (SkillManager) — אחסון נפרד מגיבויים. + +מוקד עיקרי: הסקיל נשמר ומוחזר byte-for-byte (בלי הזרקת metadata.json כמו בגיבויים), +קולקציית ה-skills מבודדת מ-cleanup של הגיבויים, ושני סקילים עם אותו שם לא דורסים זה את זה. + +בעקבות מוסכמת הריפו (fakes בעבודת יד, בלי mongomock) — ראו tests/test_gridfs_backups.py. +""" + +import io +import zipfile + +from file_manager import SkillManager, BackupManager + + +class _FakeFSDoc: + def __init__(self, _id, filename, data, metadata): + self._id = _id + self.filename = filename + self._data = data + self.metadata = metadata + self.length = len(data) + self.uploadDate = None + + +class _FakeGridFS: + """Fake מינימלי ל-GridFS שתומך ב-put/find/get/delete עם התאמת query על metadata.""" + + def __init__(self, docs=None): + self._docs = list(docs or []) + self._seq = 0 + + def put(self, data, filename=None, metadata=None): + self._seq += 1 + _id = self._seq + self._docs.append(_FakeFSDoc(_id, filename, bytes(data), dict(metadata or {}))) + return _id + + @staticmethod + def _match(d, query): + for k, v in (query or {}).items(): + if k == "filename": + if d.filename != v: + return False + elif k == "metadata.user_id": + if (d.metadata or {}).get("user_id") != v: + return False + elif k == "metadata.skill_id": + if (d.metadata or {}).get("skill_id") != v: + return False + elif k == "metadata.backup_id": + if (d.metadata or {}).get("backup_id") != v: + return False + return True + + def find(self, query=None): + return [d for d in self._docs if self._match(d, query or {})] + + def get(self, _id): + for d in self._docs: + if d._id == _id: + class _Out: + def __init__(self, data): + self._data = data + + def read(self): + return self._data + + return _Out(d._data) + raise FileNotFoundError(_id) + + def delete(self, _id): + self._docs = [d for d in self._docs if d._id != _id] + + +def _zip_bytes(extra: bytes = b""): + mem = io.BytesIO() + with zipfile.ZipFile(mem, "w", compression=zipfile.ZIP_DEFLATED) as zf: + zf.writestr("SKILL.md", "# My Skill\ncontent" + extra.decode("latin-1")) + zf.writestr("scripts/run.py", "print('hi')") + return mem.getvalue() + + +def _mgr_with_fake(monkeypatch, fake): + mgr = SkillManager() + monkeypatch.setattr(mgr, "_get_skills_gridfs", lambda: fake) + return mgr + + +def test_skill_saved_and_retrieved_byte_for_byte(monkeypatch): + """הטסט החשוב ביותר: מה שנשמר חוזר byte-for-byte, בלי הזרקת metadata.json.""" + raw = _zip_bytes() + fake = _FakeGridFS() + mgr = _mgr_with_fake(monkeypatch, fake) + + skill_id = mgr.save_skill_bytes(raw, {"user_id": 555, "original_name": "my skill.zip"}) + assert skill_id + + got = mgr.get_skill_bytes(555, skill_id) + assert got == raw # זהות מוחלטת — byte-for-byte + + # בניגוד לגיבויים: אין הזרקת metadata.json לתוך הארכיון + names = zipfile.ZipFile(io.BytesIO(got)).namelist() + assert "metadata.json" not in names + assert "SKILL.md" in names + + +def test_get_skill_bytes_rejects_other_user(monkeypatch): + """סקיל לא נגיש למשתמש אחר (אימות בעלות).""" + raw = _zip_bytes() + fake = _FakeGridFS() + mgr = _mgr_with_fake(monkeypatch, fake) + skill_id = mgr.save_skill_bytes(raw, {"user_id": 111, "original_name": "s.zip"}) + assert mgr.get_skill_bytes(222, skill_id) is None + assert mgr.get_skill_bytes(111, skill_id) == raw + + +def test_two_skills_same_name_no_overwrite(monkeypatch): + """שני סקילים עם אותו שם מקורי אינם דורסים זה את זה.""" + fake = _FakeGridFS() + mgr = _mgr_with_fake(monkeypatch, fake) + raw1 = _zip_bytes() + raw2 = _zip_bytes(extra=b"XYZ") + + id1 = mgr.save_skill_bytes(raw1, {"user_id": 7, "original_name": "skill.zip"}) + id2 = mgr.save_skill_bytes(raw2, {"user_id": 7, "original_name": "skill.zip"}) + assert id1 != id2 + + lst = mgr.list_skills(7) + assert len(lst) == 2 + filenames = {s.file_name for s in lst} + assert len(filenames) == 2 # שמות קבצים שונים בפועל — אין דריסה + + # כל אחד מחזיר את התוכן שלו במדויק + assert mgr.get_skill_bytes(7, id1) == raw1 + assert mgr.get_skill_bytes(7, id2) == raw2 + + +def test_delete_skill_only_own(monkeypatch): + fake = _FakeGridFS() + mgr = _mgr_with_fake(monkeypatch, fake) + sid = mgr.save_skill_bytes(_zip_bytes(), {"user_id": 42, "original_name": "a.zip"}) + # משתמש אחר לא יכול למחוק + res_other = mgr.delete_skills(999, [sid]) + assert res_other["deleted"] == 0 + assert mgr.get_skill_bytes(42, sid) is not None + # הבעלים מוחק + res = mgr.delete_skills(42, [sid]) + assert res["deleted"] == 1 + assert mgr.get_skill_bytes(42, sid) is None + + +def test_cleanup_backups_does_not_touch_skills(monkeypatch, tmp_path): + """cleanup_expired_backups כבול לקולקציית "backups"; קולקציית "skills" לא נסרקת ולא נמחקת.""" + monkeypatch.setenv("BACKUPS_STORAGE", "mongo") + monkeypatch.setenv("BACKUPS_DIR", str(tmp_path)) + + skills_fs = _FakeGridFS() + smgr = _mgr_with_fake(monkeypatch, skills_fs) + raw = _zip_bytes() + sid = smgr.save_skill_bytes(raw, {"user_id": 9, "original_name": "keep.zip"}) + assert sid + + # BackupManager עם קולקציית backups ריקה ו-retention אגרסיבי + bmgr = BackupManager() + monkeypatch.setattr(bmgr, "_get_gridfs", lambda: _FakeGridFS()) + bmgr.cleanup_expired_backups(retention_days=0) + + # הסקיל שרד — קולקציה נפרדת לגמרי + assert smgr.get_skill_bytes(9, sid) == raw + assert len(smgr.list_skills(9)) == 1 diff --git a/utils.py b/utils.py index d54aa4bfe..12c84239a 100644 --- a/utils.py +++ b/utils.py @@ -1644,3 +1644,86 @@ def build_zip_bytes(items, *, max_files: int = ZIP_CREATE_MAX_FILES, continue buf.seek(0) return buf.getvalue() + + +# ----- ניהול ZIP ממתין לבחירת יעד (סקיל/גיבוי) ברגע ההעלאה ----- +# ה-bytes נשמרים בקובץ זמני עד שהמשתמש בוחר כפתור; המטא הקטן נשמר ב-user_data. +# המחיקות מוגבלות לתת-תיקייה ייעודית בלבד (allowlist), לפי כללי הבטיחות של הריפו. + +PENDING_ZIP_SUBDIR = "codebot_pending_zip" +PENDING_ZIP_TTL_SECONDS = 3600 # שעה — קובץ ממתין שלא נבחר נחשב נטוש ומנוקה +# תבנית token בטוח לשם קובץ (uuid/hex) — בלי מפרידי נתיב או רכיבי traversal +_SAFE_TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]+$") + + +def _pending_zip_dir() -> Path: + """מחזיר (ויוצר) את תיקיית ה-ZIP הממתינים — תת-תיקייה ייעודית תחת tmp.""" + d = Path(tempfile.gettempdir()) / PENDING_ZIP_SUBDIR + d.mkdir(parents=True, exist_ok=True) + # הידוק הרשאות לפרטיות המשתמש (הקבצים מכילים קוד שהמשתמש העלה) + try: + os.chmod(d, 0o700) + except Exception: + pass + return d + + +def _is_under_pending_dir(path: Path) -> bool: + """בטיחות מחיקה: מוודא שהנתיב באמת מתחת לתיקיית ה-pending הייעודית (ולא היא עצמה).""" + try: + base = _pending_zip_dir().resolve() + rp = path.resolve() + return rp != base and base in rp.parents + except Exception: + return False + + +def stash_pending_zip_bytes(raw: bytes, token: str) -> str: + """שומר bytes של ZIP ממתין לבחירה בקובץ זמני ומחזיר את הנתיב המלא. + + ה-token חייב להיות מזהה בטוח לשם קובץ (ללא מפרידי נתיב או '..'). + """ + if not token or not _SAFE_TOKEN_RE.match(token): + raise ValueError("invalid pending-zip token") + path = _pending_zip_dir() / f"{token}.bin" + with open(path, "wb") as f: + f.write(raw) + return str(path) + + +def load_pending_zip_bytes(path: str) -> Optional[bytes]: + """קורא bytes של ZIP ממתין מהנתיב שנשמר, או None אם לא קיים/מחוץ ל-allowlist.""" + try: + p = Path(path) + if not _is_under_pending_dir(p) or not p.exists(): + return None + return p.read_bytes() + except Exception: + return None + + +def cleanup_pending_zip(path: str) -> None: + """מוחק קובץ ZIP ממתין בודד (רק אם הוא מתחת לתיקיית ה-pending הייעודית).""" + try: + if not path: + return + p = Path(path) + if _is_under_pending_dir(p) and p.exists(): + p.unlink() + except Exception: + pass + + +def cleanup_stale_pending_zips(max_age_seconds: int = PENDING_ZIP_TTL_SECONDS) -> None: + """מנקה קבצי ZIP ממתינים ישנים (שלא נבחרו) — סורק רק קבצי .bin בתיקייה הייעודית.""" + try: + d = _pending_zip_dir() + now = time.time() + for f in d.glob("*.bin"): + try: + if now - f.stat().st_mtime > max_age_seconds: + f.unlink() + except Exception: + continue + except Exception: + pass diff --git a/webapp/app.py b/webapp/app.py index 7b803f5b0..e2c9e5e27 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -5658,12 +5658,15 @@ def admin_config_inspector_page(): ) category_summary = service.get_category_summary() missing_required = service.validate_required() + # עמוד 2: משתני שירותים אחרים (bot/mcp/scripts) — מטא-דאטה בלבד, בלי Status/Active Value + other_services = service.get_other_services_entries() return render_template( "admin_config_inspector.html", overview=overview, category_summary=category_summary, missing_required=missing_required, + other_services=other_services, selected_category=category, selected_status=status, statuses=[s.value for s in ConfigStatus], diff --git a/webapp/templates/admin_config_inspector.html b/webapp/templates/admin_config_inspector.html index c67c378e8..8d87297a4 100644 --- a/webapp/templates/admin_config_inspector.html +++ b/webapp/templates/admin_config_inspector.html @@ -333,10 +333,57 @@ color: #ff627c; } +/* "הוגדר בסביבה" (Set) — נצבע דרך טוקן --info כדי לא להוסיף צבעים קשיחים חדשים */ +.status-pill.set { + background: color-mix(in srgb, var(--info, #4299e1) 18%, transparent); + color: var(--info, #4299e1); +} + .status-pill i { font-size: 0.75rem; } +/* טאבים: עמוד Webapp / עמוד שירותים אחרים */ +.inspector-tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 1.25rem; + flex-wrap: wrap; +} +.inspector-tab { + background: var(--glass, rgba(255, 255, 255, 0.1)); + border: 1px solid var(--glass-border, rgba(255, 255, 255, 0.2)); + color: inherit; + border-radius: 10px; + padding: 0.5rem 1rem; + cursor: pointer; + font-size: 0.95rem; + transition: background 0.2s, border-color 0.2s; +} +.inspector-tab:hover { + background: var(--glass-hover, rgba(255, 255, 255, 0.15)); +} +.inspector-tab.active { + background: var(--glass-hover, rgba(255, 255, 255, 0.15)); + border-color: var(--primary, #667eea); + font-weight: 600; +} +.inspector-page { display: none; } +.inspector-page.active { display: block; } + +/* תגית שירות (bot/mcp/scripts) בעמוד השירותים האחרים */ +.service-badge { + display: inline-block; + padding: 0.15rem 0.55rem; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 600; + background: var(--glass, rgba(255, 255, 255, 0.1)); + border: 1px solid var(--glass-border, rgba(255, 255, 255, 0.2)); + text-transform: uppercase; + letter-spacing: 0.03em; +} + /* Description tooltip */ .config-desc { font-size: 0.8rem; @@ -572,6 +619,18 @@ {% endif %} + +
+ + +
+ +
+
@@ -582,6 +641,10 @@

סה"כ משתנים

שונו מדיפולט

{{ overview.modified_count }}
+
+

הוגדרו בסביבה

+
{{ overview.set_count }}
+

חסרים

{{ overview.missing_count }}
@@ -648,6 +711,9 @@

{{ stats.modified }} + + {{ stats.set }} + {{ stats.missing }} @@ -733,6 +799,8 @@

{% if entry.status.value == 'Modified' %} + {% elif entry.status.value == 'Set' %} + {% elif entry.status.value == 'Missing' %} {% else %} @@ -766,10 +834,99 @@

סה"כ: {{ overview.total_count }} משתנים

+ +
+ + +
+
+ +
+ המשתנים כאן שייכים לשירותי Render שאינם ה-Webapp (Bot / MCP / Webserver / Scripts) — + כולל משתנים משותפים שמוגדרים בכמה שירותים במקביל. עמודת "שירות" מציינת לאילו + שירותים מתייחסת כל שורה. הערכים חיים בתהליכים נפרדים ואינם נגישים משירות ה-Webapp, + ולכן אין כאן Status או Active Value — לערכים בפועל יש לבדוק ב-Render Dashboard של + השירות הרלוונטי. שורות המסומנות "גם Webapp" מופיעות עם ערכיהן בעמוד הראשון. +
+
+ +
+

+ + משתני Bot / MCP / Webserver / Scripts +

+ + {% if other_services %} + + + + + + + + + + + {% for row in other_services %} + + + + + + + {% endfor %} + +
KeyשירותDefault Valueתיאור
+
+ {{ row.category }} + {{ row.key }} +
+
+ {{ row.service }} + {% if row.also_webapp %} + גם Webapp + {% endif %} + +
+ {% if row.default_value %} + {{ row.default_value }} + {% if row.is_sensitive %} + + {% endif %} + {% else %} + (ללא) + {% endif %} +
+
+
{{ row.description or '—' }}
+
+ {% else %} +
+

אין משתנים של שירותים אחרים

+
+ {% endif %} +
+