diff --git a/bot.py b/bot.py index 746014f..3dc4567 100644 --- a/bot.py +++ b/bot.py @@ -39,7 +39,7 @@ from raw_updates import RawUpdateMiddleware, init_raw_updates_db from apinex_client import APINEX_STT_URL, POST_HISTORY_CHAR_LIMIT, POST_HISTORY_LIMIT, classify_intent, classify_prompt_injection, create_tone_profile, current_operation_id, diagnostic_operation, extract_posts_from_images, extract_transcript, generate_ideas, generate_posts, generate_posts_from_images, transcribe_audio from help_message import WELCOME_RICH_HTML, help_plain_view, help_view, rich_to_plain -from keyboards import MAIN_MENU_VALUE, SUPPORT_VALUE, TRAINING_RESET_VALUE, help_inline_keyboard, main_menu_keyboard, mode_keyboard, resolve_button_text +from keyboards import MAIN_MENU_VALUE, SUBSCRIPTION_ACTION_VALUE, SUPPORT_VALUE, TRAINING_RESET_VALUE, help_inline_keyboard, main_menu_keyboard, mode_keyboard, resolve_button_text from payments import send_subscription_invoice import support from user_store import ( @@ -148,10 +148,7 @@ MODE_COPY = { "Генерация", "Напиши запрос или отправь голосовое.\nДля правки ответь на мой вариант.", ), - "subscription": ( - "Подписка", - "Проверь статус ниже.\nКнопка откроет оплату или продление.", - ), + "subscription": ("Подписка", "Проверь статус ниже.\nВыбери действие в меню."), "help": ("Помощь", "Выбери раздел в сообщении выше."), } @@ -172,6 +169,9 @@ async def enter_mode(message: Message, state: FSMContext, mode: str) -> None: await _close_support_case(message) await state.clear() await state.set_state(MODE_STATES[mode]) + subscription = None + if mode == "subscription" and message.from_user is not None: + subscription = await asyncio.to_thread(subscription_row, message.from_user.id) title, description = MODE_COPY[mode] if mode == "help": await send_rich_message( @@ -182,11 +182,17 @@ async def enter_mode(message: Message, state: FSMContext, mode: str) -> None: ) await message.answer( f"Режим «{title}».\n{description}", - reply_markup=mode_keyboard(mode), + reply_markup=mode_keyboard( + mode, + subscription_purchased=bool(subscription and subscription["last_paid_at"]), + ), ) if mode == "subscription" and message.from_user is not None: - sub = await asyncio.to_thread(subscription_row, message.from_user.id) - await _show_subscription_info(message, sub) + await _show_subscription_info(message, subscription) + elif mode == "subscription": + await message.answer( + "Не удалось определить пользователя.", reply_markup=mode_keyboard("subscription") + ) async def show_main_menu(message: Message, state: FSMContext) -> None: @@ -472,23 +478,14 @@ async def _show_subscription_info( text += f"\n\nПродление на 30 дней — {SUBSCRIPTION_PRICE_STARS} ⭐." if message.from_user is not None: text += f"\nID пользователя: {message.from_user.id}" - markup = subscription_renew_keyboard() + markup = mode_keyboard( + "subscription", subscription_purchased=bool(sub["last_paid_at"]) + ) if sub else mode_keyboard("subscription") else: markup = mode_keyboard(mode) await message.answer(text, reply_markup=markup) -def subscription_renew_keyboard() -> InlineKeyboardMarkup: - return InlineKeyboardMarkup( - inline_keyboard=[[ - InlineKeyboardButton( - text="Продлить подписку", - callback_data="subscription:renew", - ) - ]] - ) - - async def balance_command(message: Message, state: FSMContext) -> None: if not await require_mode(message, state, "subscription"): return @@ -1439,6 +1436,10 @@ async def handle_text(message: Message, state: FSMContext) -> None: if button == SUPPORT_VALUE: await open_support(message, state) return + if button == SUBSCRIPTION_ACTION_VALUE: + if await require_mode(message, state, "subscription") and message.from_user is not None: + await _send_subscription_invoice(message, message.from_user.id) + return if button == TRAINING_RESET_VALUE: await clear_command(message, state) return @@ -1463,7 +1464,7 @@ async def handle_text(message: Message, state: FSMContext) -> None: prompts = { "lobby": "Выбери режим работы кнопкой ниже.", "training": "Пришли пост текстом, скриншотом, голосовым или аудиофайлом — профиль будет накапливаться.", - "subscription": "Статус уже показан выше. Для продления нажми кнопку «Продлить подписку».", + "subscription": "Статус уже показан выше. Нажми кнопку покупки или продления подписки.", "help": "Открой нужный раздел справки выше или выбери другой режим.", } await message.answer( @@ -1484,7 +1485,7 @@ async def handle_other_message(message: Message, state: FSMContext) -> None: elif mode == "generation": text = "Для генерации отправь текстовый запрос или голосовое сообщение." elif mode == "subscription": - text = "Статус уже показан выше. Для продления нажми «Продлить подписку»." + text = "Статус уже показан выше. Нажми кнопку покупки или продления подписки." elif mode == "help": text = "Выбери раздел справки или другой режим в меню." else: diff --git a/keyboards.py b/keyboards.py index 8900627..cdf2d2d 100644 --- a/keyboards.py +++ b/keyboards.py @@ -24,6 +24,9 @@ TRAINING_RESET_VALUE = "__training_reset__" POST_GENERATE_BUTTON = "✍️ Придумать пост" SUPPORT_BUTTON = "🆘 Поддержка" SUPPORT_VALUE = "__support__" +SUBSCRIPTION_ACTION_VALUE = "__subscription_action__" +BUY_SUBSCRIPTION_BUTTON = "🛒 Купить подписку" +RENEW_SUBSCRIPTION_BUTTON = "🔄 Продлить подписку" BUTTON_TEXT_MAP: dict[str, str] = { **MODE_BUTTONS, @@ -32,6 +35,8 @@ BUTTON_TEXT_MAP: dict[str, str] = { TRAINING_RESET_BUTTON: TRAINING_RESET_VALUE, POST_GENERATE_BUTTON: "/post", SUPPORT_BUTTON: SUPPORT_VALUE, + BUY_SUBSCRIPTION_BUTTON: SUBSCRIPTION_ACTION_VALUE, + RENEW_SUBSCRIPTION_BUTTON: SUBSCRIPTION_ACTION_VALUE, } @@ -47,7 +52,11 @@ def main_menu_keyboard() -> ReplyKeyboardMarkup: ) -def mode_keyboard(mode: str, support: bool = False) -> ReplyKeyboardMarkup: +def mode_keyboard( + mode: str, + support: bool = False, + subscription_purchased: bool = False, +) -> ReplyKeyboardMarkup: """Внутри режима показывает релевантные действия и возврат в меню. В активном обращении поддержки остаётся только кнопка «Главное меню». @@ -65,6 +74,9 @@ def mode_keyboard(mode: str, support: bool = False) -> ReplyKeyboardMarkup: rows.insert(0, [KeyboardButton(text=POST_GENERATE_BUTTON)]) elif mode == "help": rows.insert(0, [KeyboardButton(text=SUPPORT_BUTTON)]) + elif mode == "subscription": + action = RENEW_SUBSCRIPTION_BUTTON if subscription_purchased else BUY_SUBSCRIPTION_BUTTON + rows.insert(0, [KeyboardButton(text=action)]) return ReplyKeyboardMarkup( keyboard=rows, resize_keyboard=True, diff --git a/tests/test_keyboards.py b/tests/test_keyboards.py index 7e89190..86636a2 100644 --- a/tests/test_keyboards.py +++ b/tests/test_keyboards.py @@ -49,6 +49,13 @@ class ButtonTextTests(unittest.TestCase): def test_generate_post_button_maps_to_post_command(self): self.assertEqual(keyboards.resolve_button_text(keyboards.POST_GENERATE_BUTTON), "/post") + def test_subscription_actions_map_to_internal_action(self): + for label in (keyboards.BUY_SUBSCRIPTION_BUTTON, keyboards.RENEW_SUBSCRIPTION_BUTTON): + self.assertEqual( + keyboards.resolve_button_text(label), + keyboards.SUBSCRIPTION_ACTION_VALUE, + ) + def test_regular_text_is_not_treated_as_button(self): self.assertIsNone(keyboards.resolve_button_text("Напиши пост про запуск")) self.assertIsNone(keyboards.resolve_button_text("привет")) @@ -74,8 +81,18 @@ class KeyboardLayoutTests(unittest.TestCase): expected.insert(0, keyboards.POST_GENERATE_BUTTON) elif mode == "help": expected.insert(0, keyboards.SUPPORT_BUTTON) + elif mode == "subscription": + expected.insert(0, keyboards.BUY_SUBSCRIPTION_BUTTON) self.assertEqual(labels, expected, mode) + def test_subscription_keyboard_uses_renew_label_after_paid_subscription(self): + labels = [ + button.text + for row in keyboards.mode_keyboard("subscription", subscription_purchased=True).keyboard + for button in row + ] + self.assertEqual(labels, [keyboards.RENEW_SUBSCRIPTION_BUTTON, keyboards.MAIN_MENU_BUTTON]) + def test_support_keyboard_keeps_only_main_menu(self): for mode in bot.MODE_STATES: labels = [ @@ -211,7 +228,7 @@ class ModeRoutingTests(unittest.IsolatedAsyncioTestCase): await bot.handle_text(message, state) self.assertEqual(await state.get_state(), bot.BotMode.help.state) - async def test_subscription_entry_shows_status_and_renew_button(self): + async def test_subscription_entry_shows_buy_reply_button_without_purchase(self): message = _StubMessage("💳 Подписка") state = _StubState("lobby") with ( @@ -222,14 +239,40 @@ 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(message.answers[0][1]["reply_markup"].keyboard[0][0].text, keyboards.MAIN_MENU_BUTTON) + initial_keyboard = message.answers[0][1]["reply_markup"] + self.assertEqual(initial_keyboard.keyboard[0][0].text, keyboards.BUY_SUBSCRIPTION_BUTTON) self.assertIn("Подписка не активирована", message.answers[1][0]) - renew_keyboard = message.answers[1][1]["reply_markup"] - self.assertEqual( - renew_keyboard.inline_keyboard[0][0].callback_data, - "subscription:renew", - ) + buy_keyboard = message.answers[1][1]["reply_markup"] + self.assertFalse(hasattr(buy_keyboard, "inline_keyboard")) + self.assertEqual(buy_keyboard.keyboard[0][0].text, keyboards.BUY_SUBSCRIPTION_BUTTON) + + async def test_subscription_status_uses_renew_button_after_previous_payment(self): + message = _StubMessage() + sub = {"last_paid_at": "2026-01-01T00:00:00", "expires_at": "2026-02-01T00:00:00", "is_active": 1} + with patch.object(bot.user_store, "_days_until", return_value=0): + await bot._show_subscription_info(cast(Any, message), cast(Any, sub)) + markup = message.answers[0][1]["reply_markup"] + self.assertFalse(hasattr(markup, "inline_keyboard")) + self.assertEqual(markup.keyboard[0][0].text, keyboards.RENEW_SUBSCRIPTION_BUTTON) + + async def test_subscription_action_button_sends_invoice_in_subscription_mode(self): + message = _StubMessage(keyboards.BUY_SUBSCRIPTION_BUTTON) + with ( + patch.object(bot, "notify_development_deploy", new=AsyncMock()), + patch.object(bot, "_send_subscription_invoice", new=AsyncMock()) as send_invoice, + ): + await bot.handle_text(message, _StubState("subscription")) + send_invoice.assert_awaited_once_with(message, 42) + + async def test_subscription_action_button_is_rejected_outside_subscription_mode(self): + message = _StubMessage(keyboards.RENEW_SUBSCRIPTION_BUTTON) + with ( + patch.object(bot, "notify_development_deploy", new=AsyncMock()), + patch.object(bot, "_send_subscription_invoice", new=AsyncMock()) as send_invoice, + ): + await bot.handle_text(message, _StubState("generation")) + send_invoice.assert_not_awaited() + self.assertIn("только в режиме «Подписка»", message.answers[0][0]) async def test_renew_callback_sends_invoice_without_intermediate_message(self): message_bot = AsyncMock() diff --git a/tests/test_writer_features.py b/tests/test_writer_features.py index 8487a1e..dff8e45 100644 --- a/tests/test_writer_features.py +++ b/tests/test_writer_features.py @@ -1016,8 +1016,8 @@ class SubscriptionTests(unittest.IsolatedAsyncioTestCase): self.assertIn("пробный период", message.answer.call_args.args[0].lower()) renew_markup = message.answer.call_args.kwargs["reply_markup"] self.assertEqual( - renew_markup.inline_keyboard[0][0].callback_data, - "subscription:renew", + renew_markup.keyboard[0][0].text, + "🛒 Купить подписку", ) async def test_blocked_when_subscription_expired(self):