Conversation Handlers & States

summary:

מסמך זה מרכז את הזרימות העיקריות של ה‑ConversationHandlers וה‑states.

רשימת States (מבחר בפועל)

# handlers/states.py
GET_CODE, GET_FILENAME, GET_NOTE, EDIT_CODE, EDIT_NAME, WAIT_ADD_CODE_MODE, LONG_COLLECT = range(7)

# ספריית סניפטים – זרימת הגשה
CL_COLLECT_TITLE, CL_COLLECT_DESCRIPTION, CL_COLLECT_URL, CL_COLLECT_LOGO = range(7, 11)
SN_COLLECT_TITLE, SN_COLLECT_DESCRIPTION, SN_COLLECT_CODE, SN_COLLECT_LANGUAGE = range(11, 15)
SN_REJECT_REASON = 15
SN_LONG_COLLECT = 16

# github_menu_handler.py (שיחות העלאה)
REPO_SELECT, FILE_UPLOAD, FOLDER_SELECT = range(3)

Save Flow (תרשים מצבים)

        stateDiagram-v2
    [*] --> WAITING_FOR_FILENAME: /save
    WAITING_FOR_FILENAME --> WAITING_FOR_CODE: שם קובץ התקבל
    WAITING_FOR_CODE --> WAITING_FOR_NOTE: קוד התקבל
    WAITING_FOR_NOTE --> [*]: הערה התקבלה/דילוג
    WAITING_FOR_FILENAME --> [*]: /cancel
    WAITING_FOR_CODE --> [*]: /cancel
    WAITING_FOR_NOTE --> [*]: /cancel
    

GitHub Flow (תרשים מצבים)

ספריית סניפטים – זרימת הגשה (Bot)

Snippet Submit Flow

שלב

תיאור

בחירת מצב

”🧩 קוד רגיל“ או ”✍️ קוד ארוך“ (מצב איסוף רב־חלקי, סיום ב־/done)

SN_COLLECT_TITLE

קבלת כותרת (3–180)

SN_COLLECT_DESCRIPTION

קבלת תיאור קצר (עד 1000)

SN_COLLECT_CODE / SN_LONG_COLLECT

קבלת קוד (חד־חלקי או רב־חלקי)

SN_COLLECT_LANGUAGE

קבלת שפה (python/js/…)

ביטול הוגן

  • כפתור ❌ ביטול מסיים את הזרימה ומנקה context.user_data.

  • לחיצה על כל כפתור אחר בזמן הזרימה מבטלת אוטומטית את ההקשר (אין ”הידבקות“ של כפתורים לטקסט הקלט).

התראות אדמין והודעת משתמש

  • אדמין מקבל Inline Keyboard לאישור/דחייה + כפתור ”👁️ הצג סניפט“ (ווב־אפ).

  • המשתמש מקבל הודעה ידידותית בעת אישור/דחייה (כולל סיבת דחייה אם צוינה).

        stateDiagram-v2
    [*] --> GITHUB_MENU: /github
    GITHUB_MENU --> GITHUB_REPO_SELECT: "בחר ריפו"
    GITHUB_REPO_SELECT --> GITHUB_FILE_BROWSE: ריפו נבחר
    GITHUB_FILE_BROWSE --> GITHUB_FILE_BROWSE: ניווט בתיקיות
    GITHUB_FILE_BROWSE --> [*]: קובץ נשמר
    GITHUB_MENU --> [*]: חזרה
    GITHUB_REPO_SELECT --> GITHUB_MENU: חזרה
    GITHUB_FILE_BROWSE --> GITHUB_REPO_SELECT: חזרה
    

דוגמת Handler תמציתית

save_conversation = ConversationHandler(
    entry_points=[CommandHandler('save', start_save)],
    states={
        States.WAITING_FOR_FILENAME: [MessageHandler(filters.TEXT & ~filters.COMMAND, receive_filename)],
        States.WAITING_FOR_CODE: [MessageHandler(filters.TEXT & ~filters.COMMAND, receive_code)],
        States.WAITING_FOR_NOTE: [
            MessageHandler(filters.TEXT & ~filters.COMMAND, receive_note),
            CallbackQueryHandler(skip_note, pattern='^skip_note$')
        ],
    },
    fallbacks=[CommandHandler('cancel', cancel)],
    name="save_conversation",
    persistent=True,
)

טבלת זרימות מלאה

Flows Summary

פקודה / טריגר

Entry Point

States מרכזיים

Handlers עיקריים

קישורי קוד

/save

/save (פקודה) ו/או כפתור ”➕ הוסף קוד חדש“

GET_CODE → GET_FILENAME → GET_NOTE

handlers.save_flow: start_save_flow, get_code, get_filename, get_note, save_file_final

save_flow.py (start_save_flow) · get_code · get_filename · get_note · save_file_final

/github + upload_file

github_menu_command (תפריט), upload_conv_handler (שיחת העלאה)

FILE_UPLOAD → REPO_SELECT → FOLDER_SELECT (העלאה); תפריט GitHub מתנהל ב־CallbackQuery

github_menu_handler.GitHubMenuHandler: github_menu_command, handle_menu_callback, handle_file_upload, handle_text_input

github_menu_command · upload_conv_handler (main.py)

/backup

BackupMenuHandler.show_backup_menu

— (דיאלוג כפתורים באמצעות CallbackQuery בלבד)

BackupMenuHandler.handle_callback_query

show_backup_menu · handle_callback_query

/drive

GoogleDriveMenuHandler.menu

— (דיאלוג כפתורים באמצעות CallbackQuery בלבד)

GoogleDriveMenuHandler.handle_callback

menu · handle_callback

הערות וקישורי API רלוונטיים:

קישורים

דוגמאות קוד תמציתיות (ממשיות)

Save – שלבים עיקריים

handlers/save_flow.py – start_save_flow
async def start_save_flow(update, context: ContextTypes.DEFAULT_TYPE) -> int:
    cancel_markup = InlineKeyboardMarkup([[InlineKeyboardButton("❌ ביטול", callback_data="cancel")]])
    # תמיכה גם בקריאה מתוך callback וגם מתוך הודעת טקסט
    target_msg = getattr(update, "message", None)
    if target_msg is None and getattr(update, "callback_query", None) is not None:
        target_msg = update.callback_query.message
    await target_msg.reply_text(
        "✨ *מצוין!* בואו נצור קוד חדש!\n\n"
        "📝 שלח לי את קטע הקוד המבריק שלך.\n"
        "💡 אני אזהה את השפה אוטומטית ואארגן הכל!",
        reply_markup=cancel_markup,
        parse_mode='Markdown',
    )
    return GET_CODE
handlers/save_flow.py – get_code
async def get_code(update, context: ContextTypes.DEFAULT_TYPE) -> int:
    # קלט טקסט גולמי כפי שנשלח ע"י המשתמש, ללא המרה ל-Markdown. הניקוי המינימלי
    # רץ לפני השמירה (``save_file_final``): רק שם ידוע אם הקובץ הוא Markdown.
    code = update.message.text or ''
    context.user_data['code_to_save'] = code
    lines = len(code.split('\n'))
    chars = len(code)
    words = len(code.split())
    await update.message.reply_text(
        f"✅ *קוד מתקדם התקבל בהצלחה!*\n\n"
        f"📊 **סטטיסטיקות מהירות:**\n"
        f"• 📏 שורות: {lines:,}\n"
        f"• 🔤 תווים: {chars:,}\n"
        f"• 📝 מילים: {words:,}\n\n"
        f"💭 עכשיו תן לי שם קובץ חכם (למשל: `my_amazing_script.py`)\n"
        f"🧠 השם יעזור לי לזהות את השפה ולארגן הכל מושלם!",
        parse_mode='Markdown',
    )
    return GET_FILENAME
handlers/save_flow.py – get_filename
async def get_filename(update, context: ContextTypes.DEFAULT_TYPE) -> int:
    filename = update.message.text.strip()
    user_id = update.message.from_user.id
    # קבלה של שמות ללא סיומת (Dockerfile/Makefile/Procfile), נקודתיים (.gitignore), וגם name.ext רגיל
    if not ValidationUtils.is_valid_filename(filename):
        await update.message.reply_text(
            "🤔 השם נראה קצת מוזר...\n"
            "💡 נסה שם כמו: `script.py` או `index.html`\n"
            "✅ אותיות, מספרים, נקודות וקווים מותרים!"
        )
        return GET_FILENAME
    # בדיקת קיום קובץ קודם: נסה מסלול חדש אם מופעל בדגל סביבה, אחרת DB ישיר
    existing_file = None
    use_new = _should_use_new_save_flow()
    if use_new:
        service = _build_layered_snippet_service()
        existing_entity = await _call_service_method(service, "get_snippet", user_id, filename)
        if existing_entity is not None:
            existing_file = existing_entity
        if existing_file is None:
            facade = _get_files_facade_or_none()
            if facade is not None:
                try:
                    existing_doc = facade.get_latest_version(user_id, filename)
                    existing_file = bool(existing_doc)
                except Exception:
                    existing_file = None
    else:
        facade = _get_files_facade_or_none()
        if facade is not None:
            try:
                existing_file = facade.get_latest_version(user_id, filename)
            except Exception:
                existing_file = None
    if existing_file:
        keyboard = [
            [InlineKeyboardButton("🔄 החלף את הקובץ הקיים", callback_data=f"replace_{filename}")],
            [InlineKeyboardButton("✏️ שנה שם קובץ", callback_data="rename_file")],
            [InlineKeyboardButton("🚫 בטל ושמור במקום אחר", callback_data="cancel_save")],
        ]
        context.user_data['pending_filename'] = filename
        await update.message.reply_text(
            f"⚠️ *אופס!* הקובץ `{filename}` כבר קיים במערכת!\n\n"
            f"🤔 מה תרצה לעשות?",
            reply_markup=InlineKeyboardMarkup(keyboard),
            parse_mode='Markdown',
        )
        return GET_FILENAME
    context.user_data['pending_filename'] = filename
    await update.message.reply_text(
        "📝 רוצה להוסיף הערה קצרה לקובץ?\n"
        "כתוב/כתבי אותה עכשיו או שלח/י 'דלג' כדי לשמור בלי הערה."
    )
    return GET_NOTE
handlers/save_flow.py – get_note
async def get_note(update, context: ContextTypes.DEFAULT_TYPE) -> int:
    note_text = (update.message.text or '').strip()
    if note_text.lower() in {"דלג", "skip", "ללא"}:
        context.user_data['note_to_save'] = ""
    else:
        context.user_data['note_to_save'] = note_text[:280]
    filename = context.user_data.get('pending_filename') or context.user_data.get('filename_to_save')
    user_id = update.message.from_user.id
    return await save_file_final(update, context, filename, user_id)
handlers/save_flow.py – save_file_final
async def save_file_final(update, context, filename, user_id):
    context.user_data['filename_to_save'] = filename
    code = context.user_data.get('code_to_save')
    # מחסום נגד "גרסת רפאים": אם אין תוכן לשמירה ובוצעה זה עתה שמירה מוצלחת לאותו שם קובץ – דלג
    try:
        import time as _time
        last = context.user_data.get('last_save_success') or {}
        last_name = str(last.get('file_name') or '')
        last_ts = int(context.user_data.get('last_save_success_ts') or last.get('saved_at_epoch') or 0)
        code_empty = not code or not str(code).strip()
        if code_empty and last_name == str(filename) and (_time.time() - last_ts) <= 15:
            try:
                emit_event(
                    "save_flow_skip_empty_duplicate",
                    severity="info",
                    operation="save_flow.guard",
                    user_id=int(user_id),
                    file_name=str(filename),
                )
            except Exception:
                pass
            # אל תנקה את last_save_success כדי לשמר עקבות; כן ננקה סטייט של ה-save flow
            _cleanup_save_flow_state(context)
            return ConversationHandler.END
    except Exception:
        # במקרה של כשל בבדיקה – נמשיך כרגיל
        pass
    # נקודת הניקוי היחידה של הזרימה: הקוד והשם ידועים, ורק מכאן ואילך הוא
    # נשמר. מה שנוקה נאמר למשתמש בהודעת ההצלחה.
    cleanup = code_service.clean_pasted_code(code, filename)
    code = cleanup.text
    cleanup_notice = code_service.format_cleanup_notice(cleanup)
    note = (context.user_data.get('note_to_save') or '').strip()
    # נסה מסלול חדש (ארכיטקטורה שכבתית) כאשר מופעל בדגל סביבה
    use_new = _should_use_new_save_flow()
    if use_new:
        try:
            layered_saved = await _save_via_layered_flow(
                update, context, filename, user_id, code, note, cleanup_notice=cleanup_notice,
            )
            if layered_saved:
                return ConversationHandler.END
        except Exception:
            # אם המסלול החדש נכשל, ניפול חזרה למסלול הישן בהמשך
            pass
    try:
        detected_language = code_service.detect_language(code, filename)
        try:
            from src.infrastructure.composition import get_files_facade  # type: ignore
            facade = get_files_facade()
        except Exception:
            facade = None
        success = False
        last_exc: Optional[BaseException] = None
        if facade is not None:
            try:
                success = bool(
                    facade.save_code_snippet(
                        user_id=int(user_id),
                        file_name=str(filename),
                        code=str(code or ""),
                        programming_language=str(detected_language or "text"),
                        description=str(note or ""),
                        tags=None,
                    )
                )
            except Exception as exc:
                success = False
                last_exc = last_exc or exc
        if not success and last_exc is not None:
            raise last_exc
        if success:
            try:
                saved_doc = (facade.get_latest_version(user_id, filename) if facade is not None else None) or {}
                fid = str(saved_doc.get('_id') or '')
            except Exception:
                fid = ''
            await _send_save_success(
                update, context, filename, detected_language, note or '', fid, cleanup_notice=cleanup_notice,
            )
            _cleanup_save_flow_state(context)
            return ConversationHandler.END
        else:
            await update.message.reply_text(
                "💥 אופס! קרתה שגיאה טכנית.\n"
                "🔧 המערכת מתקדמת - ננסה שוב מאוחר יותר!",
                reply_markup=ReplyKeyboardMarkup([[]], resize_keyboard=True),
            )
    except Exception as e:
        try:
            emit_event(
                "save_file_failed",
                severity="anomaly",
                operation="save_flow.save_file_final",
                handled=True,
                user_id=int(user_id),
                file_name=str(filename),
                error=str(e),
            )
            emit_internal_alert(
                name="save_file_failed",
                severity="anomaly",
                summary=f"user_id={user_id}, file_name={filename}",
            )
        except Exception:
            pass
        logger.error(f"Failed to save file for user {user_id}: {e}")
        await update.message.reply_text(
            "🤖 המערכת החכמה שלנו נתקלה בבעיה זמנית.\n"
            "⚡ ננסה שוב בקרוב!",
            reply_markup=ReplyKeyboardMarkup([[]], resize_keyboard=True),
        )
    _cleanup_save_flow_state(context)
    return ConversationHandler.END

GitHub – תפריט ושיחת העלאה

github_menu_handler.py – github_menu_command
    async def github_menu_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """מציג תפריט GitHub"""
        user_id = update.effective_user.id

        session = self.get_user_session(user_id)
        token = self.get_user_token(user_id)

        # נקה דגלי זרימת "הדבק קוד" אם היו פעילים, כדי למנוע תקיעה בזרימה
        try:
            context.user_data.pop("waiting_for_paste_content", None)
            context.user_data.pop("waiting_for_paste_filename", None)
            context.user_data.pop("paste_content", None)
        except Exception:
            pass

        # בנה הודעת סטטוס
        status_msg = "<b>🔧 תפריט GitHub</b>\n\n"
        if token:
            status_msg += "🔑 <b>מחובר ל-GitHub</b>\n"
        else:
            status_msg += "🔒 <b>לא מחובר</b>\n"
        if session.get("selected_repo"):
            status_msg += f"📁 ריפו נבחר: <code>{session['selected_repo']}</code>\n"
        if session.get("selected_folder"):
            status_msg += f"📂 תיקיית יעד: <code>{session['selected_folder']}</code>\n"

        keyboard = []

        # כפתור הגדרת טוקן
        if not token:
            keyboard.append(
                [InlineKeyboardButton("🔑 הגדר טוקן GitHub", callback_data="set_token")]
            )

        # כפתור בחירת ריפו - זמין רק עם טוקן
        if token:
            keyboard.append([InlineKeyboardButton("📁 בחר ריפו", callback_data="select_repo")])
            # יצירת ריפו חדש מ-ZIP גם ללא ריפו נבחר
            keyboard.append([InlineKeyboardButton("🆕 צור ריפו חדש מּZIP", callback_data="github_create_repo_from_zip")])

        # כפתורי העלאה - מוצגים רק אם יש ריפו נבחר
        if token and session.get("selected_repo"):
            # העבר את "בחר תיקיית יעד" למעלה, ישירות אחרי "בחר ריפו"
            keyboard.append([InlineKeyboardButton("📂 בחר תיקיית יעד", callback_data="set_folder")])
            # ניווט בריפו
            keyboard.append([InlineKeyboardButton("🗃️ עיין בריפו", callback_data="browse_repo")])
            # כפתור העלאה
            keyboard.append([InlineKeyboardButton("📤 העלה קובץ חדש", callback_data="upload_file")])
            # פריסת ZIP לתיקייה — פעולת העלאה, ולכן יושבת ליד "העלה קובץ חדש"
            keyboard.append(
                [InlineKeyboardButton("📁 פרוס ZIP לתיקייה", callback_data="github_zip_to_folder")]
            )
            # פעולות נוספות בטוחות
            keyboard.append(
                [InlineKeyboardButton("📥 הורד קובץ מהריפו", callback_data="gh_download_file_menu")]
            )
            # כפתור ייבוא ריפו (ZIP רשמי → ייבוא קבצים ל-DB)
            keyboard.append(
                [InlineKeyboardButton("⬇️ ייבוא ריפו", callback_data="github_import_repo")]
            )
            # ריכוז פעולות מחיקה בתפריט משנה
            keyboard.append(
                [InlineKeyboardButton("🧨 מחק קובץ/ריפו שלם", callback_data="danger_delete_menu")]
            )
            # התראות חכמות
            keyboard.append(
                [InlineKeyboardButton("🔔 התראות חכמות", callback_data="notifications_menu")]
            )
            # תפריט גיבוי/שחזור מרוכז
            keyboard.append(
                [InlineKeyboardButton("🧰 גיבוי ושחזור", callback_data="github_backup_menu")]
            )
        # כפתור ניתוח ריפו - תמיד מוצג אם יש טוקן
        if token:
            keyboard.append([InlineKeyboardButton("🔍 נתח ריפו", callback_data="analyze_repo")])
            keyboard.append([InlineKeyboardButton("✅ בדוק תקינות ריפו", callback_data="validate_repo")])
            # כפתור יציאה (מחיקת טוקן) כאשר יש טוקן
            keyboard.append(
                [InlineKeyboardButton("🚪 התנתק מגיטהאב", callback_data="logout_github")]
            )

        # כפתור הצגת הגדרות
        keyboard.append(
            [InlineKeyboardButton("📋 הצג הגדרות נוכחיות", callback_data="show_current")]
        )

        # כפתור סגירה
        keyboard.append([InlineKeyboardButton("❌ סגור", callback_data="close_menu")])

        reply_markup = InlineKeyboardMarkup(keyboard)

        if update.callback_query:
            await update.callback_query.edit_message_text(
                status_msg, reply_markup=reply_markup, parse_mode="HTML"
            )
        else:
            await update.message.reply_text(
                status_msg, reply_markup=reply_markup, parse_mode="HTML"
            )
main.py – הגדרת upload_conv_handler
        upload_conv_handler = ConversationHandler(
            entry_points=[
                CallbackQueryHandler(github_handler.handle_menu_callback, pattern='^upload_file$')
            ],
            states={
                FILE_UPLOAD: [
                    MessageHandler(filters.Document.ALL, github_handler.handle_file_upload)
                ],
                REPO_SELECT: [
                    MessageHandler(filters.TEXT & ~filters.COMMAND, github_handler.handle_text_input)
                ],
                FOLDER_SELECT: [
                    MessageHandler(filters.TEXT & ~filters.COMMAND, github_handler.handle_text_input)
                ]
            },
            fallbacks=[
                CommandHandler('cancel', _upload_cancel),
                CallbackQueryHandler(_upload_cancel, pattern=r'^cancel$')
            ]
        )

Backup – תפריט

backup_menu_handler.py – show_backup_menu
    async def show_backup_menu(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        query = update.callback_query if update.callback_query else None
        if query:
            await query.answer()
            message = query.edit_message_text
        else:
            message = update.message.reply_text
        
        keyboard = [
            [InlineKeyboardButton("📦 צור גיבוי מלא", callback_data="backup_create_full")],
            [InlineKeyboardButton("♻️ שחזור מגיבוי (ZIP)", callback_data="backup_restore_full_start")],
            [InlineKeyboardButton("🗂 גיבויים אחרונים", callback_data="backup_list")],
        ]
        reply_markup = InlineKeyboardMarkup(keyboard)
        try:
            user_id = (update.callback_query.from_user.id if update.callback_query else update.effective_user.id)
            emit_event("backup_menu_opened", severity="info", user_id=int(user_id))
        except Exception:
            pass
        await message("בחר פעולה מתפריט הגיבוי/שחזור:", reply_markup=reply_markup)
backup_menu_handler.py – handle_callback_query (קטע ראשון)
    async def handle_callback_query(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        query = update.callback_query
        user_id = query.from_user.id
        data = query.data
        
        if data == "backup_create_full":
            await self._create_full_backup(update, context)
        elif data == "backup_restore_full_start":
            await self._show_backups_list(update, context)
        elif data == "backup_list":
            # הצג את הרשימה בעמוד האחרון שבו היינו (אם נשמר), אחרת עמוד 1
            await self._show_backups_list(update, context)
        elif data.startswith("backup_add_note:"):
            backup_id = data.split(":", 1)[1]
            await self._ask_backup_note(update, context, backup_id)
        elif data.startswith("backup_page_"):
            try:
                page = int(data.split("_")[-1])
            except Exception:
                page = 1
            await self._show_backups_list(update, context, page=page)
        elif data.startswith("backup_restore_id:"):
            backup_id = data.split(":", 1)[1]
            await self._restore_by_id(update, context, backup_id)
        elif data.startswith("backup_download_id:"):
            backup_id = data.split(":", 1)[1]
            # הורדה בפועל של קובץ הגיבוי לפי מזהה
            await self._download_by_id(update, context, backup_id)
        elif data.startswith("backup_details:"):
            backup_id = data.split(":", 1)[1]
            await self._show_backup_details(update, context, backup_id)

Drive – תפריט

handlers/drive/menu.py – GoogleDriveMenuHandler.menu
    async def menu(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        # Feature flag: allow fallback to old behavior if disabled
        if not config.DRIVE_MENU_V2:
            query = update.callback_query if update.callback_query else None
            if query:
                await query.answer()
                await query.edit_message_text("התכונה כבויה כרגע (DRIVE_MENU_V2=false)")
            else:
                await update.message.reply_text("התכונה כבויה כרגע (DRIVE_MENU_V2=false)")
            return
        query = update.callback_query if update.callback_query else None
        if query:
            await query.answer()
            send = query.edit_message_text
        else:
            send = update.message.reply_text

        user_id = update.effective_user.id
        try:
            from src.infrastructure.composition import get_files_facade  # type: ignore
            tokens = get_files_facade().get_drive_tokens(user_id)
        except Exception:
            tokens = {}

        # נחשיב "מחובר" אם יש טוקנים שמורים; בדיקת שירות בפועל תעשה לפני העלאה
        # זה מונע מצב מבלבל שבו מוצג "לא מחובר" מיד אחרי התחברות מוצלחת
        service_ready = bool(tokens)
        if not service_ready:
            kb = [[InlineKeyboardButton("🔐 התחבר ל‑Drive", callback_data="drive_auth")]]
            await send("Google Drive\n\nלא מחובר. התחבר כדי לגבות לקבצי Drive.", reply_markup=InlineKeyboardMarkup(kb))
            return

        # מצב תפריט: אל תיצור Job חדש אוטומטית. יצירה מתבצעת רק בפעולות מפורשות
        # (כמו קביעת תזמון או לאחר גיבוי ידני שמעדכן next_run). כאן נסתפק בהצגה.
        # Hydrate session with persisted preferences so selections survive deploys
        try:
            self._hydrate_session_from_prefs(user_id)
        except Exception:
            pass
        # Connected -> show main backup selection directly per requested flow
        await self._render_simple_selection(update, context, header_prefix="Google Drive — מחובר\n")
handlers/drive/menu.py – GoogleDriveMenuHandler.handle_callback (קטע ראשון)
    async def handle_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        query = update.callback_query
        user_id = query.from_user.id
        data = query.data
        await query.answer()

        if data == "drive_menu":
            await self.menu(update, context)
            return
        # Backward compatibility: map old callback to new one
        if data == "drive_advanced":
            data = "drive_sel_adv"

מוקשים נפוצים ופתרונות

query.answer()

# ❌ טעות נפוצה: חסר await
query.answer()

# ✅ נכון
await query.answer()

עריכת הודעות בבטחה – ”Message is not modified“

השתמשו ב־TelegramUtils.safe_edit_message_text / safe_edit_message_reply_markup כדי לבלוע את החריגה הספציפית בלבד.

from utils import TelegramUtils

await TelegramUtils.safe_edit_message_text(query, "טקסט", reply_markup=kb)

Filters

במכונות מצבים: המנעו מללכוד פקודות בתור טקסט חופשי.

MessageHandler(filters.TEXT & ~filters.COMMAND, get_code)

Persistent data (context.user_data)

שמרו דגלים זמניים ובנקודות מעבר (למשל ב־GitHub paste/upload):

context.user_data["waiting_for_paste_content"] = True
context.user_data.pop("waiting_for_paste_filename", None)

דוגמאות טסטים קצרות

Save – התחלה וזרימה בסיסית

import pytest

@pytest.mark.asyncio
async def test_save_flow_happy_path(mock_update, mock_context):
    # Arrange – התחלת זרימה
    from handlers import save_flow
    state = await save_flow.start_save_flow(mock_update, mock_context)
    assert state == save_flow.GET_CODE

    # Act – קבלת קוד ושם
    mock_update.message.text = "print('hi')\n"
    state = await save_flow.get_code(mock_update, mock_context)
    assert state == save_flow.GET_FILENAME

    mock_update.message.text = "script.py"
    state = await save_flow.get_filename(mock_update, mock_context)
    assert state == save_flow.GET_NOTE

    mock_update.message.text = "תיאור"
    state = await save_flow.get_note(mock_update, mock_context)
    # Assert – השיחה מסתיימת לאחר שמירה מוצלחת
    from telegram.ext import ConversationHandler
    assert state in (ConversationHandler.END,)

GitHub – upload_conv_handler

@pytest.mark.asyncio
async def test_github_upload_conversation(mock_update_cb, mock_context):
    # סימולציית כניסה למסך העלאה ובדיקת רישום ה-handlers
    from main import create_application
    app = create_application("dummy")
    # קיימים states FILE_UPLOAD/REPO_SELECT/FOLDER_SELECT – אין צורך להריץ בוט אמיתי בטסט זה
    assert any("ConversationHandler" in type(h).__name__ for h in app.handlers)