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 = {