diff --git a/GUIDES/BOT_USER_GUIDE.md b/GUIDES/BOT_USER_GUIDE.md index ad15897b7..8091c2782 100644 --- a/GUIDES/BOT_USER_GUIDE.md +++ b/GUIDES/BOT_USER_GUIDE.md @@ -748,7 +748,7 @@ class DataHandler: 2. בחר מקור: - **✍️ הדבק קוד** - הדבק קוד ישירות - **🗂 לפי ריפו** - בחר מקבצים לפי ריפו - - **📦 קבצי ZIP** - בחר מקבצי ZIP שמורים + - **📦 קבצי גיבוי** - בחר מקבצי ZIP שמורים - **📂 קבצים גדולים** - בחר מקבצים גדולים (500+ שורות) - **📁 שאר הקבצים** - בחר מכל הקבצים האחרים 3. בחר קובץ להעלאה @@ -1071,7 +1071,7 @@ git checkout -b restore-from-checkpoint **תפריט גיבוי ל-Drive:** ``` -📦 קבצי ZIP - גיבוי של כל קבצי ה-ZIP השמורים בבוט +📦 קבצי גיבוי - גיבוי של כל קבצי ה-ZIP השמורים בבוט 🧰 הכל - גיבוי של כל הקבצים בבוט 📂 בחר תיקיית יעד - בחירת תיקייה ב-Drive להעלאה ⏰ קבע תזמון - תזמון אוטומטי לגיבויים @@ -1084,7 +1084,7 @@ git checkout -b restore-from-checkpoint ### איך לגבות ל-Drive? 1. **בחר סוג גיבוי:** - - **📦 קבצי ZIP** - רק קבצי ZIP שיצרת או הורדת + - **📦 קבצי גיבוי** - רק קבצי ZIP שיצרת או הורדת - **🧰 הכל** - כל הקבצים השמורים בבוט 2. **בחר תיקיית יעד (אופציונלי):** diff --git a/backup_menu_handler.py b/backup_menu_handler.py index 29679f7c1..6fe7ac87e 100644 --- a/backup_menu_handler.py +++ b/backup_menu_handler.py @@ -10,6 +10,7 @@ from services import backup_service as backup_manager from handlers.pagination import build_pagination_row +from i18n.strings_he import BTN_BACKUP_ZIPS logger = logging.getLogger(__name__) @@ -627,7 +628,7 @@ def _key(v: Any) -> float: id_to_version[getattr(b, 'backup_id', '')] = idx except Exception: id_to_version = {} - lines = [f"📦 קבצי ZIP שמורים — סה\"כ: {total}\n📄 עמוד {page} מתוך {total_pages}\n"] + lines = [f"{BTN_BACKUP_ZIPS} שמורים — סה\"כ: {total}\n📄 עמוד {page} מתוך {total_pages}\n"] keyboard = [] delete_mode = bool(context.user_data.get("backup_delete_mode")) selected = set(context.user_data.get("backup_delete_selected", set())) diff --git a/config.py b/config.py index 0c7936308..9aa12bbb2 100644 --- a/config.py +++ b/config.py @@ -333,6 +333,11 @@ class BotConfig(BaseSettings): default="https://amirbiron.github.io/CodeBot/", description="Docs URL" ) BOT_LABEL: str = Field(default="CodeBot", description="Bot label for UI") + # אימוג'י מותאם (טלגרם פרימיום) לאייקון ZIP בהודעות הבוט; ריק/None => אימוג'י רגיל. + # ה-ID הוא משאב חיצוני (חבילת צד ג') ולכן חי רק ב-ENV — לא מוטבע בקוד. + CUSTOM_EMOJI_ZIP_ID: Optional[str] = Field( + default=None, description="Custom emoji ID for ZIP icon (Telegram premium)" + ) DRIVE_ADD_HASH: bool = Field( default=False, description="Append hash to filenames to avoid collisions" ) diff --git a/conversation_handlers.py b/conversation_handlers.py index 22b0c9f16..8a29a9825 100644 --- a/conversation_handlers.py +++ b/conversation_handlers.py @@ -40,6 +40,7 @@ def set_activity_reporter(new_reporter): from utils import TelegramUtils, TextUtils, ValidationUtils from services import code_service from i18n.strings_he import MAIN_MENU as MAIN_KEYBOARD +from i18n.strings_he import BTN_BACKUP_ZIPS from handlers.pagination import build_pagination_row from config import config from urllib.parse import quote_plus @@ -688,7 +689,7 @@ async def submit_flows_cancel(update: Update, context: ContextTypes.DEFAULT_TYPE "🗂 לפי ריפו — קבצים מאורגנים לפי פרויקט\n" "📂 קבצים גדולים — תצוגה מדורגת לקבצים ארוכים\n" "📁 שאר הקבצים — כל השאר\n" - "📦 קבצי ZIP — גיבויים/ארכיונים\n\n" + "📦 קבצי גיבוי — ארכיוני ZIP (גיבויים וריפואים מגיטהאב)\n\n" "לכל קובץ יש תפריט עם:\n" "👁️ הצג | ✏️ ערוך | 📝 שנה שם\n" "📚 היסטוריה | 📥 הורד | 🗑️ העבר לסל\n\n" @@ -894,7 +895,7 @@ def _count_and_save_skill(raw: bytes, user_id: int, original_name: str): async def _handle_zip_route(update: Update, context: ContextTypes.DEFAULT_TYPE, data: str) -> None: - """מטפל בבחירת יעד ל-ZIP שהועלה: '📝 סקיל' (אחסון קבוע as-is) או '📦 גיבוי' (רשימת הגיבויים). + """מטפל בבחירת יעד ל-ZIP שהועלה: '🧩 סקיל' (אחסון קבוע as-is) או '📦 גיבוי' (רשימת הגיבויים). ה-bytes נטענים מהקובץ הזמני שנשמר ב-_maybe_store_zip_copy; השמירה הכבדה רצה ב-thread נפרד כדי לא לחסום את לולאת האירועים. @@ -932,7 +933,7 @@ async def _handle_zip_route(update: Update, context: ContextTypes.DEFAULT_TYPE, await TelegramUtils.safe_edit_message_text( query, f"✅ נשמר כסקיל: {html_escape(original_name)}\n" - "🔎 ניתן למצוא אותו תחת: '📚' ← '📝 סקילים'.", + "🔎 ניתן למצוא אותו תחת: '📚' ← '🧩 סקילים'.", parse_mode=ParseMode.HTML, ) else: @@ -959,7 +960,7 @@ async def _handle_zip_route(update: Update, context: ContextTypes.DEFAULT_TYPE, await TelegramUtils.safe_edit_message_text( query, "✅ קובץ ZIP נשמר בהצלחה לרשימת ה‑ZIP השמורים.\n" - "📦 ניתן למצוא אותו תחת: '📚' ← '📦 קבצי ZIP' או ב‑Batch/GitHub." + f"📦 ניתן למצוא אותו תחת: '📚' ← '{BTN_BACKUP_ZIPS}' או ב‑Batch/GitHub." ) else: await TelegramUtils.safe_edit_message_text( @@ -1076,8 +1077,8 @@ async def show_all_files(update: Update, context: ContextTypes.DEFAULT_TYPE) -> keyboard = [ [InlineKeyboardButton("🔎 חפש קובץ", callback_data="search_files")], [InlineKeyboardButton("🗂 לפי ריפו", callback_data="by_repo_menu")], - [InlineKeyboardButton("📦 קבצי ZIP", callback_data="backup_list")], - [InlineKeyboardButton("📝 סקילים", callback_data="skill_list")], + [InlineKeyboardButton(BTN_BACKUP_ZIPS, 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")], @@ -1147,8 +1148,8 @@ async def show_all_files_callback(update: Update, context: ContextTypes.DEFAULT_ pass keyboard = [ [InlineKeyboardButton("🗂 לפי ריפו", callback_data="by_repo_menu")], - [InlineKeyboardButton("📦 קבצי ZIP", callback_data="backup_list")], - [InlineKeyboardButton("📝 סקילים", callback_data="skill_list")], + [InlineKeyboardButton(BTN_BACKUP_ZIPS, 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")], @@ -4766,7 +4767,7 @@ async def show_batch_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> send = update.message.reply_text keyboard = [ [InlineKeyboardButton("🗂 לפי ריפו", callback_data="batch_cat:repos")], - [InlineKeyboardButton("📦 קבצי ZIP", callback_data="batch_cat:zips")], + [InlineKeyboardButton(BTN_BACKUP_ZIPS, callback_data="batch_cat:zips")], [InlineKeyboardButton("📂 קבצים גדולים", callback_data="batch_cat:large")], [InlineKeyboardButton("📁 שאר הקבצים", callback_data="batch_cat:other")], [InlineKeyboardButton("📋 סטטוס עבודות", callback_data="show_jobs")], @@ -4894,7 +4895,7 @@ async def show_batch_zips_menu(update: Update, context: ContextTypes.DEFAULT_TYP if not backups: keyboard = [[InlineKeyboardButton("🔙 חזור", callback_data="batch_menu")]] await query.edit_message_text( - "ℹ️ לא נמצאו קבצי ZIP שמורים.", + "ℹ️ לא נמצאו קבצי גיבוי שמורים.", reply_markup=InlineKeyboardMarkup(keyboard) ) return ConversationHandler.END @@ -4910,7 +4911,7 @@ async def show_batch_zips_menu(update: Update, context: ContextTypes.DEFAULT_TYP end = min(start + PAGE_SIZE, total) items = backups[start:end] - lines = [f'📦 קבצי ZIP שמורים — סה"כ: {total}\n📄 עמוד {page} מתוך {total_pages}\n'] + lines = [f'{BTN_BACKUP_ZIPS} שמורים — סה"כ: {total}\n📄 עמוד {page} מתוך {total_pages}\n'] keyboard = [] # חישוב גרסאות vN לפי ריפו repo_to_sorted: Dict[str, list] = {} diff --git a/docs/environment-variables.rst b/docs/environment-variables.rst index 77b6b117c..3419fa785 100644 --- a/docs/environment-variables.rst +++ b/docs/environment-variables.rst @@ -832,6 +832,12 @@ - ``CodeBot`` - ``CKB`` - Bot/WebApp + * - ``CUSTOM_EMOJI_ZIP_ID`` + - Custom emoji ID לאייקון ZIP בהודעות הבוט (טלגרם פרימיום, לבעל בוט עם מנוי); ריק = אימוג'י רגיל 📁. + - לא + - (ללא) + - ``5069094945915142952`` + - Bot * - ``DRIVE_ADD_HASH`` - הוספת hash לקבצים משותפים - לא diff --git a/docs/handlers/drive_menu.rst b/docs/handlers/drive_menu.rst index db797ddf1..14677db5d 100644 --- a/docs/handlers/drive_menu.rst +++ b/docs/handlers/drive_menu.rst @@ -3,7 +3,7 @@ Drive Menu V2 סקירה ----- -תפריט הגיבוי ל‑Google Drive (גרסת V2) כולל בחירה מהירה (קבצי ZIP/הכל/מתקדם), בחירת תיקיית יעד (אוטומטי/ברירת מחדל/מותאם), תזמון גיבוי, וטיפול שגיאות ברור. +תפריט הגיבוי ל‑Google Drive (גרסת V2) כולל בחירה מהירה (קבצי גיבוי/הכל/מתקדם), בחירת תיקיית יעד (אוטומטי/ברירת מחדל/מותאם), תזמון גיבוי, וטיפול שגיאות ברור. דגל פיצ'ר ---------- @@ -11,7 +11,7 @@ Drive Menu V2 זרימות עיקריות --------------- -- קבצי ZIP: העלאת ZIPים שמורים בבוט, כולל הצעה ליצור ZIP אם אין. +- קבצי גיבוי: העלאת ZIPים שמורים בבוט, כולל הצעה ליצור ZIP אם אין. - הכל: יצירת ZIP מלא והעלאה. - מתקדם: לפי ריפו / קבצים גדולים / שאר; בחירה מרובה. - תיקיית יעד: אוטומטי (סידור לפי קטגוריה/תאריך), ברירת מחדל, או נתיב מותאם. diff --git a/github_menu_handler.py b/github_menu_handler.py index 515d0a6e2..35e85f4b5 100644 --- a/github_menu_handler.py +++ b/github_menu_handler.py @@ -20,6 +20,8 @@ import errno from urllib.parse import urlparse +from i18n.strings_he import BTN_BACKUP_ZIPS + # Shim: expose a 'requests' object for tests and route GETs through it. # This allows monkeypatching gh.requests.get in tests while still using # our pooled http client under the hood. @@ -1497,7 +1499,7 @@ async def handle_menu_callback(self, update: Update, context: ContextTypes.DEFAU keyboard = [ [InlineKeyboardButton("✍️ הדבק קוד", callback_data="upload_paste_code")], [InlineKeyboardButton("🗂 לפי ריפו", callback_data="gh_upload_cat:repos")], - [InlineKeyboardButton("📦 קבצי ZIP", callback_data="gh_upload_cat:zips")], + [InlineKeyboardButton(BTN_BACKUP_ZIPS, callback_data="gh_upload_cat:zips")], [InlineKeyboardButton("📂 קבצים גדולים", callback_data="gh_upload_cat:large")], [InlineKeyboardButton("📁 שאר הקבצים", callback_data="gh_upload_cat:other")], [InlineKeyboardButton("🔙 חזור", callback_data="github_menu")], @@ -1528,7 +1530,7 @@ async def handle_menu_callback(self, update: Update, context: ContextTypes.DEFAU keyboard = [ [InlineKeyboardButton("✍️ הדבק קוד", callback_data="upload_paste_code")], [InlineKeyboardButton("🗂 לפי ריפו", callback_data="gh_upload_cat:repos")], - [InlineKeyboardButton("📦 קבצי ZIP", callback_data="gh_upload_cat:zips")], + [InlineKeyboardButton(BTN_BACKUP_ZIPS, callback_data="gh_upload_cat:zips")], [InlineKeyboardButton("📂 קבצים גדולים", callback_data="gh_upload_cat:large")], [InlineKeyboardButton("📁 שאר הקבצים", callback_data="gh_upload_cat:other")], [InlineKeyboardButton("🔙 חזור", callback_data="github_menu")], @@ -1579,12 +1581,12 @@ async def handle_menu_callback(self, update: Update, context: ContextTypes.DEFAU backup_handler = BackupMenuHandler() context.bot_data['backup_handler'] = backup_handler except Exception as e: - await query.edit_message_text(f"❌ רכיב גיבוי לא זמין: {e}") + await TelegramUtils.safe_edit_message_text(query, f"❌ רכיב גיבוי לא זמין: {e}") return try: await backup_handler._show_backups_list(update, context, page=1) except Exception as e: - await query.edit_message_text(f"❌ שגיאה בטעינת קבצי ZIP: {e}") + await TelegramUtils.safe_edit_message_text(query, f"❌ שגיאה בטעינת קבצי גיבוי: {e}") elif query.data.startswith("gh_upload_zip_browse:"): # עיון בקובץ ZIP שמור ובחירת קובץ מתוכו להעלאה לריפו backup_id = query.data.split(":", 1)[1] diff --git a/handlers/documents.py b/handlers/documents.py index d88d28a78..da21a3920 100644 --- a/handlers/documents.py +++ b/handlers/documents.py @@ -17,6 +17,7 @@ from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram.constants import ParseMode +from telegram.error import BadRequest from telegram.ext import ContextTypes # שימוש ב-FilesFacade דרך Composition Root כדי להימנע מתלות ישירה ב-DB @@ -27,6 +28,9 @@ logger = logging.getLogger(__name__) +# לוג חד-פעמי לתהליך כשטלגרם דוחה את האימוג'י המותאם (תנאי פרימיום/ID לא תקף) +_custom_emoji_warned = False + # מגבלות ייבוא ZIP — הגנה מפני "פצצת ZIP" (דקומפרסיה מתפוצצת) בעת בניית ריפו מקובץ. # הבדיקות נעשות מול הגודל הלא-דחוס (ZipInfo.file_size) לפני קריאת התוכן לזיכרון. MAX_IMPORT_ZIP_MEMBERS = 2000 # מספר קבצים מקסימלי בארכיון לייבוא @@ -1003,8 +1007,10 @@ async def _maybe_store_zip_copy( stash_pending_zip_bytes, cleanup_pending_zip, cleanup_stale_pending_zips, + tg_emoji, PENDING_ZIP_TTL_SECONDS, ) + from config import config import uuid as _uuid original_name = document.file_name or "upload.zip" @@ -1049,21 +1055,48 @@ async def _maybe_store_zip_copy( "ts": int(datetime.now(timezone.utc).timestamp()), } + # כפתורים: אימוג'י רגיל בלבד — inline keyboard לא תומך ב-custom emoji entities keyboard = InlineKeyboardMarkup([ [ - InlineKeyboardButton("📝 סקיל", callback_data=f"zip_route_skill:{token}"), + InlineKeyboardButton("🧩 סקיל", callback_data=f"zip_route_skill:{token}"), InlineKeyboardButton("📦 גיבוי", callback_data=f"zip_route_backup:{token}"), ] ]) - try: - 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, + + def _zip_prompt_text(icon: str) -> str: + return ( + f"{icon} קיבלתי קובץ ZIP: {html_escape(original_name)}\n" + "איפה לשמור אותו?\n\n" + "🧩 בקטגוריית סקילים — קטגוריה מיוחדת לשמירת סקילים בפורמט ZIP\n" + "📦 בקטגוריית גיבויים — קטגוריה לשמירת Repo's מגיטהאב בפורמט ZIP " + "(אפשר אחר כך לשחזר מהזיפ את כל הריפו בגיטהאב דרך הבוט - בלחיצה)" ) + + try: + # אייקון ZIP מותאם (טלגרם פרימיום) — ה-ID מגיע מ-ENV בלבד; בלי ID נופלים ל-📁 + custom_icon = tg_emoji(getattr(config, "CUSTOM_EMOJI_ZIP_ID", None), "📁") + try: + await update.message.reply_text( + _zip_prompt_text(custom_icon), + reply_markup=keyboard, + parse_mode=ParseMode.HTML, + ) + except BadRequest: + # טלגרם דחתה את ההודעה — אם זה בגלל האימוג'י המותאם (תנאי פרימיום/ID + # שנמחק), שולחים שוב עם האימוג'י הרגיל: המשתמש חייב לקבל את ההודעה. + if custom_icon == "📁": + raise # אין אימוג'י מותאם בהודעה — הכשל ממקור אחר, אין טעם בניסיון זהה + await update.message.reply_text( + _zip_prompt_text("📁"), + reply_markup=keyboard, + parse_mode=ParseMode.HTML, + ) + # מסמנים ומלוגגים רק אחרי שה-fallback עבר — זו ההוכחה שהבעיה הייתה + # האימוג'י (BadRequest ממקור אחר היה מפיל גם את השליחה הזו ומתגלגל הלאה) + global _custom_emoji_warned + if not _custom_emoji_warned: + _custom_emoji_warned = True + logger.warning("האימוג'י המותאם (CUSTOM_EMOJI_ZIP_ID) נדחה ע\"י טלגרם — נופלים לאימוג'י רגיל") except Exception: # בלי כפתורים אין דרך לממש את הבחירה — מנקים את הרשומה והקובץ שנוצרו עבור ה-ZIP הזה cleanup_pending_zip(path) diff --git a/handlers/drive/menu.py b/handlers/drive/menu.py index 0fc78981c..4feb96057 100644 --- a/handlers/drive/menu.py +++ b/handlers/drive/menu.py @@ -19,6 +19,7 @@ from config import config from file_manager import backup_manager from handlers.drive.utils import extract_schedule_key +from i18n.strings_he import BTN_BACKUP_ZIPS from utils import TelegramUtils logger = logging.getLogger(__name__) @@ -697,7 +698,7 @@ async def _poll_once(ctx: ContextTypes.DEFAULT_TYPE): saved_zips = [] sess = self._session(user_id) if sess.get("selected_category") == "zip": - await query.answer("כבר נבחר 'קבצי ZIP'", show_alert=False) + await query.answer(f"כבר נבחר '{BTN_BACKUP_ZIPS}'", show_alert=False) return sess["selected_category"] = "zip" # שמירת בחירה אחרונה בפרפרנסים כדי שתשרוד דיפלוי @@ -706,7 +707,7 @@ async def _poll_once(ctx: ContextTypes.DEFAULT_TYPE): get_files_facade().save_drive_prefs(user_id, {"last_selected_category": "zip"}) except Exception: pass - prefix = "ℹ️ לא נמצאו קבצי ZIP שמורים בבוט. באישור לא יועלה דבר.\n\n" if not saved_zips else "✅ נבחר: קבצי ZIP\n\n" + prefix = "ℹ️ לא נמצאו קבצי גיבוי שמורים בבוט. באישור לא יועלה דבר.\n\n" if not saved_zips else f"✅ נבחר: {BTN_BACKUP_ZIPS}\n\n" await self._render_simple_selection(update, context, header_prefix=prefix) return if data == "drive_sel_all": @@ -1146,11 +1147,11 @@ def label(key: str, text: str) -> str: [InlineKeyboardButton("📦 צור ZIP שמור בבוט", callback_data="drive_make_zip_now")], [InlineKeyboardButton("🔙 חזרה", callback_data="drive_backup_now")], ] - await query.edit_message_text("ℹ️ לא נמצאו קבצי ZIP שמורים בבוט. אפשר ליצור עכשיו ZIP שמור בבוט או לבחור 🧰 הכל.", reply_markup=InlineKeyboardMarkup(kb)) + await query.edit_message_text("ℹ️ לא נמצאו קבצי גיבוי שמורים בבוט. אפשר ליצור עכשיו ZIP שמור בבוט או לבחור 🧰 הכל.", reply_markup=InlineKeyboardMarkup(kb)) return # פידבק מיידי לפני פעולת העלאה שעלולה לקחת זמן try: - await query.edit_message_text("⏳ מעלה קבצי ZIP ל‑Drive…\nזה עשוי לקחת כמה דקות.\n🔔 תתקבל הודעה בסיום.") + await query.edit_message_text("⏳ מעלה קבצי גיבוי ל‑Drive…\nזה עשוי לקחת כמה דקות.\n🔔 תתקבל הודעה בסיום.") except Exception: pass # הרצת ההעלאה בת׳רד נפרד כדי לא לחסום את הלולאה האסינכרונית @@ -1231,7 +1232,7 @@ def label(key: str, text: str) -> str: fn, data_bytes = gdrive.create_full_backup_zip_bytes(user_id, category="all") ok = _backup_service.save_backup_bytes(data_bytes, {"backup_id": os.path.splitext(fn)[0], "user_id": user_id, "backup_type": "manual"}) if ok: - await query.edit_message_text("✅ נוצר ZIP שמור בבוט. עכשיו ניתן לבחור שוב '📦 קבצי ZIP' להעלאה ל‑Drive.") + await query.edit_message_text(f"✅ נוצר ZIP שמור בבוט. עכשיו ניתן לבחור שוב '{BTN_BACKUP_ZIPS}' להעלאה ל‑Drive.") else: await query.edit_message_text("❌ יצירת ה‑ZIP נכשלה. נסה שוב מאוחר יותר.") except Exception: @@ -1343,7 +1344,7 @@ def _compose_selection_header(self, user_id: int) -> str: type_emoji = "" if category == "zip": type_emoji = "📦" - typ = "קבצי ZIP" + typ = "קבצי גיבוי" elif category == "all": type_emoji = "🧰" typ = "הכל" @@ -1400,7 +1401,7 @@ async def _render_simple_selection(self, update: Update, context: ContextTypes.D sess["all_done"] = False # הצג וי ירוק על הבחירה הפעילה (מוצג גם בכותרת למעלה) active = selected or sess.get("last_upload") - zip_label = ("✅ " if active == "zip" else "") + "📦 קבצי ZIP" + zip_label = ("✅ " if active == "zip" else "") + BTN_BACKUP_ZIPS all_label = ("✅ " if active == "all" else "") + "🧰 הכל" folder_label = self._folder_button_label(user_id) schedule_label = self._schedule_button_label(user_id) @@ -1476,7 +1477,7 @@ async def _render_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE) "📚 מדריך גיבוי ל‑Google Drive\n" "━━━━━━━━━━━━━━━━━━━\n\n" "🎯 סוגי גיבוי:\n" - "• 📦 קבצי ZIP - מעלה קבצי ZIP שכבר שמורים בבוט\n" + f"• {BTN_BACKUP_ZIPS} - מעלה קבצי ZIP שכבר שמורים בבוט\n" " └ אם אין ZIP שמורים, ניתן ליצור באמצעות 'צור ZIP שמור בבוט'\n" "• 🧰 הכל - יוצר גיבוי מלא חדש של כל הקבצים ומעלה ל‑Drive\n" " └ הגיבוי נשמר בתיקיית 'הכל' עם תאריך ושעה\n\n" @@ -1518,7 +1519,7 @@ async def _render_simple_summary(self, update: Update, context: ContextTypes.DEF schedule = self._schedule_button_label(user_id).replace("🕑 ", "") txt = ( "סיכום הגדרות:\n" - f"• סוג גיבוי אחרון: {('קבצי ZIP' if last_upload=='zip' else ('הכל' if last_upload=='all' else '—'))}\n" + f"• סוג גיבוי אחרון: {('קבצי גיבוי' if last_upload=='zip' else ('הכל' if last_upload=='all' else '—'))}\n" f"• תיקיית יעד: {folder}\n" f"• תזמון: {schedule if schedule != '🗓 זמני גיבוי' else 'לא נקבע'}\n" ) diff --git a/i18n/strings_he.py b/i18n/strings_he.py index 77be3d0c8..8e506b1ac 100644 --- a/i18n/strings_he.py +++ b/i18n/strings_he.py @@ -6,6 +6,11 @@ ["ℹ️ הסבר על הבוט"] ] +# תווית הכפתור של קטגוריית קבצי ה-ZIP השמורים (גיבויים) — מקור אמת יחיד לתצוגה. +# חשוב: זו תווית בלבד; ה-callback_data (backup_list / batch_cat:zips / gh_upload_cat:zips / +# drive_sel_zip) אינם קשורים אליה ואסור לשנותם — שינוי שלהם שובר כפתורים בהודעות קיימות. +BTN_BACKUP_ZIPS = "📦 קבצי גיבוי" + MESSAGES = { "welcome": ( "🤖 שלום וברוך הבא לבוט שומר הקוד המתקדם!\n\n" diff --git a/services/config_inspector_service.py b/services/config_inspector_service.py index 21b67b992..8586845c6 100644 --- a/services/config_inspector_service.py +++ b/services/config_inspector_service.py @@ -1526,6 +1526,13 @@ class ConfigService: description="תווית הבוט בממשק", category="display", ), + "CUSTOM_EMOJI_ZIP_ID": ConfigDefinition( + key="CUSTOM_EMOJI_ZIP_ID", + services=("bot",), + default="", + description="Custom emoji ID לאייקון ZIP בהודעות הבוט (טלגרם פרימיום); ריק = אימוג'י רגיל", + category="display", + ), "ALERT_EXTERNAL_SERVICES": ConfigDefinition( key="ALERT_EXTERNAL_SERVICES", services=("webapp", "bot", "mcp", "webserver"), diff --git a/skill_menu_handler.py b/skill_menu_handler.py index c639d4eb5..9199cba2f 100644 --- a/skill_menu_handler.py +++ b/skill_menu_handler.py @@ -76,7 +76,7 @@ async def _show_skills_list(self, update: Update, context: ContextTypes.DEFAULT_ await TelegramUtils.safe_edit_message_text( query, "ℹ️ אין סקילים שמורים.\n" - "כדי לשמור סקיל: שלח/י קובץ ZIP ובחר/י '📝 סקיל'.", + "כדי לשמור סקיל: שלח/י קובץ ZIP ובחר/י '🧩 סקיל'.", reply_markup=InlineKeyboardMarkup(keyboard), ) return @@ -99,7 +99,7 @@ async def _show_skills_list(self, update: Update, context: ContextTypes.DEFAULT_ # איסוף דירוגים לפריטי העמוד בקריאה אחת ב-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"] + lines = [f"🧩 סקילים שמורים — סה\"כ: {total}\n📄 עמוד {page} מתוך {total_pages}\n"] keyboard = [] for info in items: name = info.original_name or info.skill_id @@ -110,7 +110,7 @@ async def _show_skills_list(self, update: Update, context: ContextTypes.DEFAULT_ if emoji: second += f" | {emoji}" lines.append(second) - btn = f"📝 {_truncate_middle(name, 28)} - {_format_date(info.created_at)}" + 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}")]) @@ -142,7 +142,7 @@ async def _show_skill_details(self, update: Update, context: ContextTypes.DEFAUL except Exception: note_text = "" lines = [ - f"📝 סקיל: {match.original_name or skill_id}", + f"🧩 סקיל: {match.original_name or skill_id}", f"📅 נוצר: {_format_date(match.created_at)}", f"📁 קבצים: {match.file_count}", f"📏 גודל: {_format_bytes(match.total_size)}", @@ -178,7 +178,7 @@ async def _download_by_id(self, update: Update, context: ContextTypes.DEFAULT_TY 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))}", + caption=f"🧩 {filename} — {_format_bytes(len(raw))}", ) try: await self._show_skills_list(update, context) diff --git a/tests/config.py b/tests/config.py index 728111612..c76589db9 100644 --- a/tests/config.py +++ b/tests/config.py @@ -59,6 +59,8 @@ def __init__(self) -> None: ) self.PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL") or "" self.WEBAPP_URL = os.getenv("WEBAPP_URL") or "" + # אימוג'י מותאם לאייקון ZIP — None כברירת מחדל (כמו בפרודקשן; הטסטים דורסים לפי צורך) + self.CUSTOM_EMOJI_ZIP_ID = os.getenv("CUSTOM_EMOJI_ZIP_ID") or None config = _Cfg() diff --git a/tests/handlers/test_documents.py b/tests/handlers/test_documents.py index 81388dec7..235c828a9 100644 --- a/tests/handlers/test_documents.py +++ b/tests/handlers/test_documents.py @@ -296,6 +296,16 @@ async def test_handle_document_stores_zip_copy(handler_env): # ZIP שהועלה ללא מצב מיוחד: לא נשמר אוטומטית, אלא מציג בחירה סקיל/גיבוי (בחירה מפורשת) assert not handler_env["backup"].saved_bytes, "ZIP לא אמור להישמר אוטומטית — נדרשת בחירה מפורשת" assert any("ZIP" in text for text, _ in replies.messages) + # נעילת הנוסח החדש: "איפה לשמור" (יעד, לא שיטה) + תווית הסקיל עם 🧩 + assert any("איפה לשמור אותו?" in text for text, _ in replies.messages) + button_labels = [ + btn.text + for _, kw in replies.messages if kw.get("reply_markup") + for row in kw["reply_markup"].inline_keyboard + for btn in row + ] + assert "🧩 סקיל" in button_labels + assert "📦 גיבוי" in button_labels markups = [kw.get("reply_markup") for _, kw in replies.messages if kw.get("reply_markup")] assert markups, "צפוי reply_markup עם כפתורי בחירה סקיל/גיבוי" callbacks = [ @@ -317,6 +327,103 @@ async def test_handle_document_stores_zip_copy(handler_env): ) +@pytest.mark.asyncio +async def test_handle_document_zip_custom_emoji_fallback(handler_env, monkeypatch): + """נתיב ה-fallback של האימוג'י המותאם: טלגרם דוחה את ה-tg-emoji → נשלחת שוב עם 📁. + + ה-ZIP חייב להישאר במצב ממתין לבחירה (pending_zip לא מנוקה), והדגל החד-פעמי מסומן + רק אחרי שה-fallback הצליח. + """ + from telegram.error import BadRequest + from config import config as bot_config + from handlers import documents as documents_mod + + monkeypatch.setattr(documents_mod, "_custom_emoji_warned", False) + monkeypatch.setattr(bot_config, "CUSTOM_EMOJI_ZIP_ID", "5069094945915142952") + + zip_bytes = io.BytesIO() + with zipfile.ZipFile(zip_bytes, "w") as zf: + zf.writestr("inner.txt", "content") + payload = zip_bytes.getvalue() + + bot = _DummyBot(payload) + update, replies = _make_update({ + "file_name": "emoji.zip", + "file_size": len(payload), + "file_id": "fid-zip-emoji", + "mime_type": "application/zip", + }) + + # מדמה את טלגרם: דוחה הודעה עם custom emoji entity, מקבלת את השאר + original_reply = update.message.reply_text + + async def rejecting_reply(text, **kwargs): + if "📁' in t for t, _ in replies.messages) + # שליחה מוצלחת עם האימוג'י — הדגל לא מסומן (אין דחייה) + assert documents_mod._custom_emoji_warned is False + from utils import cleanup_pending_zip + for _meta in (context.user_data.get("pending_zip") or {}).values(): + cleanup_pending_zip((_meta or {}).get("path", "")) + + @pytest.mark.asyncio async def test_handle_document_zip_import_restores_backup(handler_env, monkeypatch): zip_bytes = io.BytesIO() diff --git a/tests/test_utils.py b/tests/test_utils.py index ddaa9b4c1..1392c8255 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -57,6 +57,14 @@ def test_file_utils_extension_and_mime(): assert FileUtils.get_mime_type("note.txt") == "text/plain" +def test_tg_emoji_with_and_without_id(): + """tg_emoji: עם ID → תג tg-emoji עם fallback בפנים; בלי ID (None/ריק) → ה-fallback עצמו.""" + from utils import tg_emoji + assert tg_emoji("5069094945915142952", "📁") == '📁' + assert tg_emoji(None, "📁") == "📁" + assert tg_emoji("", "📁") == "📁" + + def test_normalize_code_removes_invisibles_and_normalizes_newlines(): # Compose a string with BOM, CRLF, zero-width space, directional marks, NBSP, and trailing spaces s = "\ufeffline1\r\nline\u200B2\u200E\u200F\u202A\u202B\u202C\u202D\u202E\u2066\u2067\u2068\u2069\rend\u00A0 \t\r\n" diff --git a/utils.py b/utils.py index 8377897b9..51a46f3b9 100644 --- a/utils.py +++ b/utils.py @@ -1318,6 +1318,19 @@ def detect_language_from_filename(filename: str) -> str: # אם לא נמצאה התאמה, נחזיר 'text' return 'text' +def tg_emoji(emoji_id: Optional[str], fallback: str) -> str: + """מחזיר תג לאימוג'י מותאם של טלגרם, או את האימוג'י הרגיל כשאין ID. + + לשימוש בגוף הודעה עם parse_mode=HTML בלבד — כפתורי inline keyboard לא תומכים + ב-message entities, ולכן שם משתמשים תמיד באימוג'י רגיל. השליחה עלולה להיכשל + כשהתנאי הפרימיום לא מתקיים — באחריות הקורא לתפוס BadRequest ולשלוח מחדש עם + ה-fallback (ראו _maybe_store_zip_copy ב-handlers/documents.py). + """ + if not emoji_id: + return fallback + return f'{fallback}' + + def get_language_emoji(language: str) -> str: """מחזיר אימוג'י מתאים לשפת התכנות""" emoji_map = {