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
This commit is contained in:
2026-02-02 21:25:19 +03:00
parent 661f1913af
commit 8fecb3d543
5 changed files with 986 additions and 12 deletions

845
src/handlers.py Normal file
View File

@@ -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)