feat: dev-кнопка подтверждения тестовой оплаты

This commit is contained in:
admin committed 2026-10-08 13:32:07 +02:00
1 parent d5225e2e95
commit 45b1c5b468
5 files changed
+98 -9

No files matched your search

+2
View File
@@ -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)
+9 -3
View File
@@ -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`).
+1 -1
View File
@@ -1 +1 @@
0.1.0+26
0.1.0+27
+47 -5
View File
@@ -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:
+39
View File
@@ -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 (