feat: dev-кнопка подтверждения тестовой оплаты
This commit is contained in:
1 parent
d5225e2e95
commit
45b1c5b468
5 files changed
+98
-9
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
@@ -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`).
|
||||
@@ -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:
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in new issue
Block a user