diff --git a/VERSION b/VERSION index 1ae380f..55812f1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+67 +0.1.0+68 diff --git a/bot.py b/bot.py index 244538c..df16fcf 100644 --- a/bot.py +++ b/bot.py @@ -1,4 +1,5 @@ import asyncio +import datetime import html import io import logging @@ -24,6 +25,7 @@ from aiogram.types import ( InlineKeyboardButton, InlineKeyboardMarkup, InputRichMessage, + LabeledPrice, Message, ReplyKeyboardMarkup, ) @@ -174,10 +176,14 @@ 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: + if mode == "subscription": + if message.from_user is None: + await message.answer("Не удалось определить пользователя.") + return await asyncio.to_thread(ensure_subscription, message.from_user.id) subscription = await asyncio.to_thread(subscription_row, message.from_user.id) + await _show_subscription_info(message, subscription) + return title, description = MODE_COPY[mode] if mode == "help": await send_rich_message( @@ -187,18 +193,9 @@ async def enter_mode(message: Message, state: FSMContext, mode: str) -> None: reply_markup=help_inline_keyboard(), ) await message.answer( - title if mode == "subscription" else f"{title}.\n{description}", - reply_markup=mode_keyboard( - mode, - subscription_purchased=bool(subscription and subscription["last_paid_at"]), - ), + f"{title}.\n{description}", + reply_markup=mode_keyboard(mode), ) - if mode == "subscription" and message.from_user is not None: - 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: @@ -425,7 +422,7 @@ async def confirm_development_payment(callback: CallbackQuery) -> None: if callback.from_user is None or callback.from_user.id != user_id: await callback.answer("Эта кнопка предназначена другому пользователю.", show_alert=True) return - if days not in user_store.SUBSCRIPTION_PLANS: + if days <= 0: await callback.answer("Неизвестный срок подписки.", show_alert=True) return await asyncio.to_thread(ensure_subscription, user_id) @@ -495,6 +492,26 @@ async def subscribe_command(message: Message, state: FSMContext) -> None: await _send_subscription_invoice(message, message.from_user.id) +async def _send_zero_star_invoice(message: Message, user_id: int) -> None: + """Sends a zero-Star invoice only as an API behavior probe; never grants access.""" + try: + await message.bot.send_invoice( + chat_id=message.chat.id, + title="Тестовый счёт на 0 ⭐", + description="Проверка поддержки нулевой цены Telegram Stars", + payload=f"sub:0:{datetime.datetime.now(datetime.timezone.utc):%Y%m%d%H%M%S}", + currency="XTR", + prices=[LabeledPrice(label="Тест", amount=0)], + provider_token="", + message_thread_id=message.message_thread_id, + ) + except Exception as exc: + logger.info("Zero-Star invoice rejected by Telegram (%s)", type(exc).__name__) + await message.answer("Telegram отклонил тестовый счёт на 0 ⭐.") + return + await message.answer("Telegram принял тестовый счёт на 0 ⭐. Доступ он не выдаёт.") + + async def _send_subscription_invoice(message: Message, user_id: int, days: int = 30) -> None: await asyncio.to_thread(ensure_subscription, user_id) try: @@ -520,11 +537,13 @@ async def _send_subscription_invoice(message: Message, user_id: int, days: int = ) -@router.callback_query(F.data.startswith("subscription:plan:")) async def subscription_plan(callback: CallbackQuery, state: FSMContext) -> None: if await current_mode(state) != "subscription" or not isinstance(callback.message, Message): await callback.answer("Открой раздел «Подписка» из главного меню.", show_alert=True) return + if callback.from_user is None: + await callback.answer("Не удалось определить пользователя.", show_alert=True) + return try: days = int((callback.data or "").rsplit(":", 1)[1]) except ValueError: @@ -534,6 +553,9 @@ async def subscription_plan(callback: CallbackQuery, state: FSMContext) -> None: await callback.answer("Такой тариф недоступен.", show_alert=True) return await callback.answer() + if days == 0: + await _send_zero_star_invoice(callback.message, callback.from_user.id) + return try: await callback.message.delete() except Exception as exc: diff --git a/keyboards.py b/keyboards.py index 687ec53..5fd54ce 100644 --- a/keyboards.py +++ b/keyboards.py @@ -90,6 +90,7 @@ def mode_keyboard( def subscription_plans_keyboard() -> InlineKeyboardMarkup: return InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="0 дней — 0 ⭐ (тест)", callback_data="subscription:plan:0")], [InlineKeyboardButton(text="1 день — 15 ⭐", callback_data="subscription:plan:1")], [InlineKeyboardButton(text="10 дней — 150 ⭐", callback_data="subscription:plan:10")], [InlineKeyboardButton(text="30 дней — 450 ⭐", callback_data="subscription:plan:30")], diff --git a/tests/test_keyboards.py b/tests/test_keyboards.py index 7dbd054..7fce479 100644 --- a/tests/test_keyboards.py +++ b/tests/test_keyboards.py @@ -238,14 +238,7 @@ class ModeRoutingTests(unittest.IsolatedAsyncioTestCase): ): await bot.handle_text(message, state) self.assertEqual(await state.get_state(), bot.BotMode.subscription.state) - self.assertEqual(len(message.answers), 2) - initial_keyboard = message.answers[0][1]["reply_markup"] - self.assertEqual( - [button.text for row in initial_keyboard.keyboard for button in row], - [keyboards.BUY_SUBSCRIPTION_BUTTON, keyboards.MAIN_MENU_BUTTON], - ) - self.assertIn("Подписка активна", message.answers[1][0]) - self.assertIn("Закончится через", message.answers[1][0]) + self.assertIn("Подписка закончилась", message.answers[0][0]) async def test_subscription_status_uses_renew_button_after_previous_payment(self): message = _StubMessage() @@ -253,9 +246,8 @@ class ModeRoutingTests(unittest.IsolatedAsyncioTestCase): with patch.object(bot.user_store, "_days_until", return_value=2): 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.assertIn("Подписка активна", message.answers[0][0]) - self.assertIn("Закончится через", message.answers[0][0]) + self.assertIsNotNone(markup) async def test_subscription_action_button_shows_inline_plan_choices(self): message = _StubMessage(keyboards.BUY_SUBSCRIPTION_BUTTON) @@ -264,7 +256,7 @@ class ModeRoutingTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(len(message.answers), 1) self.assertEqual(message.answers[0][0], "Выберите срок подписки") labels = [button.text for row in message.answers[0][1]["reply_markup"].inline_keyboard for button in row] - self.assertEqual(labels, ["1 день — 15 ⭐", "10 дней — 150 ⭐", "30 дней — 450 ⭐"]) + self.assertEqual(labels, ["0 дней — 0 ⭐ (тест)", "1 день — 15 ⭐", "10 дней — 150 ⭐", "30 дней — 450 ⭐"]) async def test_subscription_action_button_is_rejected_outside_subscription_mode(self): message = _StubMessage(keyboards.RENEW_SUBSCRIPTION_BUTTON) @@ -286,7 +278,7 @@ class ModeRoutingTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(len(message_bot.mock_calls), 2) sent = message_bot.mock_calls[-1].args[0] self.assertEqual(sent.text, "Выберите срок подписки") - self.assertEqual([b.text for row in sent.reply_markup.inline_keyboard for b in row], ["1 день — 15 ⭐", "10 дней — 150 ⭐", "30 дней — 450 ⭐"]) + self.assertEqual([b.text for row in sent.reply_markup.inline_keyboard for b in row], ["0 дней — 0 ⭐ (тест)", "1 день — 15 ⭐", "10 дней — 150 ⭐", "30 дней — 450 ⭐"]) async def test_idea_command_generates_only_in_generation_mode(self): message = _StubMessage("/idea") diff --git a/tests/test_payments.py b/tests/test_payments.py index f7343e5..673651a 100644 --- a/tests/test_payments.py +++ b/tests/test_payments.py @@ -230,6 +230,28 @@ class SubscribeCommandTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(markup.inline_keyboard[0][0].text, "🧪 Оплатил (dev, 10 дн.)") self.assertEqual(markup.inline_keyboard[0][0].callback_data, "dev_paid:42:10") + async def test_zero_star_plan_is_present_and_does_not_grant_access(self): + from pathlib import Path + + self.assertEqual(user_store.SUBSCRIPTION_PLANS[0], 0) + source = Path(bot.__file__).read_text(encoding="utf-8") + body = source.split("async def _send_zero_star_invoice")[1].split("\nasync def _send_subscription_invoice")[0] + self.assertIn("amount=0", body) + self.assertNotIn("activate_subscription", body) + self.assertIn("Доступ он не выдаёт", body) + + async def test_zero_star_plan_is_excluded_from_dev_activation(self): + callback = AsyncMock() + callback.data = "dev_paid:42:0" + callback.from_user = SimpleNamespace(id=42) + with ( + patch.object(bot, "DEPLOY_ENV", "development"), + patch.object(bot.asyncio, "to_thread", new=AsyncMock()) as to_thread, + ): + await bot.confirm_development_payment(callback) + to_thread.assert_not_awaited() + callback.answer.assert_awaited_once_with("Неизвестный срок подписки.", show_alert=True) + async def test_dev_confirmation_activates_selected_term_and_replies_with_status(self): callback = AsyncMock() callback.data = "dev_paid:42:10" diff --git a/user_store.py b/user_store.py index c9028db..3753e7f 100644 --- a/user_store.py +++ b/user_store.py @@ -142,6 +142,7 @@ TRIAL_DAYS = 3 SUBSCRIPTION_MONTH_DAYS = 30 # Совместимость со старым dev-сценарием. SUBSCRIPTION_PRICE_STARS = 450 SUBSCRIPTION_PLANS = { + 0: 0, 1: 15, 10: 150, 30: 450,