from telegram import ( InlineKeyboardButton, InlineKeyboardMarkup, KeyboardButton, ReplyKeyboardMarkup, ReplyKeyboardRemove, 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_genders, get_scenario_stats, get_stats, get_user_audio_duration, get_user_session, get_user_stats, get_users_in_state, get_users_with_scenario, update_user_gender, upsert_user_session, ) from src.decorators import answer_callback, require_state, with_user_and_session from src.logger import logger from src.messages import ( ACCEPT_TEXT, ASK_REPLICA_NUMBER_TEXT, CONFIRM_RESTART_TEXT, CONFIRM_SAVE_TEXT, FIRST_REPLICA_INSTRUCTIONS, INTRO_TEXT, INVALID_INTRO_RESPONSE, NO_MORE_SCENARIOS_TEXT, REPEAT_REPLICA_TEXT, SHOW_REPLICA_TEXT, SPECIFY_GENDER_TEXT, TRACK_SAVED_TEXT, VOICE_EXPECTED_TEXT, ) from src.scenarios import find_available_track # === Клавиатуры === def get_intro_keyboard() -> ReplyKeyboardMarkup: return ReplyKeyboardMarkup( [[KeyboardButton(ACCEPT_TEXT)]], resize_keyboard=True, one_time_keyboard=True, ) def get_specify_gender_keyboard() -> InlineKeyboardMarkup: return InlineKeyboardMarkup( [ [InlineKeyboardButton("👨 Мужской", callback_data="select_gender:male")], [InlineKeyboardButton("👩 Женский", callback_data="select_gender:female")], ] ) 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 safe_edit_message( query, text: str, reply_markup: InlineKeyboardMarkup | None = None ) -> None: """Безопасно редактирует сообщение, игнорируя ошибку 'Message is not modified'.""" try: await query.edit_message_text(text, reply_markup=reply_markup) 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) total = get_track_length(session.scenario_id, session.speaker_id) header = SHOW_REPLICA_TEXT.format(num=session.replica_index + 1, total=total) return f"{header}\n\n{replica_text}" def format_first_replica(session: UserSession) -> str: """Форматирует сообщение с первой репликой и инструкциями.""" replica_text = get_current_replica_text(session) total = get_track_length(session.scenario_id, session.speaker_id) header = SHOW_REPLICA_TEXT.format(num=1, total=total) return f"{FIRST_REPLICA_INSTRUCTIONS}\n\n{header}\n\n{replica_text}" def format_admin_stats() -> str: """Форматирует статистику для админки.""" from src.audio import format_duration stats = get_stats() duration_str = format_duration(stats["total_duration"]) return f"""📊 Статистика датасета: 📁 Сценарии: {stats["completed_scenarios"]}/{stats["total_scenarios"]} завершено 🎵 Дорожки: {stats["completed_tracks"]}/{stats["total_tracks"]} озвучено 💬 Реплики: {stats["total_recordings"]}/{stats["total_replicas"]} записано ⏱ Объём: {duration_str} 👥 Пользователей: {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 session.last_bot_message_id = None await update.message.reply_text( INTRO_TEXT, reply_markup=get_intro_keyboard(), parse_mode="Markdown" ) 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 === @with_user_and_session async def handle_accept_intro( update: Update, context: ContextTypes.DEFAULT_TYPE, user: User, session: UserSession | None, ) -> None: """Обработчик согласия с условиями.""" if not session or session.state != UserState.INTRO: return # Пропускаем, следующий обработчик обработает text = update.message.text.strip() if text != ACCEPT_TEXT: await update.message.reply_text(INVALID_INTRO_RESPONSE) return # Удаляем reply-клавиатуру await update.message.reply_text( "✅ Отлично!", reply_markup=ReplyKeyboardRemove(), ) session.state = UserState.SPECIFY_GENDER msg = await update.message.reply_text( SPECIFY_GENDER_TEXT, reply_markup=get_specify_gender_keyboard(), ) session.last_bot_message_id = msg.message_id upsert_user_session(session) logger.info(f"User {user.id} accepted intro") @answer_callback @with_user_and_session @require_state(UserState.SPECIFY_GENDER) async def handle_select_gender( update: Update, context: ContextTypes.DEFAULT_TYPE, user: User, session: UserSession ) -> None: """Обработчик выбора пола.""" query = update.callback_query gender = query.data.replace("select_gender:", "") update_user_gender(user.id, gender) user.gender = gender 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 await query.edit_message_text(format_first_replica(session)) logger.info( f"User {user.id} selected gender {gender}, " f"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 await query.edit_message_text(format_first_replica(session)) 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 await query.edit_message_text(format_first_replica(session)) 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.audio import format_duration from src.scenarios import is_scenario_complete, move_scenario_to_data # Переносим файлы только когда весь сценарий озвучен if is_scenario_complete(session.scenario_id): move_scenario_to_data(session.scenario_id) # Получаем статистику пользователя stats = get_user_stats(user.id) duration = get_user_audio_duration(user.id) duration_str = format_duration(duration) # Формируем сообщение с благодарностью и статистикой thanks_msg = TRACK_SAVED_TEXT.format( total_replicas=stats["total_replicas"], completed_tracks=stats["completed_tracks"], duration=duration_str, ) 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"{thanks_msg}\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 first_replica_msg = format_first_replica(session) await query.edit_message_text(f"{thanks_msg}\n\n{first_replica_msg}") 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(), parse_mode="Markdown" ) elif session.state == UserState.SPECIFY_GENDER: await query.edit_message_text( SPECIFY_GENDER_TEXT, reply_markup=get_specify_gender_keyboard() ) elif session.state == UserState.NO_MORE_SCENARIOS: await query.edit_message_text(NO_MORE_SCENARIOS_TEXT) elif session.state == UserState.FIRST_REPLICA: await query.edit_message_text(format_first_replica(session)) 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"] # Получаем полы, представленные в новом сценарии scenario_genders = get_scenario_genders(pending["id"]) # Уведомляем и переводим пользователей в 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, gender FROM users WHERE id = ?", (waiting_user_id,), ).fetchone() if row: user_gender = row[1] # Уведомляем только если пол пользователя представлен в сценарии if user_gender and user_gender in scenario_genders: waiting_session = get_user_session(waiting_user_id) if waiting_session: track = find_available_track(waiting_user_id) if track: scenario_id, speaker_id = track waiting_session.state = UserState.FIRST_REPLICA waiting_session.scenario_id = scenario_id waiting_session.speaker_id = speaker_id waiting_session.replica_index = 0 upsert_user_session(waiting_session) msg = ( f"🎉 Появился новый сценарий!\n\n" f"{format_first_replica(waiting_session)}" ) 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 safe_edit_message( query, "📭 Нет сценариев для удаления.\n\n" + format_admin_stats(), get_admin_keyboard(), ) return await safe_edit_message( query, "🗑 Выберите сценарий для удаления:", get_admin_delete_list_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_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 safe_edit_message(query, text, 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 safe_edit_message(query, "❌ Данные потеряны. Попробуйте снова.") 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 safe_edit_message( query, f"✅ Сценарий {scenario_id} удалён! (файлов: {deleted_files})\n\n" + format_admin_stats(), 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 safe_edit_message(query, format_admin_stats(), 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 safe_edit_message(query, format_admin_stats(), 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 # Получаем реальный replica_index из таблицы replicas (индекс в сценарии) replicas = get_replicas_for_track(session.scenario_id, session.speaker_id) real_replica_index = replicas[session.replica_index].replica_index voice = update.message.voice await save_voice_message( context.bot, voice.file_id, user.id, session.scenario_id, session.speaker_id, real_replica_index, max(1, voice.duration), # телеграм возвращает с точностью до секунд ) 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) total = get_track_length(session.scenario_id, session.speaker_id) msg_id = await send_message_and_save( update, context, session, f"{REPEAT_REPLICA_TEXT.format(num=num, total=total)}\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 === @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)