diff --git a/README.md b/README.md index fa3ad1b..9274b1f 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ onboarding. В «Подписке» сразу отображаются тип | Настроить стиль | В режиме «Обучение» присылает скриншоты постов | Бот распознаёт тексты, накапливает их и обновляет ToV | | Голосом | В режиме «Генерация» отправляет голосовое | STT расшифровывает → обычный текстовый запрос | | Узнать статус / продлить | Входит в режим «Подписка» и нажимает «Продлить подписку» | Текущий тип периода, остаток дней и Stars-инвойс на 100 звёзд | -| Очистить данные | В режиме «Обучение» вызывает `/clear` | Очищает стиль и историю, не меняя подписку или триал | +| Очистить данные | В режиме «Обучение» нажимает «Сбросить данные» или вводит `/clear`, затем подтверждает | Очищает стиль и историю, не меняя подписку или триал | ### Индикаторы и обратная связь - Пока **идёт обработка** (классификация, расшифровка, чтение скриншота, генерация) — бот показывает «печатает» (`typing_status`), обновляя каждые 4 секунды. @@ -132,7 +132,7 @@ update_tone_from_image → _handle_image_message | `subscribe_command` | `/subscribe` — отправляет Stars-инвойс на 30 дней (подписка активируется только после оплаты) | | `handle_pre_checkout` | Подтверждает только свой инвойс в XTR на 100 звёзд, всё остальное отклоняет | | `handle_successful_payment` | Активирует подписку после реальной оплаты; `telegram_payment_charge_id` пишется в `payments` для защиты от повторного начисления | -| `clear_command` | `/clear` — сброс пользовательских данных без изменения подписки и триала; только в обучении | +| `clear_command` | `/clear` и кнопка «Сбросить данные» показывают inline-подтверждение; сброс только по confirm в режиме обучения | | `help_command` | `/help` — меню справки (Rich Message с кнопками) + fallback на plain text | | `help_navigation` | Callback `help:
` → `edit_message_text(rich_message=...)` — переключение разделов | | `edit_rich_message` | Редактирование текущего rich-сообщения на новый раздел | diff --git a/VERSION b/VERSION index 64a7fcc..81f5f58 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+33 +0.1.0+34 diff --git a/bot.py b/bot.py index 88ee8f5..be1430e 100644 --- a/bot.py +++ b/bot.py @@ -30,7 +30,7 @@ import user_store from apinex_client import APINEX_STT_URL, POST_HISTORY_CHAR_LIMIT, POST_HISTORY_LIMIT, classify_intent, create_tone_profile, current_operation_id, diagnostic_operation, extract_posts_from_images, extract_transcript, generate_ideas, generate_posts, transcribe_audio from help_message import WELCOME_RICH_HTML, help_plain_view, help_view, rich_to_plain -from keyboards import MAIN_MENU_VALUE, main_menu_keyboard, mode_keyboard, resolve_button_text +from keyboards import MAIN_MENU_VALUE, TRAINING_RESET_VALUE, main_menu_keyboard, mode_keyboard, resolve_button_text from payments import send_subscription_invoice from user_store import ( add_post_samples, @@ -92,10 +92,20 @@ MODE_COMMANDS = { } MODE_COPY = { "lobby": ("Главное меню", "Выбери режим работы в меню ниже."), - "training": ("Обучение", "Пришли скриншоты своих постов, чтобы настроить стиль."), - "generation": ("Генерация", "Опиши тему поста, попроси идею или ответь на мой вариант с правкой."), - "subscription": ("Подписка", "Ниже показаны текущий статус и остаток дней; кнопка откроет продление."), - "help": ("Помощь", "Выбери нужный раздел справки в сообщении выше."), + "training": ( + "Обучение", + "Пришли скриншоты своих постов, чтобы настроить стиль.\n" + "Кнопка «Сбросить данные» очистит обучение после подтверждения.", + ), + "generation": ( + "Генерация", + "Напиши запрос или отправь голосовое.\nДля правки ответь на мой вариант.", + ), + "subscription": ( + "Подписка", + "Проверь статус ниже.\nКнопка откроет оплату или продление.", + ), + "help": ("Помощь", "Выбери раздел в сообщении выше."), } @@ -114,7 +124,7 @@ async def enter_mode(message: Message, state: FSMContext, mode: str) -> None: if mode == "help": await send_rich_message(message, help_view(None), "помощи") await message.answer( - f"Режим «{title}». {description}", + f"Режим «{title}».\n{description}", reply_markup=mode_keyboard(mode), ) if mode == "subscription" and message.from_user is not None: @@ -174,7 +184,11 @@ async def report_error(message: Message, stage: str, exc: Exception) -> None: operation_id = current_operation_id() or "-" user_id = message.from_user.id if message.from_user else "-" try: - await message.answer(f"❌ Не получилось: {stage}. Попробуй ещё раз позже.") + await message.answer( + "❌ Не удалось выполнить действие\n\n" + f"{html.escape(stage.capitalize())}. Попробуй ещё раз позже.", + parse_mode="HTML", + ) if is_development_environment(): await message.answer( f"🧪 Dev-диагностика: {stage}. ID: {operation_id}. " @@ -339,7 +353,7 @@ async def _show_subscription_info( ) if mode == "subscription": - text += f"\nПродление на 30 дней — {SUBSCRIPTION_PRICE_STARS} ⭐." + text += f"\n\nПродление на 30 дней — {SUBSCRIPTION_PRICE_STARS} ⭐." markup = subscription_renew_keyboard() else: markup = mode_keyboard(mode) @@ -433,13 +447,15 @@ async def style_command(message: Message, state: FSMContext) -> None: samples = await asyncio.to_thread(get_post_samples, message.from_user.id) if not tone and not samples: await message.answer( - "Пока не настроен. Пришли скриншоты своих опубликованных постов — " - "я распознаю текст, сохраню образцы и построю правила твоего тона.", + "🎓 Стиль ещё не настроен\n\n" + "Пришли скриншоты опубликованных постов.\n" + "Я сохраню примеры и составлю правила Tone of Voice.", + parse_mode="HTML", reply_markup=mode_keyboard("training"), ) return await message.answer( - f"Твой стиль\n\n{tone or 'Правила пока не сформированы.'}", + f"🎨 Твой стиль\n\n{html.escape(tone or 'Правила пока не сформированы.')}", parse_mode="HTML", reply_markup=mode_keyboard("training"), ) @@ -447,18 +463,63 @@ async def style_command(message: Message, state: FSMContext) -> None: await message.answer(f"Образцов сохранено: {len(samples)}.") +def training_reset_confirmation_keyboard(user_id: int) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[[ + InlineKeyboardButton(text="✅ Подтвердить", callback_data=f"training:reset:confirm:{user_id}"), + InlineKeyboardButton(text="↩️ Отмена", callback_data=f"training:reset:cancel:{user_id}"), + ]] + ) + + async def clear_command(message: Message, state: FSMContext) -> None: if not await require_mode(message, state, "training"): return if message.from_user is None: await message.answer("Не удалось определить пользователя.") return - await asyncio.to_thread(clear_user_data, message.from_user.id) await message.answer( - "Данные обучения и история очищены: стиль, примеры постов, история публикаций и идей. " - "Подписка и триал не изменены. Служебные API-логи сохраняются. " + "⚠️ Сбросить данные обучения?\n\n" + "Будут удалены стиль, примеры постов, история публикаций и идей, а также настройки.\n" + "Подписка, триал и служебные API-логи останутся.", + parse_mode="HTML", + reply_markup=training_reset_confirmation_keyboard(message.from_user.id), + ) + + +@router.callback_query(F.data.startswith("training:reset:")) +async def confirm_training_reset(callback: CallbackQuery, state: FSMContext) -> None: + parts = (callback.data or "").split(":") + if len(parts) != 4 or parts[2] not in {"confirm", "cancel"}: + await callback.answer("Некорректное действие.", show_alert=True) + return + try: + owner_id = int(parts[3]) + except ValueError: + await callback.answer("Некорректное подтверждение.", show_alert=True) + return + if callback.from_user is None or callback.from_user.id != owner_id: + await callback.answer("Эта кнопка предназначена другому пользователю.", show_alert=True) + return + if await current_mode(state) != "training": + await callback.answer("Сброс доступен только в режиме «Обучение».", show_alert=True) + return + message = callback.message + if not isinstance(message, Message): + await callback.answer("Не удалось обновить сообщение.", show_alert=True) + return + await callback.answer() + if parts[2] == "cancel": + await message.edit_text("Сброс отменён. Данные не изменены.", reply_markup=None) + return + await asyncio.to_thread(clear_user_data, owner_id) + await message.edit_text( + "✅ Данные обучения сброшены.\n\n" + "Стиль, примеры постов и история удалены.\n" + "Подписка, триал и API-логи сохранены.\n\n" "Пришли новые скриншоты, чтобы настроить стиль заново.", - reply_markup=mode_keyboard("training"), + parse_mode="HTML", + reply_markup=None, ) @@ -485,7 +546,12 @@ async def _check_subscription(message: Message) -> bool: async def _send_idea(message: Message, idea: dict[str, str], user_id: int) -> None: - await message.answer(f"{idea['title']}\n\n{idea['angle']}\n\nЗачем: {idea['why']}") + await message.answer( + f"💡 {html.escape(idea['title'])}\n\n" + f"Угол: {html.escape(idea['angle'])}\n\n" + f"Зачем: {html.escape(idea['why'])}", + parse_mode="HTML", + ) await asyncio.to_thread(save_idea, user_id, idea) @@ -636,9 +702,17 @@ async def _process_image_group(messages: list[Message], bot: Bot) -> None: all_samples = await asyncio.to_thread(add_post_samples, first.from_user.id, new_samples) tone = await create_tone_profile(all_samples, previous_tone, user_id=first.from_user.id) await asyncio.to_thread(save_tone_of_voice, first.from_user.id, tone) - await first.answer( - f"Обновила только правила стиля по накопленным примерам ({len(all_samples)} постов).\n\n{tone}\n\n" - "Распознанные тексты сохранены для следующих обновлений и не переписывались." + rich_html = ( + "

✅ Стиль обновлён

" + f"

Образцов учтено: {len(all_samples)}.

" + f"

{html.escape(tone)}

" + "

Скриншоты сохранены как образцы; тексты постов не переписывались.

" + ) + await send_rich_message( + first, + rich_html, + "обновления стиля", + reply_markup=mode_keyboard("training"), ) @@ -782,6 +856,9 @@ async def handle_text(message: Message, state: FSMContext) -> None: if button == MAIN_MENU_VALUE: await show_main_menu(message, state) return + if button == TRAINING_RESET_VALUE: + await clear_command(message, state) + return if button and button.startswith("/"): await handle_command_text(message, state) return @@ -832,7 +909,7 @@ async def set_main_menu(bot: Bot) -> None: BotCommand(command="help", description="Режим: помощь"), BotCommand(command="post", description="Написать пост (генерация)"), BotCommand(command="style", description="Мой стиль (обучение)"), - BotCommand(command="clear", description="Сбросить данные (обучение)"), + BotCommand(command="clear", description="Сбросить данные (обучение, с подтверждением)"), BotCommand(command="idea", description="Идея (генерация)"), BotCommand(command="balance", description="Статус подписки"), BotCommand(command="subscribe", description="Оплатить подписку"), diff --git a/help_message.py b/help_message.py index cad54fd..9dcfa2f 100644 --- a/help_message.py +++ b/help_message.py @@ -28,7 +28,8 @@ HELP_SECTIONS: dict[str, tuple[str, str]] = { ), "tone": ( "🎨 Tone of Voice", - """

Пришли скриншот или галерею скриншотов своих опубликованных постов. Я распознаю текст постов и сохраню его как образцы, дополню предыдущие примеры и обновлю только правила Tone of Voice. Сами публикации не переписываю.

""", + """

Пришли скриншот или галерею скриншотов своих опубликованных постов. Я распознаю текст, сохраню его как образцы и обновлю только правила Tone of Voice. Сами публикации не переписываю.

+

Чтобы удалить стиль и историю обучения, нажми «Сбросить данные» и подтверди действие.

""", ), "edit": ( "✏️ Правка поста", @@ -51,7 +52,7 @@ HELP_SECTIONS: dict[str, tuple[str, str]] = { "commands": ( "⌨️ Команды", """

Команды-переключатели доступны в любом режиме: /start — главное меню; /training — обучение; /generate — генерация; /subscription — подписка; /help — помощь.

-

Действия доступны только в своём режиме: /style и /clear — обучение; /post и /idea — генерация; /balance и /subscribe — подписка. /clear очищает пользовательские данные, но не меняет подписку и триал.

+

Действия доступны только в своём режиме: /style и /clear — обучение; /post и /idea — генерация; /balance и /subscribe — подписка. /clear и кнопка «Сбросить данные» сначала запрашивают подтверждение; подписка и триал не меняются.

В режиме «Генерация» можно попросить идею обычными словами: «предложи идею для поста», «о чём написать?», «накидай тему».

""", ), } diff --git a/keyboards.py b/keyboards.py index fa394c0..755b921 100644 --- a/keyboards.py +++ b/keyboards.py @@ -16,11 +16,14 @@ MODE_BUTTONS: dict[str, str] = { } MAIN_MENU_BUTTON = "🏠 Главное меню" MAIN_MENU_VALUE = "__main_menu__" +TRAINING_RESET_BUTTON = "🗑 Сбросить данные" +TRAINING_RESET_VALUE = "__training_reset__" BUTTON_TEXT_MAP: dict[str, str] = { **MODE_BUTTONS, MAIN_MENU_BUTTON: MAIN_MENU_VALUE, "Главное меню": MAIN_MENU_VALUE, + TRAINING_RESET_BUTTON: TRAINING_RESET_VALUE, } @@ -36,12 +39,19 @@ def main_menu_keyboard() -> ReplyKeyboardMarkup: ) -def mode_keyboard(_mode: str) -> ReplyKeyboardMarkup: - """Внутри любого режима оставляет только выход в главное меню.""" +def mode_keyboard(mode: str) -> ReplyKeyboardMarkup: + """Внутри режима показывает релевантные действия и возврат в меню.""" + rows = [[KeyboardButton(text=MAIN_MENU_BUTTON)]] + if mode == "training": + rows.append([KeyboardButton(text=TRAINING_RESET_BUTTON)]) return ReplyKeyboardMarkup( - keyboard=[[KeyboardButton(text=MAIN_MENU_BUTTON)]], + keyboard=rows, resize_keyboard=True, - input_field_placeholder="Напиши сообщение или вернись в главное меню", + input_field_placeholder=( + "Управляй обучением или вернись в меню" + if mode == "training" + else "Напиши сообщение или вернись в главное меню" + ), ) diff --git a/tests/test_keyboards.py b/tests/test_keyboards.py index 438b9c8..39f1939 100644 --- a/tests/test_keyboards.py +++ b/tests/test_keyboards.py @@ -51,10 +51,24 @@ class KeyboardLayoutTests(unittest.TestCase): self.assertEqual(labels, set(keyboards.MODE_BUTTONS)) self.assertNotIn("🏠 Лобби", labels) - def test_each_mode_keyboard_has_only_main_menu_button(self): + def test_each_mode_keyboard_shows_expected_actions(self): for mode in bot.MODE_STATES: labels = [b.text for row in keyboards.mode_keyboard(mode).keyboard for b in row] - self.assertEqual(labels, [keyboards.MAIN_MENU_BUTTON], mode) + expected = [keyboards.MAIN_MENU_BUTTON] + if mode == "training": + expected.append(keyboards.TRAINING_RESET_BUTTON) + self.assertEqual(labels, expected, mode) + + def test_mode_entry_copy_uses_short_paragraphs(self): + self.assertIn("\n", bot.MODE_COPY["training"][1]) + self.assertIn("\n", bot.MODE_COPY["generation"][1]) + self.assertIn("\n", bot.MODE_COPY["subscription"][1]) + + def test_training_reset_button_routes_to_confirmation_action(self): + self.assertEqual( + keyboards.resolve_button_text(keyboards.TRAINING_RESET_BUTTON), + keyboards.TRAINING_RESET_VALUE, + ) def test_every_button_is_resolvable(self): markups = [keyboards.main_menu_keyboard()] @@ -165,7 +179,7 @@ class ModeRoutingTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(await state.get_state(), bot.BotMode.subscription.state) self.assertEqual(len(message.answers), 2) mode_keyboard = message.answers[0][1]["reply_markup"] - self.assertEqual(mode_keyboard.keyboard[0][0].text, keyboards.MAIN_MENU_BUTTON) + self.assertEqual(message.answers[0][1]["reply_markup"].keyboard[0][0].text, keyboards.MAIN_MENU_BUTTON) self.assertIn("Подписка не активирована", message.answers[1][0]) renew_keyboard = message.answers[1][1]["reply_markup"] self.assertEqual( diff --git a/tests/test_writer_features.py b/tests/test_writer_features.py index 5aa2c26..82c385d 100644 --- a/tests/test_writer_features.py +++ b/tests/test_writer_features.py @@ -386,6 +386,7 @@ class ScreenshotToneWorkflowTests(unittest.IsolatedAsyncioTestCase): patch.object(bot, "add_post_samples", return_value=["Старый пост", "Новый пост"]) as add_samples, patch.object(bot, "create_tone_profile", new=AsyncMock(return_value="Обновлённый стиль")) as create_profile, patch.object(bot, "save_tone_of_voice") as save_tone, + patch.object(bot, "send_rich_message", new=AsyncMock()) as send_rich, patch.object(bot.asyncio, "to_thread", side_effect=run_in_thread), ): await bot._process_image_group([message], AsyncMock()) @@ -395,8 +396,9 @@ class ScreenshotToneWorkflowTests(unittest.IsolatedAsyncioTestCase): ["Старый пост", "Новый пост"], "Старый профиль", user_id=7 ) save_tone.assert_called_once_with(7, "Обновлённый стиль") - self.assertIn("только правила стиля", message.answer.await_args.args[0]) - self.assertIn("не переписывались", message.answer.await_args.args[0]) + self.assertIn("Стиль обновлён", str(send_rich.await_args)) + self.assertIn("не переписывались", str(send_rich.await_args)) + self.assertIn("обновления стиля", str(send_rich.await_args)) async def test_clear_command_resets_only_calling_user_profile_and_samples(self): @@ -424,11 +426,15 @@ class ScreenshotToneWorkflowTests(unittest.IsolatedAsyncioTestCase): patch.object(bot.asyncio, "to_thread", side_effect=run_in_thread), ): await bot.clear_command(message, _StubState("training")) - clear_data.assert_called_once_with(25) - msg_calls = [c.args[0] for c in message.answer.call_args_list] - self.assertTrue(any("API-логи" in m for m in msg_calls)) - self.assertTrue(any("скриншоты" in m for m in msg_calls)) - self.assertTrue(any("Подписка и триал не изменены" in m for m in msg_calls)) + self.assertEqual(message.answer.await_count, 1) + warning, kwargs = message.answer.await_args.args[0], message.answer.await_args.kwargs + self.assertIn("Сбросить данные обучения?", warning) + self.assertIn("Подписка, триал", warning) + buttons = kwargs["reply_markup"].inline_keyboard[0] + self.assertEqual([button.text for button in buttons], ["✅ Подтвердить", "↩️ Отмена"]) + self.assertEqual(buttons[0].callback_data, "training:reset:confirm:25") + self.assertEqual(buttons[1].callback_data, "training:reset:cancel:25") + clear_data.assert_not_called() class StorageTests(unittest.TestCase): @@ -698,34 +704,103 @@ class SubscriptionTests(unittest.IsolatedAsyncioTestCase): async def test_clear_preserves_subscription(self): user_store.ensure_subscription(105, self.db_path) user_store.activate_subscription(105, 1, self.db_path) - message = SimpleNamespace( - from_user=SimpleNamespace(id=105), + user_store.save_tone_of_voice(105, "test style", self.db_path) + from datetime import datetime + from types import SimpleNamespace + from aiogram.types import Chat, Message + + message_bot = AsyncMock() + message = Message(message_id=10, date=datetime.now(), chat=Chat(id=555, type="private")).as_(message_bot) + state = _StubState("training") + async def run_in_thread(function, *args, **kwargs): + return function(*args, **kwargs) + + with patch.object(bot.asyncio, "to_thread", side_effect=run_in_thread): + await bot.clear_command(message, cast(Any, state)) + callback = SimpleNamespace( + data="training:reset:confirm:105", + from_user=SimpleNamespace(id=105), + message=message, + answer=AsyncMock(), + ) + await bot.confirm_training_reset(callback, state) + + self.assertEqual(user_store.get_tone_of_voice(105, self.db_path), "") + sub = user_store.subscription_row(105, self.db_path) + self.assertEqual(sub["is_active"], 1) + + async def test_training_reset_cancel_does_not_clear_data(self): + from datetime import datetime + from types import SimpleNamespace + from aiogram.types import Chat, Message + + message_bot = AsyncMock() + message = Message(message_id=1, date=datetime.now(), chat=Chat(id=555, type="private")).as_(message_bot) + callback = SimpleNamespace( + data="training:reset:cancel:107", + from_user=SimpleNamespace(id=107), + message=message, answer=AsyncMock(), ) + with patch.object(bot, "clear_user_data") as clear_data: + await bot.confirm_training_reset(callback, _StubState("training")) + clear_data.assert_not_called() + callback.answer.assert_awaited_once_with() + message_bot.assert_awaited_once() + self.assertIn("Сброс отменён", str(message_bot.await_args)) + + async def test_training_reset_confirm_requires_owner_and_training_mode(self): + from datetime import datetime + from types import SimpleNamespace + from aiogram.types import Chat, Message + + message = Message(message_id=1, date=datetime.now(), chat=Chat(id=555, type="private")).as_(AsyncMock()) + callback = SimpleNamespace( + data="training:reset:confirm:108", + from_user=SimpleNamespace(id=999), + message=message, + answer=AsyncMock(), + ) + with patch.object(bot, "clear_user_data") as clear_data: + await bot.confirm_training_reset(callback, _StubState("training")) + clear_data.assert_not_called() + callback.answer.assert_awaited_once() + self.assertIn("другому пользователю", callback.answer.await_args.args[0]) + + callback.from_user.id = 108 + callback.answer.reset_mock() + with patch.object(bot, "clear_user_data") as clear_data: + await bot.confirm_training_reset(callback, _StubState("generation")) + clear_data.assert_not_called() + self.assertIn("только в режиме", callback.answer.await_args.args[0]) + + async def test_clear_preserves_trial(self): + user_store.ensure_subscription(106, self.db_path) + user_store.save_tone_of_voice(106, "test style", self.db_path) + before = user_store.subscription_row(106, self.db_path) + assert before is not None + from datetime import datetime + from types import SimpleNamespace + from aiogram.types import Chat, Message + + message_bot = AsyncMock() + message = Message(message_id=10, date=datetime.now(), chat=Chat(id=555, type="private")).as_(message_bot) + state = _StubState("training") async def run_in_thread(function, *args, **kwargs): return function(*args, **kwargs) with patch.object(bot.asyncio, "to_thread", side_effect=run_in_thread): - await bot.clear_command(message, _StubState("training")) - - msg_calls = [c.args[0] for c in message.answer.call_args_list] - self.assertTrue(any("Подписка и триал не изменены" in msg for msg in msg_calls)) - sub = user_store.subscription_row(105, self.db_path) - self.assertEqual(sub["is_active"], 1) - - async def test_clear_preserves_trial(self): - user_store.ensure_subscription(106, self.db_path) - before = user_store.subscription_row(106, self.db_path) - assert before is not None - message = SimpleNamespace( - from_user=SimpleNamespace(id=106), - answer=AsyncMock(), - ) - - with patch.object(bot.asyncio, "to_thread", side_effect=lambda f, *a: f(*a)): - await bot.clear_command(cast(Any, message), cast(Any, _StubState("training"))) + await bot.clear_command(cast(Any, message), cast(Any, state)) + callback = SimpleNamespace( + data="training:reset:confirm:106", + from_user=SimpleNamespace(id=106), + message=message, + answer=AsyncMock(), + ) + await bot.confirm_training_reset(callback, cast(Any, state)) + self.assertEqual(user_store.get_tone_of_voice(106, self.db_path), "") after = user_store.subscription_row(106, self.db_path) assert after is not None self.assertEqual(after["is_active"], before["is_active"])