1057 lines
37 KiB
Python
1057 lines
37 KiB
Python
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)
|