from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram.ext import ContextTypes from src.config import ADMIN_LOGIN from src.database import ( User, UserSession, UserState, delete_scenario_data, delete_user_recordings_for_scenario, get_all_scenarios, get_connection, get_or_create_user, get_replicas_for_track, get_scenario, get_scenario_stats, get_stats, get_user_session, get_users_in_state, get_users_with_scenario, upsert_user_session, ) from src.decorators import answer_callback, require_state, with_user_and_session from src.logger import logger from src.scenarios import find_available_track # === Тексты сообщений === INTRO_TEXT = """👋 Добро пожаловать! Это бот для сбора датасета озвученных реплик совещаний. Вы будете озвучивать реплики участников совещаний. Каждая дорожка — это реплики одного участника в рамках одного совещания. 📋 Отправляя голосовые сообщения, вы соглашаетесь с тем, что они будут использованы в исследовательских целях для обучения моделей машинного обучения. Нажмите кнопку ниже, чтобы начать.""" NO_MORE_SCENARIOS_TEXT = """📭 Пока нет доступных сценариев для озвучивания. Вы получите уведомление, когда появятся новые сценарии.""" FIRST_REPLICA_INSTRUCTIONS = """🎙 Начинаем запись дорожки! Отправляйте голосовые сообщения с озвучкой реплик. Говорите чётко и естественно. 📝 Реплика 1:""" SHOW_REPLICA_TEXT = "📝 Реплика {num}:" CONFIRM_RESTART_TEXT = """⚠️ Вы уверены, что хотите начать заново? Все текущие записи этой дорожки будут удалены. 💡 Подсказка: после завершения записи можно будет перезаписать отдельные реплики.""" CONFIRM_SAVE_TEXT = """✅ Дорожка полностью озвучена! Сохранить результат?""" INVALID_INPUT_TEXT = "❌ Пожалуйста, отправьте голосовое сообщение." ASK_REPLICA_NUMBER_TEXT = "🔢 Введите номер реплики для перезаписи (1-{max}):" REPEAT_REPLICA_TEXT = "🔄 Перезапись реплики {num}:" # === Клавиатуры === def get_intro_keyboard() -> InlineKeyboardMarkup: return InlineKeyboardMarkup( [ [ InlineKeyboardButton( "✅ Принять и продолжить", callback_data="accept_intro" ) ] ] ) def get_show_replica_keyboard() -> InlineKeyboardMarkup: return InlineKeyboardMarkup( [ [ InlineKeyboardButton( "🔄 Перезаписать предыдущую", callback_data="rerecord_previous" ) ], [InlineKeyboardButton("🔁 Начать заново", callback_data="restart_track")], ] ) def get_confirm_restart_keyboard() -> InlineKeyboardMarkup: return InlineKeyboardMarkup( [ [ InlineKeyboardButton( "✅ Да, начать заново", callback_data="confirm_restart" ) ], [ InlineKeyboardButton( "❌ Нет, продолжить", callback_data="cancel_restart" ) ], ] ) def get_confirm_save_keyboard() -> InlineKeyboardMarkup: return InlineKeyboardMarkup( [ [InlineKeyboardButton("💾 Сохранить", callback_data="save_track")], [ InlineKeyboardButton( "🔄 Перезаписать последнюю", callback_data="rerecord_last" ) ], [ InlineKeyboardButton( "🔢 Перезаписать по номеру", callback_data="ask_replica_number" ) ], ] ) def get_ask_replica_number_keyboard() -> InlineKeyboardMarkup: return InlineKeyboardMarkup( [[InlineKeyboardButton("❌ Отмена", callback_data="cancel_ask_number")]] ) def get_admin_keyboard() -> InlineKeyboardMarkup: return InlineKeyboardMarkup( [ [ InlineKeyboardButton( "🗑 Удалить сценарий", callback_data="delete_scenario_list" ) ], [ InlineKeyboardButton( "👤 Вернуться в пользовательский режим", callback_data="exit_admin" ) ], ] ) def get_admin_upload_confirm_keyboard() -> InlineKeyboardMarkup: return InlineKeyboardMarkup( [ [InlineKeyboardButton("✅ Да, добавить", callback_data="confirm_upload")], [InlineKeyboardButton("❌ Отмена", callback_data="cancel_upload")], ] ) def get_admin_delete_list_keyboard() -> InlineKeyboardMarkup: """Клавиатура со списком сценариев для удаления.""" scenarios = get_all_scenarios() buttons = [ [InlineKeyboardButton(f"📄 {s.id}", callback_data=f"delete_scenario:{s.id}")] for s in scenarios ] buttons.append( [InlineKeyboardButton("❌ Отмена", callback_data="cancel_delete_list")] ) return InlineKeyboardMarkup(buttons) def get_admin_delete_confirm_keyboard() -> InlineKeyboardMarkup: return InlineKeyboardMarkup( [ [InlineKeyboardButton("🗑 Да, удалить", callback_data="confirm_delete")], [InlineKeyboardButton("❌ Отмена", callback_data="cancel_delete")], ] ) # === Вспомогательные функции === async def remove_previous_keyboard( update: Update, context: ContextTypes.DEFAULT_TYPE, session: UserSession ) -> None: """Удаляет inline-кнопки с предыдущего сообщения.""" if session.last_bot_message_id: try: await context.bot.edit_message_reply_markup( chat_id=update.effective_chat.id, message_id=session.last_bot_message_id, reply_markup=None, ) except Exception: pass async def send_message_and_save( update: Update, context: ContextTypes.DEFAULT_TYPE, session: UserSession, text: str, keyboard: InlineKeyboardMarkup | None = None, ) -> int: """Отправляет сообщение и возвращает его ID.""" await remove_previous_keyboard(update, context, session) msg = await update.effective_chat.send_message(text, reply_markup=keyboard) return msg.message_id def get_current_replica_text(session: UserSession) -> str | None: """Возвращает текст текущей реплики.""" if ( not session.scenario_id or session.speaker_id is None or session.replica_index is None ): return None replicas = get_replicas_for_track(session.scenario_id, session.speaker_id) if session.replica_index < len(replicas): return replicas[session.replica_index].text return None def get_track_length(scenario_id: str, speaker_id: int) -> int: """Возвращает количество реплик в дорожке.""" return len(get_replicas_for_track(scenario_id, speaker_id)) def format_replica_message(session: UserSession) -> str: """Форматирует сообщение с репликой.""" replica_text = get_current_replica_text(session) header = SHOW_REPLICA_TEXT.format(num=session.replica_index + 1) return f"{header}\n\n{replica_text}" def format_admin_stats() -> str: """Форматирует статистику для админки.""" stats = get_stats() return f"""📊 Статистика датасета: 📁 Сценарии: {stats["total_scenarios"]} 🎵 Дорожки: {stats["total_tracks"]} (завершено: {stats["completed_tracks"]}) 💬 Реплики: {stats["total_replicas"]} 🎙 Записей: {stats["total_recordings"]} 👥 Пользователей: {stats["total_users"]} Отправьте JSON-файл с новым сценарием для загрузки.""" def create_new_session(user_id: int) -> UserSession: """Создаёт новую сессию пользователя.""" return UserSession( user_id=user_id, state=UserState.INTRO, scenario_id=None, speaker_id=None, replica_index=None, previous_state=None, last_bot_message_id=None, ) # === Обработчики команд === async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Обработчик команды /start.""" user = get_or_create_user(update.effective_user.id) session = get_user_session(user.id) or create_new_session(user.id) session.state = UserState.INTRO msg_id = await send_message_and_save( update, context, session, INTRO_TEXT, get_intro_keyboard() ) session.last_bot_message_id = msg_id upsert_user_session(session) logger.info(f"User {user.id} started bot") async def admin_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Обработчик команды /admin.""" if update.effective_user.username != ADMIN_LOGIN: await update.message.reply_text("❌ У вас нет прав администратора.") return user = get_or_create_user(update.effective_user.id) session = get_user_session(user.id) if not session: session = create_new_session(user.id) session.state = UserState.ADMIN else: session.previous_state = session.state session.state = UserState.ADMIN msg_id = await send_message_and_save( update, context, session, format_admin_stats(), get_admin_keyboard() ) session.last_bot_message_id = msg_id upsert_user_session(session) logger.info(f"Admin {update.effective_user.username} entered admin mode") # === Callback handlers === @answer_callback @with_user_and_session @require_state(UserState.INTRO) async def handle_accept_intro( update: Update, context: ContextTypes.DEFAULT_TYPE, user: User, session: UserSession ) -> None: """Обработчик кнопки 'Принять и продолжить'.""" query = update.callback_query track = find_available_track(user.id) if not track: session.state = UserState.NO_MORE_SCENARIOS await query.edit_message_text(NO_MORE_SCENARIOS_TEXT) else: scenario_id, speaker_id = track session.state = UserState.FIRST_REPLICA session.scenario_id = scenario_id session.speaker_id = speaker_id session.replica_index = 0 replica_text = get_current_replica_text(session) await query.edit_message_text(f"{FIRST_REPLICA_INSTRUCTIONS}\n\n{replica_text}") logger.info(f"User {user.id} started track {scenario_id}/{speaker_id}") session.last_bot_message_id = query.message.message_id upsert_user_session(session) @answer_callback @with_user_and_session @require_state(UserState.SHOW_REPLICA) async def handle_rerecord_previous( update: Update, context: ContextTypes.DEFAULT_TYPE, user: User, session: UserSession ) -> None: """Обработчик кнопки 'Перезаписать предыдущую'.""" query = update.callback_query session.replica_index -= 1 if session.replica_index == 0: session.state = UserState.FIRST_REPLICA replica_text = get_current_replica_text(session) await query.edit_message_text(f"{FIRST_REPLICA_INSTRUCTIONS}\n\n{replica_text}") else: text = format_replica_message(session) await query.edit_message_text(text, reply_markup=get_show_replica_keyboard()) session.last_bot_message_id = query.message.message_id upsert_user_session(session) @answer_callback @with_user_and_session @require_state(UserState.SHOW_REPLICA) async def handle_restart_track( update: Update, context: ContextTypes.DEFAULT_TYPE, user: User, session: UserSession ) -> None: """Обработчик кнопки 'Начать заново'.""" query = update.callback_query session.state = UserState.CONFIRM_RESTART await query.edit_message_text( CONFIRM_RESTART_TEXT, reply_markup=get_confirm_restart_keyboard() ) session.last_bot_message_id = query.message.message_id upsert_user_session(session) @answer_callback @with_user_and_session @require_state(UserState.CONFIRM_RESTART) async def handle_confirm_restart( update: Update, context: ContextTypes.DEFAULT_TYPE, user: User, session: UserSession ) -> None: """Обработчик подтверждения рестарта.""" query = update.callback_query from src.scenarios import delete_partial_track delete_partial_track(user.id, session.scenario_id, session.speaker_id) delete_user_recordings_for_scenario(user.id, session.scenario_id) session.state = UserState.FIRST_REPLICA session.replica_index = 0 replica_text = get_current_replica_text(session) await query.edit_message_text(f"{FIRST_REPLICA_INSTRUCTIONS}\n\n{replica_text}") session.last_bot_message_id = query.message.message_id upsert_user_session(session) logger.info( f"User {user.id} restarted track {session.scenario_id}/{session.speaker_id}" ) @answer_callback @with_user_and_session @require_state(UserState.CONFIRM_RESTART) async def handle_cancel_restart( update: Update, context: ContextTypes.DEFAULT_TYPE, user: User, session: UserSession ) -> None: """Обработчик отмены рестарта.""" query = update.callback_query session.state = UserState.SHOW_REPLICA text = format_replica_message(session) await query.edit_message_text(text, reply_markup=get_show_replica_keyboard()) session.last_bot_message_id = query.message.message_id upsert_user_session(session) @answer_callback @with_user_and_session @require_state(UserState.CONFIRM_SAVE) async def handle_save_track( update: Update, context: ContextTypes.DEFAULT_TYPE, user: User, session: UserSession ) -> None: """Обработчик сохранения дорожки.""" query = update.callback_query from src.scenarios import move_track_to_data move_track_to_data(user.id, session.scenario_id, session.speaker_id) track = find_available_track(user.id) if not track: session.state = UserState.NO_MORE_SCENARIOS session.scenario_id = None session.speaker_id = None session.replica_index = None await query.edit_message_text( f"✅ Дорожка сохранена!\n\n{NO_MORE_SCENARIOS_TEXT}" ) else: scenario_id, speaker_id = track session.state = UserState.FIRST_REPLICA session.scenario_id = scenario_id session.speaker_id = speaker_id session.replica_index = 0 replica_text = get_current_replica_text(session) await query.edit_message_text( f"✅ Дорожка сохранена!\n\n{FIRST_REPLICA_INSTRUCTIONS}\n\n{replica_text}" ) logger.info( f"User {user.id} saved track, started new: {scenario_id}/{speaker_id}" ) session.last_bot_message_id = query.message.message_id upsert_user_session(session) @answer_callback @with_user_and_session @require_state(UserState.CONFIRM_SAVE) async def handle_rerecord_last( update: Update, context: ContextTypes.DEFAULT_TYPE, user: User, session: UserSession ) -> None: """Обработчик 'Перезаписать последнюю'.""" query = update.callback_query track_length = get_track_length(session.scenario_id, session.speaker_id) session.state = UserState.SHOW_REPLICA session.replica_index = track_length - 1 text = format_replica_message(session) await query.edit_message_text(text, reply_markup=get_show_replica_keyboard()) session.last_bot_message_id = query.message.message_id upsert_user_session(session) @answer_callback @with_user_and_session @require_state(UserState.CONFIRM_SAVE) async def handle_ask_replica_number( update: Update, context: ContextTypes.DEFAULT_TYPE, user: User, session: UserSession ) -> None: """Обработчик 'Перезаписать по номеру'.""" query = update.callback_query track_length = get_track_length(session.scenario_id, session.speaker_id) session.state = UserState.ASK_REPLICA_NUMBER await query.edit_message_text( ASK_REPLICA_NUMBER_TEXT.format(max=track_length), reply_markup=get_ask_replica_number_keyboard(), ) session.last_bot_message_id = query.message.message_id upsert_user_session(session) @answer_callback @with_user_and_session @require_state(UserState.ASK_REPLICA_NUMBER) async def handle_cancel_ask_number( update: Update, context: ContextTypes.DEFAULT_TYPE, user: User, session: UserSession ) -> None: """Обработчик отмены ввода номера.""" query = update.callback_query session.state = UserState.CONFIRM_SAVE await query.edit_message_text( CONFIRM_SAVE_TEXT, reply_markup=get_confirm_save_keyboard() ) session.last_bot_message_id = query.message.message_id upsert_user_session(session) @answer_callback @with_user_and_session @require_state(UserState.ADMIN) async def handle_exit_admin( update: Update, context: ContextTypes.DEFAULT_TYPE, user: User, session: UserSession ) -> None: """Обработчик выхода из админки.""" query = update.callback_query if session.previous_state: session.state = session.previous_state session.previous_state = None else: session.state = UserState.INTRO # Показываем соответствующее сообщение if session.state == UserState.INTRO: await query.edit_message_text(INTRO_TEXT, reply_markup=get_intro_keyboard()) elif session.state == UserState.NO_MORE_SCENARIOS: await query.edit_message_text(NO_MORE_SCENARIOS_TEXT) elif session.state == UserState.FIRST_REPLICA: replica_text = get_current_replica_text(session) await query.edit_message_text(f"{FIRST_REPLICA_INSTRUCTIONS}\n\n{replica_text}") elif session.state == UserState.SHOW_REPLICA: text = format_replica_message(session) await query.edit_message_text(text, reply_markup=get_show_replica_keyboard()) elif session.state == UserState.CONFIRM_SAVE: await query.edit_message_text( CONFIRM_SAVE_TEXT, reply_markup=get_confirm_save_keyboard() ) else: await query.edit_message_text("👤 Вы вернулись в пользовательский режим.") session.last_bot_message_id = query.message.message_id upsert_user_session(session) @answer_callback @with_user_and_session @require_state(UserState.ADMIN_UPLOAD_CONFIRM) async def handle_confirm_upload( update: Update, context: ContextTypes.DEFAULT_TYPE, user: User, session: UserSession ) -> None: """Обработчик подтверждения загрузки сценария.""" query = update.callback_query pending = context.user_data.get("pending_scenario") if not pending: await query.edit_message_text("❌ Данные сценария потеряны. Попробуйте снова.") session.state = UserState.ADMIN upsert_user_session(session) return from src.scenarios import load_scenario_from_json try: load_scenario_from_json(pending["id"], pending["data"]) except ValueError as e: await query.edit_message_text(f"❌ Ошибка: {e}") session.state = UserState.ADMIN upsert_user_session(session) return del context.user_data["pending_scenario"] # Уведомляем пользователей в NO_MORE_SCENARIOS for waiting_user_id in get_users_in_state(UserState.NO_MORE_SCENARIOS): try: with get_connection() as conn: row = conn.execute( "SELECT telegram_id FROM users WHERE id = ?", (waiting_user_id,) ).fetchone() if row: msg = "🎉 Появился новый сценарий! Используйте /start" await context.bot.send_message(row[0], msg) except Exception: pass session.state = UserState.ADMIN await query.edit_message_text( f"✅ Сценарий {pending['id']} добавлен!\n\n{format_admin_stats()}", reply_markup=get_admin_keyboard(), ) session.last_bot_message_id = query.message.message_id upsert_user_session(session) logger.info(f"Scenario {pending['id']} uploaded by admin") @answer_callback @with_user_and_session @require_state(UserState.ADMIN_UPLOAD_CONFIRM) async def handle_cancel_upload( update: Update, context: ContextTypes.DEFAULT_TYPE, user: User, session: UserSession ) -> None: """Обработчик отмены загрузки.""" query = update.callback_query context.user_data.pop("pending_scenario", None) session.state = UserState.ADMIN await query.edit_message_text( format_admin_stats(), reply_markup=get_admin_keyboard() ) session.last_bot_message_id = query.message.message_id upsert_user_session(session) # === Delete scenario handlers === @answer_callback @with_user_and_session @require_state(UserState.ADMIN) async def handle_delete_scenario_list( update: Update, context: ContextTypes.DEFAULT_TYPE, user: User, session: UserSession ) -> None: """Показывает список сценариев для удаления.""" query = update.callback_query scenarios = get_all_scenarios() if not scenarios: await query.edit_message_text( "📭 Нет сценариев для удаления.\n\n" + format_admin_stats(), reply_markup=get_admin_keyboard(), ) return try: await query.edit_message_text( "🗑 Выберите сценарий для удаления:", reply_markup=get_admin_delete_list_keyboard(), ) except Exception: pass # Сообщение уже такое же session.last_bot_message_id = query.message.message_id upsert_user_session(session) @answer_callback @with_user_and_session @require_state(UserState.ADMIN) async def handle_select_scenario_delete( update: Update, context: ContextTypes.DEFAULT_TYPE, user: User, session: UserSession ) -> None: """Обработчик выбора сценария для удаления.""" query = update.callback_query scenario_id = query.data.replace("delete_scenario:", "") stats = get_scenario_stats(scenario_id) context.user_data["pending_delete_scenario"] = scenario_id text = f"""🗑 Удаление сценария: {scenario_id} 📊 Статистика: - Дорожек: {stats["total_tracks"]} - Реплик: {stats["total_replicas"]} - Записей: {stats["total_recordings"]} ⚠️ Это действие необратимо! Будут удалены все записи из БД и файлы из data/ и data_partial/. Пользователи, озвучивающие этот сценарий, будут перенаправлены.""" session.state = UserState.ADMIN_DELETE_CONFIRM await query.edit_message_text( text, reply_markup=get_admin_delete_confirm_keyboard() ) session.last_bot_message_id = query.message.message_id upsert_user_session(session) @answer_callback @with_user_and_session @require_state(UserState.ADMIN_DELETE_CONFIRM) async def handle_confirm_delete( update: Update, context: ContextTypes.DEFAULT_TYPE, user: User, session: UserSession ) -> None: """Подтверждение удаления сценария.""" query = update.callback_query scenario_id = context.user_data.get("pending_delete_scenario") if not scenario_id: await query.edit_message_text("❌ Данные потеряны. Попробуйте снова.") session.state = UserState.ADMIN upsert_user_session(session) return from src.scenarios import delete_scenario_files # Получаем пользователей, которые озвучивают этот сценарий affected_users = get_users_with_scenario(scenario_id) # Удаляем файлы и данные deleted_files = delete_scenario_files(scenario_id) delete_scenario_data(scenario_id) del context.user_data["pending_delete_scenario"] # Уведомляем и перенаправляем пользователей for affected_user_id, telegram_id in affected_users: if affected_user_id == user.id: continue # Админа обработаем отдельно try: affected_session = get_user_session(affected_user_id) if affected_session: track = find_available_track(affected_user_id) if track: new_scenario_id, speaker_id = track affected_session.state = UserState.FIRST_REPLICA affected_session.scenario_id = new_scenario_id affected_session.speaker_id = speaker_id affected_session.replica_index = 0 msg = ( "ℹ️ Сценарий, который вы озвучивали, был удалён. Начинаем новый!" ) else: affected_session.state = UserState.NO_MORE_SCENARIOS affected_session.scenario_id = None affected_session.speaker_id = None affected_session.replica_index = None msg = ( "ℹ️ Сценарий, который вы озвучивали, был удалён.\n\n" + NO_MORE_SCENARIOS_TEXT ) upsert_user_session(affected_session) await context.bot.send_message(telegram_id, msg) except Exception: pass session.state = UserState.ADMIN await query.edit_message_text( f"✅ Сценарий {scenario_id} удалён! (файлов: {deleted_files})\n\n" + format_admin_stats(), reply_markup=get_admin_keyboard(), ) session.last_bot_message_id = query.message.message_id upsert_user_session(session) logger.info(f"Scenario {scenario_id} deleted, affected: {len(affected_users)}") @answer_callback @with_user_and_session @require_state(UserState.ADMIN_DELETE_CONFIRM) async def handle_cancel_delete( update: Update, context: ContextTypes.DEFAULT_TYPE, user: User, session: UserSession ) -> None: """Отмена удаления сценария.""" query = update.callback_query context.user_data.pop("pending_delete_scenario", None) session.state = UserState.ADMIN await query.edit_message_text( format_admin_stats(), reply_markup=get_admin_keyboard() ) session.last_bot_message_id = query.message.message_id upsert_user_session(session) @answer_callback @with_user_and_session @require_state(UserState.ADMIN) async def handle_cancel_delete_list( update: Update, context: ContextTypes.DEFAULT_TYPE, user: User, session: UserSession ) -> None: """Отмена выбора сценария для удаления.""" query = update.callback_query await query.edit_message_text( format_admin_stats(), reply_markup=get_admin_keyboard() ) session.last_bot_message_id = query.message.message_id upsert_user_session(session) # === Message handlers === @with_user_and_session @require_state( UserState.FIRST_REPLICA, UserState.SHOW_REPLICA, UserState.REPEAT_REPLICA ) async def handle_voice_message( update: Update, context: ContextTypes.DEFAULT_TYPE, user: User, session: UserSession ) -> None: """Обработчик голосовых сообщений.""" from src.audio import save_voice_message voice = update.message.voice await save_voice_message( context.bot, voice.file_id, user.id, session.scenario_id, session.replica_index ) track_length = get_track_length(session.scenario_id, session.speaker_id) if session.state == UserState.REPEAT_REPLICA: session.state = UserState.CONFIRM_SAVE msg_id = await send_message_and_save( update, context, session, CONFIRM_SAVE_TEXT, get_confirm_save_keyboard() ) session.last_bot_message_id = msg_id upsert_user_session(session) return session.replica_index += 1 if session.replica_index >= track_length: session.state = UserState.CONFIRM_SAVE msg_id = await send_message_and_save( update, context, session, CONFIRM_SAVE_TEXT, get_confirm_save_keyboard() ) else: session.state = UserState.SHOW_REPLICA text = format_replica_message(session) msg_id = await send_message_and_save( update, context, session, text, get_show_replica_keyboard() ) session.last_bot_message_id = msg_id upsert_user_session(session) @with_user_and_session async def handle_replica_number_input( update: Update, context: ContextTypes.DEFAULT_TYPE, user: User, session: UserSession | None, ) -> None: """Обработчик ввода номера реплики.""" if not session or session.state != UserState.ASK_REPLICA_NUMBER: return # Пропускаем, fallback обработает text = update.message.text.strip() track_length = get_track_length(session.scenario_id, session.speaker_id) try: num = int(text) if num < 1 or num > track_length: raise ValueError() except ValueError: await update.message.reply_text(f"❌ Введите число от 1 до {track_length}") return session.state = UserState.REPEAT_REPLICA session.replica_index = num - 1 replica_text = get_current_replica_text(session) msg_id = await send_message_and_save( update, context, session, f"{REPEAT_REPLICA_TEXT.format(num=num)}\n\n{replica_text}", ) session.last_bot_message_id = msg_id upsert_user_session(session) @with_user_and_session @require_state(UserState.ADMIN) async def handle_admin_document( update: Update, context: ContextTypes.DEFAULT_TYPE, user: User, session: UserSession ) -> None: """Обработчик загрузки JSON файла.""" document = update.message.document if not document.file_name.endswith(".json"): await update.message.reply_text("❌ Ожидается JSON-файл.") return file = await context.bot.get_file(document.file_id) file_bytes = await file.download_as_bytearray() from src.scenarios import get_scenario_info, parse_scenario_file json_data, error = parse_scenario_file(bytes(file_bytes)) if error: await update.message.reply_text(f"❌ {error}") return scenario_id = document.file_name.replace(".json", "") if get_scenario(scenario_id): await update.message.reply_text(f"❌ Сценарий {scenario_id} уже существует.") return context.user_data["pending_scenario"] = {"id": scenario_id, "data": json_data} info = get_scenario_info(json_data) text = f"""📄 Сценарий: {scenario_id} 📊 Информация: - Реплик: {info["total_replicas"]} - Дорожек: {info["total_tracks"]} - Speaker IDs: {info["speaker_ids"]} Добавить сценарий?""" session.state = UserState.ADMIN_UPLOAD_CONFIRM msg_id = await send_message_and_save( update, context, session, text, get_admin_upload_confirm_keyboard() ) session.last_bot_message_id = msg_id upsert_user_session(session) # === Fallback handlers === VOICE_EXPECTED_TEXT = "❌ Пожалуйста, отправьте голосовое сообщение с озвучкой реплики." @with_user_and_session async def handle_unexpected_text( update: Update, context: ContextTypes.DEFAULT_TYPE, user: User, session: UserSession | None, ) -> None: """Обработчик неожиданных текстовых сообщений.""" if not session: await update.message.reply_text("Используйте /start для начала работы с ботом.") return voice_states = { UserState.FIRST_REPLICA, UserState.SHOW_REPLICA, UserState.REPEAT_REPLICA, } if session.state in voice_states: await update.message.reply_text(VOICE_EXPECTED_TEXT) # В других состояниях игнорируем текст (например, INTRO, NO_MORE_SCENARIOS)