diff --git a/IDEA.MD b/IDEA.MD index 7a4eff3..51e639d 100644 --- a/IDEA.MD +++ b/IDEA.MD @@ -75,6 +75,8 @@ Telegram-бот, который по запросу пользователя г - [x] Reply-клавиатура (`keyboards.py`): основное меню при `/start`, отдельное меню с кнопкой оплаты при блокировке, кнопки мапятся на команды и запросы, slash-команды публикуются через `set_my_commands` - [x] Исправлен баг: `_send_idea` была потеряна в коммите про подписку — `/idea` падал с `NameError` - [x] Реальная оплата Stars: `sendInvoice` (XTR) → `pre_checkout_query` → `successful_payment`; подписка активируется только после оплаты, `telegram_payment_charge_id` защищает от двойного начисления +- [x] В `DEPLOY_ENV=development` после инвойса доступна кнопка «🧪 Оплатил (dev)» для тестовой активации подписки без оплаты; callback ограничен пользователем инвойса, в production кнопка отсутствует +- Версия разработки: `0.1.0+27` - [x] Справка бота (`/help`) описывает `/balance` и `/subscribe` - [x] Проект Gitea «Social Post Writer Bot Roadmap» с задачами (выполненные — в «Готово», будущие — в Backlog) diff --git a/README.md b/README.md index 453a2dd..bf38c00 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,12 @@ Telegram-бот для генерации постов для соцсетей. ## Как работает бот (UX) +### Dev-подтверждение подписки + +Только при `DEPLOY_ENV=development` после `/subscribe` показывается кнопка «🧪 Оплатил (dev)». +Она включает доступ на 30 дней без проведения реального платежа и доступна только пользователю, +которому выставлен счёт. В production эта кнопка не отображается. + ### Reply-клавиатура Основное меню показывается в `/start`: @@ -78,7 +84,7 @@ apinex_client.py — клиент APInex (LLM, STT, vision), промпты, user_store.py — SQLite: пользователи, ToV, посты, идеи, API-логи, подписки keyboards.py — reply-клавиатура: `MAIN_MENU` (основная) и `LOCKED_MENU` (без подписки), маппинг кнопок на команды help_message.py — Rich Message: `WELCOME_RICH_HTML` (онбординг) + `HELP_SECTIONS`/`help_view()` (справка с кнопками), plain fallback -VERSION — версия релиза (сейчас 0.1.0+26) +VERSION — версия релиза (сейчас 0.1.0+27) ``` ### Основные потоки @@ -204,7 +210,7 @@ SUBSCRIPTION_PRICE_STARS = 100 - **Gitea:** `admin/social-post-writer-bot`, ветка `dev`. Push → автодеплой. - **Coolify:** проект `Social Post Writer Bot`, окружение `development`, приложение `social-post-writer-bot-development` (`d32u5zcq2g4odmdpq9hw91yn`) на `spb-easy-dale`. - **Деплой:** `.hermes/scripts/deploy.sh` (POST `/api/v1/deploy?uuid=...`). -- **VERSION:** файл `VERSION` (`0.1.0+19`). При `DEPLOY_ENV=development` и изменении версии бот шлёт уведомление «🚀 Бот обновлён / Версия: X» в `DEV_CHAT_ID`. +- **VERSION:** файл `VERSION` (`0.1.0+27`). При `DEPLOY_ENV=development` и изменении версии бот шлёт уведомление «🚀 Бот обновлён / Версия: X» в `DEV_CHAT_ID`. - **Переменные Coolify:** `BOT_TOKEN`, `APINEX_API_KEY`, `APINEX_STT_MODEL`, `APINEX_INTENT_CLASSIFIER_MODEL`, `APINEX_POST_WRITER_MODEL`, `APINEX_IMAGE_TONE_MODEL`, `BOT_DB_PATH`, `DEPLOY_ENV`, `DEV_CHAT_ID`. - **Docker:** `python:3.12-slim`, копирует `bot.py`, `apinex_client.py`, `user_store.py`, `help_message.py`, `VERSION`; `CMD ["python", "bot.py"]`. @@ -216,4 +222,4 @@ SUBSCRIPTION_PRICE_STARS = 100 python -m unittest discover -s tests -v ``` -Покрытие: STT, LLM, vision, ToV, reply-поля, изоляция пользователей, API-логи, **reply-клавиатура** (маппинг кнопок, роутинг, `/style`), **платежи** (инвойс, pre_checkout, successful_payment, защита от двойного списания), **справка с кнопками** (меню, разделы, callback-навигация, plain fallback), **онбординг** (welcome → вызов `help_command`), **подписки** (6 тестов: таблица, триал после `/start`, оплата, баланс, блокировка, обнуление через `/clear`). +Покрытие: STT, LLM, vision, ToV, reply-поля, изоляция пользователей, API-логи, **reply-клавиатура** (маппинг кнопок, роутинг, `/style`), **платежи** (инвойс, pre_checkout, successful_payment, защита от двойного начисления, dev-подтверждение оплаты и проверка владельца кнопки), **справка с кнопками** (меню, разделы, callback-навигация, plain fallback), **онбординг** (welcome → вызов `help_command`), **подписки** (триал после `/start`, оплата, баланс, блокировка, обнуление через `/clear`). diff --git a/VERSION b/VERSION index 3d72c9f..cf04071 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+26 +0.1.0+27 diff --git a/bot.py b/bot.py index a4406d8..17cc8dc 100644 --- a/bot.py +++ b/bot.py @@ -12,7 +12,14 @@ from aiogram import Bot, Dispatcher, F, Router from aiogram.client.session.aiohttp import AiohttpSession from aiogram.client.telegram import TEST from aiogram.filters import CommandStart -from aiogram.types import BotCommand, CallbackQuery, InputRichMessage, Message +from aiogram.types import ( + BotCommand, + CallbackQuery, + InlineKeyboardButton, + InlineKeyboardMarkup, + InputRichMessage, + Message, +) import keyboards import payments @@ -162,6 +169,29 @@ async def help_navigation(callback: CallbackQuery) -> None: await callback.answer() +@router.callback_query(F.data.startswith("dev_paid:")) +async def confirm_development_payment(callback: CallbackQuery) -> None: + if DEPLOY_ENV != "development": + await callback.answer("Доступно только в dev-среде.", show_alert=True) + return + try: + user_id = int((callback.data or "").split(":", 1)[1]) + except (IndexError, ValueError): + await callback.answer("Некорректное подтверждение.", show_alert=True) + return + if callback.from_user is None or callback.from_user.id != user_id: + await callback.answer("Эта кнопка предназначена другому пользователю.", show_alert=True) + return + await asyncio.to_thread(ensure_subscription, user_id) + days = await asyncio.to_thread(user_store.activate_subscription, user_id, months=1) + await callback.answer("Dev-доступ активирован на 30 дней.") + if isinstance(callback.message, Message): + await callback.message.edit_reply_markup(reply_markup=None) + await callback.message.answer( + f"🧪 Dev-платёж подтверждён. Подписка включена на {days} дн. Реальная оплата не проводилась." + ) + + @router.message(CommandStart()) async def start(message: Message) -> None: if message.from_user is None: @@ -233,10 +263,22 @@ async def subscribe_command(message: Message) -> None: "или напиши поддержке." ) return - await message.answer( - f"Счёт на {SUBSCRIPTION_PRICE_STARS} звёзд на 30 дней выше.\n\n" - f"Подписка активируется сразу после оплаты — отдельного подтверждения не нужно." - ) + if DEPLOY_ENV == "development": + await message.answer( + f"Счёт на {SUBSCRIPTION_PRICE_STARS} звёзд на 30 дней выше.\n\n" + "Для разработки нажми кнопку ниже: она включит dev-доступ без реального платежа.", + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton( + text="🧪 Оплатил (dev)", + callback_data=f"dev_paid:{message.from_user.id}", + )]] + ), + ) + else: + await message.answer( + f"Счёт на {SUBSCRIPTION_PRICE_STARS} звёзд на 30 дней выше.\n\n" + "Подписка активируется сразу после оплаты — отдельного подтверждения не нужно." + ) async def style_command(message: Message) -> None: diff --git a/tests/test_payments.py b/tests/test_payments.py index 34dc7eb..83c96d7 100644 --- a/tests/test_payments.py +++ b/tests/test_payments.py @@ -106,9 +106,11 @@ class _StubMessage: self.from_user = SimpleNamespace(id=user_id) self.chat = SimpleNamespace(id=chat_id) self.answers: list[str] = [] + self.answer_kwargs: list[dict] = [] async def answer(self, text: str, **kwargs): self.answers.append(text) + self.answer_kwargs.append(kwargs) return self @@ -206,6 +208,43 @@ class SubscribeCommandTests(unittest.IsolatedAsyncioTestCase): invoice.assert_awaited_once_with(message.bot, 555, None) self.assertIn("Счёт", message.answers[0]) + async def test_dev_subscribe_shows_manual_confirmation_button(self): + message = self._message() + with ( + patch.object(bot, "DEPLOY_ENV", "development"), + patch.object(bot.asyncio, "to_thread", new=AsyncMock()), + patch.object(bot, "send_subscription_invoice", new=AsyncMock()), + ): + await bot.subscribe_command(message) + self.assertIn("без реального платежа", message.answers[-1]) + markup = message.answer_kwargs[-1]["reply_markup"] + self.assertEqual(markup.inline_keyboard[0][0].text, "🧪 Оплатил (dev)") + + async def test_dev_confirmation_activates_only_for_invoice_user(self): + callback = AsyncMock() + callback.data = "dev_paid:42" + callback.from_user = SimpleNamespace(id=42) + callback.message = None + with ( + patch.object(bot, "DEPLOY_ENV", "development"), + patch.object(bot.asyncio, "to_thread", new=AsyncMock(return_value=30)) as to_thread, + ): + await bot.confirm_development_payment(callback) + self.assertEqual(to_thread.await_args.args[0], user_store.activate_subscription) + callback.answer.assert_awaited_once_with("Dev-доступ активирован на 30 дней.") + + async def test_dev_confirmation_rejects_other_user(self): + callback = AsyncMock() + callback.data = "dev_paid:42" + callback.from_user = SimpleNamespace(id=99) + 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() + async def test_subscribe_reports_failure_when_invoice_fails(self): message = self._message() with (