Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
4fdfcc7
feat(mcp): פתקים דביקים — צפייה, יצירה ועריכה דרך ה-MCP
claude Jul 20, 2026
fd6b706
feat(webapp): קיצור דרך לעריכת תיאור מתפריט ⋮ בעמוד הקובץ
claude Jul 20, 2026
e97bee5
Merge branch 'main' into claude/mcp-codekeeper-webapp-ldnzsg
amirbiron Jul 20, 2026
9218b77
fix(webapp): תבנית חדשה מגיעה מיד אחרי deploy (ETag כולל גרסת deploy)
claude Jul 20, 2026
e745b7e
Merge branch 'main' into claude/mcp-codekeeper-webapp-ldnzsg
amirbiron Jul 20, 2026
71e3264
feat(webapp): אייקון תיאור על קבצים באוסף + מודאל תצוגה
claude Jul 20, 2026
7ea470a
feat(webapp): הזזת אייקון תיאור בכרטיס אוסף + ארכיון לאוספים
claude Jul 20, 2026
26b25b6
Merge origin/main: יישור הענף מול main + פתרון קונפליקט collections.css
claude Jul 20, 2026
991b1ad
fix(webapp): המרת צבעים קשיחים לטוקני ערכה במודאל התיאור ובכפתור הארכיון
claude Jul 20, 2026
afa7b6d
Merge origin/main: יישור מול main אחרי squash של #3192 + פתרון קונפלי…
claude Jul 20, 2026
c7c49a3
fix(bot): תיקון מספר קבצים בהודעות ZIP + שלב בחירת שם ל-ZIP
claude Jul 24, 2026
bab0701
Merge branch 'main' into claude/mcp-codekeeper-webapp-ldnzsg
amirbiron Jul 24, 2026
fd88aa0
fix(bot): הקשחת יצירת ZIP — ניקוי שמות (Zip-Slip), מגבלות איסוף, to_t…
claude Jul 24, 2026
85bcfa9
fix(bot): הקשחת ZIP סבב 2 — מגבלות לפני הורדה, מניעת שמות כפולים, תיק…
claude Jul 24, 2026
2f84c9f
fix(webapp): Config Inspector — הפרדת שירותים, סטטוס Set, וביטול מיסו…
claude Jul 26, 2026
552bcf4
Merge branch 'main' into claude/mcp-codekeeper-webapp-ldnzsg
amirbiron Jul 26, 2026
78c0a04
fix(webapp): Config Inspector v2 — webserver כשירות נפרד, משתנים משות…
claude Jul 26, 2026
606a1c1
feat(bot): סוג קובץ חדש "סקיל" — אחסון נפרד מגיבויים
claude Jul 26, 2026
b4f4844
fix(bot,webapp): סבב review לסקילים — כשלי CI, אבטחה וביצועים
claude Jul 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 90 additions & 1 deletion conversation_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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"✅ נשמר כסקיל: <code>{html_escape(original_name)}</code>\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 מהקבצים שנאספו ושולח למשתמש.

Expand Down Expand Up @@ -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")],
Expand Down Expand Up @@ -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")],
Expand Down Expand Up @@ -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)

Expand Down
14 changes: 7 additions & 7 deletions docs/environment-variables.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@
- דוגמה
- רכיב
* - ``BOT_TOKEN``
- טוקן הבוט מ-BotFather
- טוקן הבוט מ-BotFather (נדרש גם ב-WebApp לאימות Telegram Login)
- כן
- -
- ``123456:ABC-DEF...``
- Bot
- Bot/WebApp
* - ``MONGODB_URL``
- חיבור ל-MongoDB
- כן
Expand Down Expand Up @@ -899,11 +899,11 @@
- ``srv-xxxx``
- Reporter
* - ``ENABLE_INTERNAL_SHARE_WEB``
- הפעלת שירות שיתוף פנימי
- הפעלת שירות שיתוף פנימי בתוך תהליך הבוט (נקרא ב-main.py; היסטורי — ההרצה הפנימית בוטלה וה-webserver רץ כשירות נפרד)
- לא
- ``false``
- ``true``
- WebApp
- Bot
* - ``PORT``
- פורט להרצת שירותים פנימיים/בדיקות
- לא
Expand Down Expand Up @@ -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``.
- לא
Expand Down Expand Up @@ -1819,7 +1819,7 @@
- לא
- ``dummy_token``
- ``test_token``
- Bot/WebApp
- Bot
* - ``DRIVE_RESCHEDULE_BOOTSTRAP_DELAY``
- כמה שניות להמתין לפני תחילת משימות ה-Drive rescheduler.
- לא
Expand Down
181 changes: 181 additions & 0 deletions file_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import re
import shutil
import time
import uuid

try:
import gridfs # from pymongo
Expand Down Expand Up @@ -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()
Loading
Loading