From 613e492b2dde759144fc24f23ee259e1f27a8eab Mon Sep 17 00:00:00 2001 From: Arity-T Date: Mon, 2 Feb 2026 21:27:26 +0300 Subject: [PATCH] refactor: extract common handler logic into decorators - Add @answer_callback for auto-answering callback queries - Add @with_user_and_session for injecting user/session - Add @require_state for state validation - Reduce handlers.py from ~850 to ~540 lines --- src/decorators.py | 76 ++++ src/handlers.py | 1034 ++++++++++++++++++++------------------------- 2 files changed, 523 insertions(+), 587 deletions(-) create mode 100644 src/decorators.py diff --git a/src/decorators.py b/src/decorators.py new file mode 100644 index 0000000..e2d02b8 --- /dev/null +++ b/src/decorators.py @@ -0,0 +1,76 @@ +from functools import wraps +from typing import Callable + +from telegram import Update +from telegram.ext import ContextTypes + +from src.database import ( + User, + UserSession, + UserState, + get_or_create_user, + get_user_session, +) + +INVALID_STATE_TEXT = "❌ Некорректное действие. Используйте /start для начала." + + +def answer_callback(func: Callable) -> Callable: + """Автоматически отвечает на callback query.""" + + @wraps(func) + async def wrapper( + update: Update, context: ContextTypes.DEFAULT_TYPE, *args, **kwargs + ): + if update.callback_query: + await update.callback_query.answer() + return await func(update, context, *args, **kwargs) + + return wrapper + + +def with_user_and_session(func: Callable) -> Callable: + """Добавляет user и session в kwargs.""" + + @wraps(func) + async def wrapper( + update: Update, context: ContextTypes.DEFAULT_TYPE, *args, **kwargs + ): + user = get_or_create_user(update.effective_user.id) + session = get_user_session(user.id) + return await func(update, context, *args, user=user, session=session, **kwargs) + + return wrapper + + +def require_state(*states: UserState): + """Проверяет, что сессия в одном из указанных состояний.""" + + def decorator(func: Callable) -> Callable: + @wraps(func) + async def wrapper( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + *args, + user: User, + session: UserSession | None, + **kwargs, + ): + if not session or session.state not in states: + if update.callback_query: + await update.callback_query.edit_message_text(INVALID_STATE_TEXT) + elif update.message: + await update.message.reply_text(INVALID_STATE_TEXT) + return + return await func( + update, context, *args, user=user, session=session, **kwargs + ) + + return wrapper + + return decorator + + +def require_states(*states: UserState): + """Алиас для require_state с несколькими состояниями.""" + return require_state(*states) diff --git a/src/handlers.py b/src/handlers.py index 44848b9..4887f03 100644 --- a/src/handlers.py +++ b/src/handlers.py @@ -3,16 +3,20 @@ from telegram.ext import ContextTypes from src.config import ADMIN_LOGIN from src.database import ( + User, UserSession, UserState, + delete_user_recordings_for_scenario, get_connection, get_or_create_user, get_replicas_for_track, + get_scenario, get_stats, get_user_session, get_users_in_state, 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 @@ -52,7 +56,9 @@ CONFIRM_SAVE_TEXT = """✅ Дорожка полностью озвучена! INVALID_INPUT_TEXT = "❌ Пожалуйста, отправьте голосовое сообщение." -INVALID_STATE_TEXT = "❌ Некорректное действие. Используйте /start для начала." +ASK_REPLICA_NUMBER_TEXT = "🔢 Введите номер реплики для перезаписи (1-{max}):" + +REPEAT_REPLICA_TEXT = "🔄 Перезапись реплики {num}:" # === Клавиатуры === @@ -118,482 +124,12 @@ def get_confirm_save_keyboard() -> InlineKeyboardMarkup: ) -# === Вспомогательные функции === - - -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) - # Находим реплику по индексу в дорожке - track_replicas = [r for r in replicas] - if session.replica_index < len(track_replicas): - return track_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)) - - -# === Обработчики состояний === - - -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) - - if not session: - session = 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, - ) - - 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 handle_accept_intro( - update: Update, context: ContextTypes.DEFAULT_TYPE -) -> None: - """Обработчик кнопки 'Принять и продолжить'.""" - query = update.callback_query - await query.answer() - - user = get_or_create_user(update.effective_user.id) - session = get_user_session(user.id) - - if not session or session.state != UserState.INTRO: - await query.edit_message_text(INVALID_STATE_TEXT) - return - - # Ищем доступную дорожку - track = find_available_track(user.id) - - if not track: - session.state = UserState.NO_MORE_SCENARIOS - await query.edit_message_text(NO_MORE_SCENARIOS_TEXT) - session.last_bot_message_id = query.message.message_id - upsert_user_session(session) - return - - # Начинаем запись дорожки - 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) - text = f"{FIRST_REPLICA_INSTRUCTIONS}\n\n{replica_text}" - - await query.edit_message_text(text) - session.last_bot_message_id = query.message.message_id - upsert_user_session(session) - logger.info(f"User {user.id} started track {scenario_id}/{speaker_id}") - - -async def handle_voice_message( - update: Update, context: ContextTypes.DEFAULT_TYPE -) -> None: - """Обработчик голосовых сообщений.""" - user = get_or_create_user(update.effective_user.id) - session = get_user_session(user.id) - - if not session: - await update.message.reply_text(INVALID_STATE_TEXT) - return - - valid_states = { - UserState.FIRST_REPLICA, - UserState.SHOW_REPLICA, - UserState.REPEAT_REPLICA, - } - - if session.state not in valid_states: - await update.message.reply_text(INVALID_INPUT_TEXT) - return - - # Импортируем здесь, чтобы избежать циклических импортов - 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) - - # Обработка REPEAT_REPLICA — возврат в CONFIRM_SAVE - 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() - ) - session.last_bot_message_id = msg_id - upsert_user_session(session) - return - - # Показываем следующую реплику - session.state = UserState.SHOW_REPLICA - replica_text = get_current_replica_text(session) - text = ( - f"{SHOW_REPLICA_TEXT.format(num=session.replica_index + 1)}\n\n{replica_text}" - ) - - 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) - - -async def handle_rerecord_previous( - update: Update, context: ContextTypes.DEFAULT_TYPE -) -> None: - """Обработчик кнопки 'Перезаписать предыдущую'.""" - query = update.callback_query - await query.answer() - - user = get_or_create_user(update.effective_user.id) - session = get_user_session(user.id) - - if not session or session.state != UserState.SHOW_REPLICA: - await query.edit_message_text(INVALID_STATE_TEXT) - return - - session.replica_index -= 1 - - if session.replica_index == 0: - session.state = UserState.FIRST_REPLICA - replica_text = get_current_replica_text(session) - text = f"{FIRST_REPLICA_INSTRUCTIONS}\n\n{replica_text}" - await query.edit_message_text(text) - else: - replica_text = get_current_replica_text(session) - text = f"{SHOW_REPLICA_TEXT.format(num=session.replica_index + 1)}\n\n{replica_text}" - 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) - - -async def handle_restart_track( - update: Update, context: ContextTypes.DEFAULT_TYPE -) -> None: - """Обработчик кнопки 'Начать заново'.""" - query = update.callback_query - await query.answer() - - user = get_or_create_user(update.effective_user.id) - session = get_user_session(user.id) - - if not session or session.state != UserState.SHOW_REPLICA: - await query.edit_message_text(INVALID_STATE_TEXT) - return - - 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) - - -async def handle_confirm_restart( - update: Update, context: ContextTypes.DEFAULT_TYPE -) -> None: - """Обработчик подтверждения рестарта дорожки.""" - query = update.callback_query - await query.answer() - - user = get_or_create_user(update.effective_user.id) - session = get_user_session(user.id) - - if not session or session.state != UserState.CONFIRM_RESTART: - await query.edit_message_text(INVALID_STATE_TEXT) - return - - # Удаляем частичные записи - from src.database import delete_user_recordings_for_scenario - 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) - text = f"{FIRST_REPLICA_INSTRUCTIONS}\n\n{replica_text}" - - await query.edit_message_text(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}" - ) - - -async def handle_cancel_restart( - update: Update, context: ContextTypes.DEFAULT_TYPE -) -> None: - """Обработчик отмены рестарта.""" - query = update.callback_query - await query.answer() - - user = get_or_create_user(update.effective_user.id) - session = get_user_session(user.id) - - if not session or session.state != UserState.CONFIRM_RESTART: - await query.edit_message_text(INVALID_STATE_TEXT) - return - - session.state = UserState.SHOW_REPLICA - replica_text = get_current_replica_text(session) - text = ( - f"{SHOW_REPLICA_TEXT.format(num=session.replica_index + 1)}\n\n{replica_text}" - ) - - 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) - - -async def handle_save_track(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Обработчик сохранения дорожки.""" - query = update.callback_query - await query.answer() - - user = get_or_create_user(update.effective_user.id) - session = get_user_session(user.id) - - if not session or session.state != UserState.CONFIRM_SAVE: - await query.edit_message_text(INVALID_STATE_TEXT) - return - - from src.scenarios import move_track_to_data - - # Переносим файлы в 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( - "✅ Дорожка сохранена!\n\n" + NO_MORE_SCENARIOS_TEXT - ) - session.last_bot_message_id = query.message.message_id - upsert_user_session(session) - return - - # Начинаем новую дорожку - 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) - text = f"✅ Дорожка сохранена!\n\n{FIRST_REPLICA_INSTRUCTIONS}\n\n{replica_text}" - - await query.edit_message_text(text) - session.last_bot_message_id = query.message.message_id - upsert_user_session(session) - logger.info(f"User {user.id} saved track, started new: {scenario_id}/{speaker_id}") - - -async def handle_rerecord_last( - update: Update, context: ContextTypes.DEFAULT_TYPE -) -> None: - """Обработчик 'Перезаписать последнюю реплику'.""" - query = update.callback_query - await query.answer() - - user = get_or_create_user(update.effective_user.id) - session = get_user_session(user.id) - - if not session or session.state != UserState.CONFIRM_SAVE: - await query.edit_message_text(INVALID_STATE_TEXT) - return - - track_length = get_track_length(session.scenario_id, session.speaker_id) - session.state = UserState.SHOW_REPLICA - session.replica_index = track_length - 1 - - replica_text = get_current_replica_text(session) - text = ( - f"{SHOW_REPLICA_TEXT.format(num=session.replica_index + 1)}\n\n{replica_text}" - ) - - 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) - - -# === Этап 5: Перезапись по номеру === - -ASK_REPLICA_NUMBER_TEXT = "🔢 Введите номер реплики для перезаписи (1-{max}):" - -REPEAT_REPLICA_TEXT = "🔄 Перезапись реплики {num}:" - - def get_ask_replica_number_keyboard() -> InlineKeyboardMarkup: return InlineKeyboardMarkup( [[InlineKeyboardButton("❌ Отмена", callback_data="cancel_ask_number")]] ) -async def handle_ask_replica_number( - update: Update, context: ContextTypes.DEFAULT_TYPE -) -> None: - """Обработчик кнопки 'Перезаписать по номеру'.""" - query = update.callback_query - await query.answer() - - user = get_or_create_user(update.effective_user.id) - session = get_user_session(user.id) - - if not session or session.state != UserState.CONFIRM_SAVE: - await query.edit_message_text(INVALID_STATE_TEXT) - return - - track_length = get_track_length(session.scenario_id, session.speaker_id) - session.state = UserState.ASK_REPLICA_NUMBER - - text = ASK_REPLICA_NUMBER_TEXT.format(max=track_length) - await query.edit_message_text(text, reply_markup=get_ask_replica_number_keyboard()) - session.last_bot_message_id = query.message.message_id - upsert_user_session(session) - - -async def handle_cancel_ask_number( - update: Update, context: ContextTypes.DEFAULT_TYPE -) -> None: - """Обработчик отмены ввода номера реплики.""" - query = update.callback_query - await query.answer() - - user = get_or_create_user(update.effective_user.id) - session = get_user_session(user.id) - - if not session or session.state != UserState.ASK_REPLICA_NUMBER: - await query.edit_message_text(INVALID_STATE_TEXT) - return - - 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) - - -async def handle_replica_number_input( - update: Update, context: ContextTypes.DEFAULT_TYPE -) -> None: - """Обработчик ввода номера реплики.""" - user = get_or_create_user(update.effective_user.id) - session = get_user_session(user.id) - - if not session or session.state != UserState.ASK_REPLICA_NUMBER: - return - - 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 - - # Переход в REPEAT_REPLICA - session.state = UserState.REPEAT_REPLICA - session.replica_index = num - 1 # 0-indexed - - replica_text = get_current_replica_text(session) - text = f"{REPEAT_REPLICA_TEXT.format(num=num)}\n\n{replica_text}" - - msg_id = await send_message_and_save(update, context, session, text) - session.last_bot_message_id = msg_id - upsert_user_session(session) - - -# === Этап 7: Административный интерфейс === - - def get_admin_keyboard() -> InlineKeyboardMarkup: return InlineKeyboardMarkup( [ @@ -615,6 +151,56 @@ def get_admin_upload_confirm_keyboard() -> InlineKeyboardMarkup: ) +# === Вспомогательные функции === + + +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_admin_stats() -> str: """Форматирует статистику для админки.""" stats = get_stats() @@ -629,11 +215,39 @@ def format_admin_stats() -> str: Отправьте 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.""" - username = update.effective_user.username - - if username != ADMIN_LOGIN: + if update.effective_user.username != ADMIN_LOGIN: await update.message.reply_text("❌ У вас нет прав администратора.") return @@ -641,41 +255,233 @@ async def admin_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N session = get_user_session(user.id) if not session: - session = UserSession( - user_id=user.id, - state=UserState.ADMIN, - scenario_id=None, - speaker_id=None, - replica_index=None, - previous_state=None, - last_bot_message_id=None, - ) + session = create_new_session(user.id) + session.state = UserState.ADMIN else: session.previous_state = session.state session.state = UserState.ADMIN - text = format_admin_stats() msg_id = await send_message_and_save( - update, context, session, text, get_admin_keyboard() + update, context, session, format_admin_stats(), get_admin_keyboard() ) session.last_bot_message_id = msg_id upsert_user_session(session) - logger.info(f"Admin {username} entered admin mode") + logger.info(f"Admin {update.effective_user.username} entered admin mode") -async def handle_exit_admin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: +# === 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: + replica_text = get_current_replica_text(session) + text = f"{SHOW_REPLICA_TEXT.format(num=session.replica_index + 1)}\n\n{replica_text}" + 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 + replica_text = get_current_replica_text(session) + text = ( + f"{SHOW_REPLICA_TEXT.format(num=session.replica_index + 1)}\n\n{replica_text}" + ) + 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 + replica_text = get_current_replica_text(session) + text = ( + f"{SHOW_REPLICA_TEXT.format(num=session.replica_index + 1)}\n\n{replica_text}" + ) + 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 - await query.answer() - user = get_or_create_user(update.effective_user.id) - session = get_user_session(user.id) - - if not session or session.state != UserState.ADMIN: - await query.edit_message_text(INVALID_STATE_TEXT) - return - - # Возвращаемся в предыдущее состояние или в INTRO if session.previous_state: session.state = session.previous_state session.previous_state = None @@ -689,8 +495,7 @@ async def handle_exit_admin(update: Update, context: ContextTypes.DEFAULT_TYPE) await query.edit_message_text(NO_MORE_SCENARIOS_TEXT) elif session.state == UserState.FIRST_REPLICA: replica_text = get_current_replica_text(session) - text = f"{FIRST_REPLICA_INSTRUCTIONS}\n\n{replica_text}" - await query.edit_message_text(text) + await query.edit_message_text(f"{FIRST_REPLICA_INSTRUCTIONS}\n\n{replica_text}") elif session.state == UserState.SHOW_REPLICA: replica_text = get_current_replica_text(session) text = f"{SHOW_REPLICA_TEXT.format(num=session.replica_index + 1)}\n\n{replica_text}" @@ -706,79 +511,16 @@ async def handle_exit_admin(update: Update, context: ContextTypes.DEFAULT_TYPE) upsert_user_session(session) -async def handle_admin_document( - update: Update, context: ContextTypes.DEFAULT_TYPE -) -> None: - """Обработчик загрузки JSON файла в админке.""" - user = get_or_create_user(update.effective_user.id) - session = get_user_session(user.id) - - if not session or session.state != UserState.ADMIN: - return - - 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 - - # Проверяем, что сценарий с таким ID не существует - scenario_id = document.file_name.replace(".json", "") - from src.database import get_scenario - - 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) - - +@answer_callback +@with_user_and_session +@require_state(UserState.ADMIN_UPLOAD_CONFIRM) async def handle_confirm_upload( - update: Update, context: ContextTypes.DEFAULT_TYPE + update: Update, context: ContextTypes.DEFAULT_TYPE, user: User, session: UserSession ) -> None: """Обработчик подтверждения загрузки сценария.""" query = update.callback_query - await query.answer() - - user = get_or_create_user(update.effective_user.id) - session = get_user_session(user.id) - - if not session or session.state != UserState.ADMIN_UPLOAD_CONFIRM: - await query.edit_message_text(INVALID_STATE_TEXT) - return - pending = context.user_data.get("pending_scenario") + if not pending: await query.edit_message_text("❌ Данные сценария потеряны. Попробуйте снова.") session.state = UserState.ADMIN @@ -798,8 +540,7 @@ async def handle_confirm_upload( del context.user_data["pending_scenario"] # Уведомляем пользователей в NO_MORE_SCENARIOS - users_waiting = get_users_in_state(UserState.NO_MORE_SCENARIOS) - for waiting_user_id in users_waiting: + for waiting_user_id in get_users_in_state(UserState.NO_MORE_SCENARIOS): try: with get_connection() as conn: row = conn.execute( @@ -811,35 +552,154 @@ async def handle_confirm_upload( "🎉 Появился новый сценарий для озвучивания! Используйте /start для продолжения.", ) except Exception: - pass # Не удалось отправить уведомление + pass session.state = UserState.ADMIN - text = f"✅ Сценарий {pending['id']} добавлен!\n\n" + format_admin_stats() - await query.edit_message_text(text, reply_markup=get_admin_keyboard()) + 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 + update: Update, context: ContextTypes.DEFAULT_TYPE, user: User, session: UserSession ) -> None: - """Обработчик отмены загрузки сценария.""" + """Обработчик отмены загрузки.""" query = update.callback_query - await query.answer() - - user = get_or_create_user(update.effective_user.id) - session = get_user_session(user.id) - - if not session or session.state != UserState.ADMIN_UPLOAD_CONFIRM: - await query.edit_message_text(INVALID_STATE_TEXT) - return - - if "pending_scenario" in context.user_data: - del context.user_data["pending_scenario"] - + context.user_data.pop("pending_scenario", None) session.state = UserState.ADMIN - text = format_admin_stats() - await query.edit_message_text(text, reply_markup=get_admin_keyboard()) + 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 + replica_text = get_current_replica_text(session) + text = f"{SHOW_REPLICA_TEXT.format(num=session.replica_index + 1)}\n\n{replica_text}" + 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 +@require_state(UserState.ASK_REPLICA_NUMBER) +async def handle_replica_number_input( + update: Update, context: ContextTypes.DEFAULT_TYPE, user: User, session: UserSession +) -> None: + """Обработчик ввода номера реплики.""" + 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)