From d0445d4480202f833c710d3cb1f190428c0da523 Mon Sep 17 00:00:00 2001 From: Arity-T Date: Mon, 2 Feb 2026 22:57:27 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D1=81=D1=86=D0=B5=D0=BD=D0=B0=D1=80=D0=B8=D0=B5=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- README.md | 6 +- main.py | 24 ++++++ src/database.py | 62 +++++++++++++-- src/handlers.py | 198 ++++++++++++++++++++++++++++++++++++++++++++++- src/scenarios.py | 22 ++++++ 6 files changed, 305 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 30d27c3..b5dc801 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,5 @@ wheels/ TASK.md .env data/ -data_partial/ \ No newline at end of file +data_partial/ +bot.db \ No newline at end of file diff --git a/README.md b/README.md index feeb153..09fd944 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,8 @@ uv run main.py ## Администрирование -Бот управляется единственным администратором, чей Telegram логин указывается в переменной окружения `ADMIN_LOGIN`. Только у администратора доступна команда `/admin`, переводящая пользовательскую сессию в состояние **ADMIN**. Команда доступна из любого другого состояния. В **ADMIN** доступна кнопка "Вернуться в пользовательский режим", переводящая пользовательскую сессию в то состояние, из которого она была переведена в **ADMIN**. После перехода режим администратора выводится сообщение с информацией о текущем состоянии датасета: количество полностью озвученных сценариев, общее количество сценариев, количество полностью озвученных дорожек, общее количество дорожек, количество уникальных дикторов, количество озвученных реплик, общее количество реплик, количество уникальных пользователей бота и т. п. +Бот управляется единственным администратором, чей Telegram логин указывается в переменной окружения `ADMIN_LOGIN`. Только у администратора доступна команда `/admin`, переводящая пользовательскую сессию в состояние **ADMIN**. Команда доступна из любого другого состояния. В **ADMIN** доступна кнопка "Вернуться в пользовательский режим", переводящая пользовательскую сессию в то состояние, из которого она была переведена в **ADMIN**. Исключением является случай, когда админ озвучивал дорожку из сценария, который только что был удалён. После перехода режим администратора выводится сообщение с информацией о текущем состоянии датасета: количество полностью озвученных сценариев, общее количество сценариев, количество полностью озвученных дорожек, общее количество дорожек, количество уникальных дикторов, количество озвученных реплик, общее количество реплик, количество уникальных пользователей бота и т. п. -В **ADMIN** администратор может отправить боту `json`-файл с новым сценарием. Это переводит пользовательскую сессию в состояние **ADMIN_UPLOAD_CONFIRM**, если файл корректен, иначе выводится сообщение об ошибке в формате файла. В **ADMIN_UPLOAD_CONFIRM** выводится сообщение с предложением добавить сценарий, а также дополнительная информация о сценарии: количество дорожек, количество реплик. Две кнопки: "Да, добавить" и "Отмена". Обе кнопки возвращают администратора в состояние **ADMIN**. \ No newline at end of file +В **ADMIN** администратор может отправить боту `json`-файл с новым сценарием. Это переводит пользовательскую сессию в состояние **ADMIN_UPLOAD_CONFIRM**, если файл корректен, иначе выводится сообщение об ошибке в формате файла. В **ADMIN_UPLOAD_CONFIRM** выводится сообщение с предложением добавить сценарий, а также дополнительная информация о сценарии: количество дорожек, количество реплик. Две кнопки: "Да, добавить" и "Отмена". Обе кнопки возвращают администратора в состояние **ADMIN**. + +В **ADMIN** доступна кнопка "Удалить сценарий". По нажатию выводится список всех сценариев в виде inline-кнопок, а также кнопка "Отмена". Выбор сценария переводит в состояние **ADMIN_DELETE_CONFIRM**. В этом состоянии выводится информация о сценарии: количество дорожек, количество реплик, количество записей. Две кнопки: "Да, удалить" и "Отмена". При удалении удаляются записи из базы данных и файлы из `data/` и `data_partial/`, а все пользователи, которые озвучивали этот сценарий, переводятся в **FIRST_REPLICA** с выводом уведомления сообщения о сохранении дорожки, если ещё есть сценарии не озвученные этим диктором, иначе в **NO_MORE_SCENARIOS**. Обе кнопки возвращают в **ADMIN**. \ No newline at end of file diff --git a/main.py b/main.py index 65a86ff..334a5ff 100644 --- a/main.py +++ b/main.py @@ -14,16 +14,21 @@ from src.handlers import ( handle_admin_document, handle_ask_replica_number, handle_cancel_ask_number, + handle_cancel_delete, + handle_cancel_delete_list, handle_cancel_restart, handle_cancel_upload, + handle_confirm_delete, handle_confirm_restart, handle_confirm_upload, + handle_delete_scenario_list, handle_exit_admin, handle_replica_number_input, handle_rerecord_last, handle_rerecord_previous, handle_restart_track, handle_save_track, + handle_select_scenario_delete, handle_unexpected_text, handle_voice_message, start_command, @@ -75,6 +80,25 @@ def main() -> None: CallbackQueryHandler(handle_cancel_upload, pattern="^cancel_upload$") ) + # Delete scenario handlers + app.add_handler( + CallbackQueryHandler( + handle_delete_scenario_list, pattern="^delete_scenario_list$" + ) + ) + app.add_handler( + CallbackQueryHandler(handle_select_scenario_delete, pattern="^delete_scenario:") + ) + app.add_handler( + CallbackQueryHandler(handle_confirm_delete, pattern="^confirm_delete$") + ) + app.add_handler( + CallbackQueryHandler(handle_cancel_delete, pattern="^cancel_delete$") + ) + app.add_handler( + CallbackQueryHandler(handle_cancel_delete_list, pattern="^cancel_delete_list$") + ) + # Message handlers app.add_handler(MessageHandler(filters.VOICE, handle_voice_message)) app.add_handler(MessageHandler(filters.Document.ALL, handle_admin_document)) diff --git a/src/database.py b/src/database.py index 9484611..f6c280c 100644 --- a/src/database.py +++ b/src/database.py @@ -22,6 +22,7 @@ class UserState(Enum): REPEAT_REPLICA = "repeat_replica" ADMIN = "admin" ADMIN_UPLOAD_CONFIRM = "admin_upload_confirm" + ADMIN_DELETE_CONFIRM = "admin_delete_confirm" @dataclass @@ -484,20 +485,69 @@ def get_stats() -> dict: "SELECT COUNT(*) FROM recordings" ).fetchone()[0] - # Количество полностью озвученных дорожек (в data/) - # Это вычисляется по файловой системе, здесь примерная оценка + # Количество полностью озвученных дорожек stats["completed_tracks"] = conn.execute(""" SELECT COUNT(*) FROM ( - SELECT user_id, scenario_id, speaker_id, COUNT(*) as cnt + SELECT r.user_id, r.scenario_id, rep.speaker_id, COUNT(*) as cnt FROM recordings r JOIN replicas rep ON r.scenario_id = rep.scenario_id AND r.replica_index = rep.replica_index - GROUP BY user_id, scenario_id, speaker_id + GROUP BY r.user_id, r.scenario_id, rep.speaker_id HAVING cnt = ( - SELECT COUNT(*) FROM replicas - WHERE scenario_id = r.scenario_id AND speaker_id = rep.speaker_id + SELECT COUNT(*) FROM replicas rp + WHERE rp.scenario_id = r.scenario_id + AND rp.speaker_id = rep.speaker_id ) ) """).fetchone()[0] return stats + + +def get_scenario_stats(scenario_id: str) -> dict: + """Получает статистику конкретного сценария.""" + with get_connection() as conn: + stats = {} + + # Количество реплик + stats["total_replicas"] = conn.execute( + "SELECT COUNT(*) FROM replicas WHERE scenario_id = ?", (scenario_id,) + ).fetchone()[0] + + # Количество дорожек + stats["total_tracks"] = conn.execute( + "SELECT COUNT(DISTINCT speaker_id) FROM replicas WHERE scenario_id = ?", + (scenario_id,), + ).fetchone()[0] + + # Количество записей + stats["total_recordings"] = conn.execute( + "SELECT COUNT(*) FROM recordings WHERE scenario_id = ?", (scenario_id,) + ).fetchone()[0] + + return stats + + +def get_users_with_scenario(scenario_id: str) -> list[tuple[int, int]]: + """Получает пользователей, озвучивающих сценарий.""" + with get_connection() as conn: + cursor = conn.execute( + """ + SELECT DISTINCT u.id, u.telegram_id + FROM user_sessions us + JOIN users u ON us.user_id = u.id + WHERE us.scenario_id = ? + """, + (scenario_id,), + ) + return [(row["id"], row["telegram_id"]) for row in cursor.fetchall()] + + +def delete_scenario_data(scenario_id: str) -> None: + """Удаляет сценарий и все связанные данные из БД.""" + with get_connection() as conn: + conn.execute("DELETE FROM recordings WHERE scenario_id = ?", (scenario_id,)) + conn.execute("DELETE FROM replicas WHERE scenario_id = ?", (scenario_id,)) + conn.execute("DELETE FROM scenarios WHERE id = ?", (scenario_id,)) + conn.commit() + logger.info(f"Deleted scenario {scenario_id} from database") diff --git a/src/handlers.py b/src/handlers.py index 8e3c04b..e5367ab 100644 --- a/src/handlers.py +++ b/src/handlers.py @@ -6,14 +6,18 @@ 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_stats, get_stats, get_user_session, get_users_in_state, + get_users_with_scenario, upsert_user_session, ) from src.decorators import answer_callback, require_state, with_user_and_session @@ -135,11 +139,16 @@ def get_ask_replica_number_keyboard() -> InlineKeyboardMarkup: def get_admin_keyboard() -> InlineKeyboardMarkup: return InlineKeyboardMarkup( [ + [ + InlineKeyboardButton( + "🗑 Удалить сценарий", callback_data="delete_scenario_list" + ) + ], [ InlineKeyboardButton( "👤 Вернуться в пользовательский режим", callback_data="exit_admin" ) - ] + ], ] ) @@ -153,6 +162,28 @@ def get_admin_upload_confirm_keyboard() -> InlineKeyboardMarkup: ) +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")], + ] + ) + + # === Вспомогательные функции === @@ -580,6 +611,171 @@ async def handle_cancel_upload( 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 query.edit_message_text( + "📭 Нет сценариев для удаления.\n\n" + format_admin_stats(), + reply_markup=get_admin_keyboard(), + ) + return + + try: + await query.edit_message_text( + "🗑 Выберите сценарий для удаления:", + reply_markup=get_admin_delete_list_keyboard(), + ) + except Exception: + pass # Сообщение уже такое же + 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 query.edit_message_text( + text, reply_markup=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 query.edit_message_text("❌ Данные потеряны. Попробуйте снова.") + 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 query.edit_message_text( + f"✅ Сценарий {scenario_id} удалён! (файлов: {deleted_files})\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 {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 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) + + +@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 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) + + # === Message handlers === diff --git a/src/scenarios.py b/src/scenarios.py index 07e0679..876d70a 100644 --- a/src/scenarios.py +++ b/src/scenarios.py @@ -250,3 +250,25 @@ def delete_partial_track(user_id: int, scenario_id: str, speaker_id: int) -> Non f"Удалено {deleted_count} частичных записей для дорожки " f"{scenario_id}/{speaker_id} (user_id={user_id})" ) + + +def delete_scenario_files(scenario_id: str) -> int: + """Удаляет все файлы сценария из data/ и data_partial/. Возвращает число файлов.""" + deleted_count = 0 + + for base_dir in [DATA_DIR, DATA_PARTIAL_DIR]: + scenario_dir = base_dir / scenario_id + if scenario_dir.exists(): + for file in scenario_dir.glob("*.wav"): + file.unlink() + deleted_count += 1 + # Удаляем пустую папку + try: + scenario_dir.rmdir() + except OSError: + pass # Папка не пуста + + if deleted_count > 0: + logger.info(f"Удалено {deleted_count} файлов для сценария {scenario_id}") + + return deleted_count