Files
dataset-tg-bot/src/handlers.py

1057 lines
37 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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