From 8fecb3d54346091f9cdda21f4aadb695cad696ba Mon Sep 17 00:00:00 2001 From: Arity-T Date: Mon, 2 Feb 2026 21:25:19 +0300 Subject: [PATCH] feat: add FSM handlers and admin interface - Add all user FSM states (INTRO through CONFIRM_SAVE) - Add replica re-recording by number (ASK_REPLICA_NUMBER, REPEAT_REPLICA) - Add admin interface with stats and scenario upload - Add voice message handling and storage --- README.md | 6 + main.py | 70 +++- src/audio.py | 31 ++ src/database.py | 46 ++- src/handlers.py | 845 ++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 986 insertions(+), 12 deletions(-) create mode 100644 src/audio.py create mode 100644 src/handlers.py diff --git a/README.md b/README.md index 7e98759..feeb153 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # Телеграм бот для сбора датасета для автоматического протоколирования совещаний +```sh +# Создать файл .env из .env.example +cp .env.example .env +uv run main.py +``` + ## Данные ### Формат входных данных diff --git a/main.py b/main.py index c4e9677..057ef61 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,32 @@ -from telegram.ext import ApplicationBuilder +from telegram.ext import ( + ApplicationBuilder, + CallbackQueryHandler, + CommandHandler, + MessageHandler, + filters, +) from src.config import BOT_TOKEN from src.database import init_db +from src.handlers import ( + admin_command, + handle_accept_intro, + handle_admin_document, + handle_ask_replica_number, + handle_cancel_ask_number, + handle_cancel_restart, + handle_cancel_upload, + handle_confirm_restart, + handle_confirm_upload, + handle_exit_admin, + handle_replica_number_input, + handle_rerecord_last, + handle_rerecord_previous, + handle_restart_track, + handle_save_track, + handle_voice_message, + start_command, +) from src.logger import logger @@ -13,7 +38,48 @@ def main() -> None: app = ApplicationBuilder().token(BOT_TOKEN).build() - # TODO: добавить обработчики + # Команды + app.add_handler(CommandHandler("start", start_command)) + app.add_handler(CommandHandler("admin", admin_command)) + + # Callback query handlers + app.add_handler(CallbackQueryHandler(handle_accept_intro, pattern="^accept_intro$")) + app.add_handler( + CallbackQueryHandler(handle_rerecord_previous, pattern="^rerecord_previous$") + ) + app.add_handler( + CallbackQueryHandler(handle_restart_track, pattern="^restart_track$") + ) + app.add_handler( + CallbackQueryHandler(handle_confirm_restart, pattern="^confirm_restart$") + ) + app.add_handler( + CallbackQueryHandler(handle_cancel_restart, pattern="^cancel_restart$") + ) + app.add_handler(CallbackQueryHandler(handle_save_track, pattern="^save_track$")) + app.add_handler( + CallbackQueryHandler(handle_rerecord_last, pattern="^rerecord_last$") + ) + app.add_handler( + CallbackQueryHandler(handle_ask_replica_number, pattern="^ask_replica_number$") + ) + app.add_handler( + CallbackQueryHandler(handle_cancel_ask_number, pattern="^cancel_ask_number$") + ) + app.add_handler(CallbackQueryHandler(handle_exit_admin, pattern="^exit_admin$")) + app.add_handler( + CallbackQueryHandler(handle_confirm_upload, pattern="^confirm_upload$") + ) + app.add_handler( + CallbackQueryHandler(handle_cancel_upload, pattern="^cancel_upload$") + ) + + # Message handlers + app.add_handler(MessageHandler(filters.VOICE, handle_voice_message)) + app.add_handler( + MessageHandler(filters.TEXT & ~filters.COMMAND, handle_replica_number_input) + ) + app.add_handler(MessageHandler(filters.Document.ALL, handle_admin_document)) logger.info("Бот запущен") app.run_polling() diff --git a/src/audio.py b/src/audio.py new file mode 100644 index 0000000..615d0de --- /dev/null +++ b/src/audio.py @@ -0,0 +1,31 @@ +from telegram import Bot + +from src.config import DATA_PARTIAL_DIR +from src.database import upsert_recording +from src.logger import logger +from src.scenarios import get_audio_filename + + +async def save_voice_message( + bot: Bot, + file_id: str, + user_id: int, + scenario_id: str, + replica_index: int, +) -> None: + """Сохраняет голосовое сообщение в data_partial/.""" + # Создаём директорию если нужно + scenario_dir = DATA_PARTIAL_DIR / scenario_id + scenario_dir.mkdir(parents=True, exist_ok=True) + + # Скачиваем файл + file = await bot.get_file(file_id) + filename = get_audio_filename(replica_index, user_id) + filepath = scenario_dir / filename + + await file.download_to_drive(filepath) + + # Записываем в БД + upsert_recording(user_id, scenario_id, replica_index) + + logger.debug(f"Saved voice: {filepath}") diff --git a/src/database.py b/src/database.py index 44a61b4..371a67b 100644 --- a/src/database.py +++ b/src/database.py @@ -164,7 +164,11 @@ def get_or_create_user(telegram_id: int) -> User: row = cursor.fetchone() if row: - return User(id=row["id"], telegram_id=row["telegram_id"], created_at=row["created_at"]) + return User( + id=row["id"], + telegram_id=row["telegram_id"], + created_at=row["created_at"], + ) cursor = conn.execute( "INSERT INTO users (telegram_id) VALUES (?) RETURNING id, telegram_id, created_at", @@ -173,7 +177,9 @@ def get_or_create_user(telegram_id: int) -> User: row = cursor.fetchone() conn.commit() logger.info(f"Создан новый пользователь: dataset_speaker_id={row['id']}") - return User(id=row["id"], telegram_id=row["telegram_id"], created_at=row["created_at"]) + return User( + id=row["id"], telegram_id=row["telegram_id"], created_at=row["created_at"] + ) def get_user_by_telegram_id(telegram_id: int) -> User | None: @@ -185,7 +191,11 @@ def get_user_by_telegram_id(telegram_id: int) -> User | None: ) row = cursor.fetchone() if row: - return User(id=row["id"], telegram_id=row["telegram_id"], created_at=row["created_at"]) + return User( + id=row["id"], + telegram_id=row["telegram_id"], + created_at=row["created_at"], + ) return None @@ -221,8 +231,13 @@ def get_scenario(scenario_id: str) -> Scenario | None: def get_all_scenarios() -> list[Scenario]: """Получает все сценарии.""" with get_connection() as conn: - cursor = conn.execute("SELECT id, created_at FROM scenarios ORDER BY created_at") - return [Scenario(id=row["id"], created_at=row["created_at"]) for row in cursor.fetchall()] + cursor = conn.execute( + "SELECT id, created_at FROM scenarios ORDER BY created_at" + ) + return [ + Scenario(id=row["id"], created_at=row["created_at"]) + for row in cursor.fetchall() + ] # === Replicas CRUD === @@ -233,7 +248,10 @@ def create_replicas(scenario_id: str, replicas: list[tuple[int, int, str]]) -> N with get_connection() as conn: conn.executemany( "INSERT INTO replicas (scenario_id, speaker_id, replica_index, text) VALUES (?, ?, ?, ?)", - [(scenario_id, speaker_id, idx, text) for speaker_id, idx, text in replicas], + [ + (scenario_id, speaker_id, idx, text) + for speaker_id, idx, text in replicas + ], ) conn.commit() @@ -383,7 +401,9 @@ def get_user_session(user_id: int) -> UserSession | None: scenario_id=row["scenario_id"], speaker_id=row["speaker_id"], replica_index=row["replica_index"], - previous_state=UserState(row["previous_state"]) if row["previous_state"] else None, + previous_state=UserState(row["previous_state"]) + if row["previous_state"] + else None, last_bot_message_id=row["last_bot_message_id"], ) return None @@ -437,10 +457,14 @@ def get_stats() -> dict: stats = {} # Общее количество сценариев - stats["total_scenarios"] = conn.execute("SELECT COUNT(*) FROM scenarios").fetchone()[0] + stats["total_scenarios"] = conn.execute( + "SELECT COUNT(*) FROM scenarios" + ).fetchone()[0] # Общее количество реплик - stats["total_replicas"] = conn.execute("SELECT COUNT(*) FROM replicas").fetchone()[0] + stats["total_replicas"] = conn.execute( + "SELECT COUNT(*) FROM replicas" + ).fetchone()[0] # Общее количество дорожек stats["total_tracks"] = conn.execute( @@ -451,7 +475,9 @@ def get_stats() -> dict: stats["total_users"] = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] # Количество озвученных реплик - stats["total_recordings"] = conn.execute("SELECT COUNT(*) FROM recordings").fetchone()[0] + stats["total_recordings"] = conn.execute( + "SELECT COUNT(*) FROM recordings" + ).fetchone()[0] # Количество полностью озвученных дорожек (в data/) # Это вычисляется по файловой системе, здесь примерная оценка diff --git a/src/handlers.py b/src/handlers.py new file mode 100644 index 0000000..44848b9 --- /dev/null +++ b/src/handlers.py @@ -0,0 +1,845 @@ +from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update +from telegram.ext import ContextTypes + +from src.config import ADMIN_LOGIN +from src.database import ( + UserSession, + UserState, + get_connection, + get_or_create_user, + get_replicas_for_track, + get_stats, + get_user_session, + get_users_in_state, + upsert_user_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 = "❌ Пожалуйста, отправьте голосовое сообщение." + +INVALID_STATE_TEXT = "❌ Некорректное действие. Используйте /start для начала." + + +# === Клавиатуры === + + +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" + ) + ], + ] + ) + + +# === Вспомогательные функции === + + +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( + [ + [ + 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 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-файл с новым сценарием для загрузки.""" + + +async def admin_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Обработчик команды /admin.""" + username = update.effective_user.username + + if 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 = 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, + ) + 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() + ) + session.last_bot_message_id = msg_id + upsert_user_session(session) + logger.info(f"Admin {username} entered admin mode") + + +async def handle_exit_admin(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.ADMIN: + await query.edit_message_text(INVALID_STATE_TEXT) + return + + # Возвращаемся в предыдущее состояние или в INTRO + 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) + text = f"{FIRST_REPLICA_INSTRUCTIONS}\n\n{replica_text}" + await query.edit_message_text(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}" + 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) + + +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) + + +async def handle_confirm_upload( + 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.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 + 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 + users_waiting = get_users_in_state(UserState.NO_MORE_SCENARIOS) + for waiting_user_id in users_waiting: + try: + with get_connection() as conn: + row = conn.execute( + "SELECT telegram_id FROM users WHERE id = ?", (waiting_user_id,) + ).fetchone() + if row: + await context.bot.send_message( + row[0], + "🎉 Появился новый сценарий для озвучивания! Используйте /start для продолжения.", + ) + except Exception: + 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()) + session.last_bot_message_id = query.message.message_id + upsert_user_session(session) + logger.info(f"Scenario {pending['id']} uploaded by admin") + + +async def handle_cancel_upload( + 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.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"] + + session.state = UserState.ADMIN + text = format_admin_stats() + await query.edit_message_text(text, reply_markup=get_admin_keyboard()) + session.last_bot_message_id = query.message.message_id + upsert_user_session(session)