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)
שלב |
תיאור |
|---|---|
בחירת מצב |
”🧩 קוד רגיל“ או ”✍️ קוד ארוך“ (מצב איסוף רב־חלקי, סיום ב־/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,
)
טבלת זרימות מלאה
פקודה / טריגר |
Entry Point |
States מרכזיים |
Handlers עיקריים |
קישורי קוד |
|---|---|---|---|---|
|
|
|
|
save_flow.py (start_save_flow) · get_code · get_filename · get_note · save_file_final |
|
|
|
|
|
|
|
— (דיאלוג כפתורים באמצעות |
|
|
|
|
— (דיאלוג כפתורים באמצעות |
|
הערות וקישורי API רלוונטיים:
שירותים:
services.code_service,services.github_service,services.backup_service,services.google_drive_serviceשיחת שמירה מרכזית: conversation_handlers.get_save_conversation_handler
פקודת /save (זרימה מקוצרת): main.py: save_command
קישורים
דוגמאות קוד תמציתיות (ממשיות)
Save – שלבים עיקריים
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
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
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
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)
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 – תפריט ושיחת העלאה
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"
)
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 – תפריט
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)
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 – תפריט
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")
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)