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:
31
src/audio.py
Normal file
31
src/audio.py
Normal file
@@ -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}")
|
||||
@@ -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/)
|
||||
# Это вычисляется по файловой системе, здесь примерная оценка
|
||||
|
||||
845
src/handlers.py
Normal file
845
src/handlers.py
Normal 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)
|
||||
Reference in New Issue
Block a user