feat: refine mode navigation and subscription renewal UX

This commit is contained in:
admin committed 2026-10-08 16:34:24 +02:00
1 parent 36a17f7639
commit 6dd685a6a5
10 files changed
+354 -171

No files matched your search

+9 -8
View File
@@ -18,13 +18,13 @@ Telegram-бот, который по запросу пользователя г
3. Бот генерирует текст; пользователь может попросить изменить или переписать его.
## Режимы работы
- **Лобби** — точка входа и возврат по `/start` или `/lobby`.
- **Главное меню** — внутреннее состояние после `/start` или выхода из режима; пользователь не выбирает его как отдельный режим.
- **Обучение** — принимает скриншоты, накапливает образцы, показывает стиль (`/style`); `/clear` очищает профиль и историю, не затрагивая подписку или триал.
- **Генерация** — создаёт и редактирует посты, предлагает идеи (`/post`, `/idea`); генерация требует активного доступа.
- **Подписка** — показывает статус и выставляет счёт (`/balance`, `/subscribe`).
- **Подписка** — при входе показывает активный период и остаток дней; inline-кнопки продления и оплаты ведут к Stars-инвойсу (`/balance`, `/subscribe`).
- **Помощь** — справка и навигация по её разделам (`/help`).
Переключатели `/lobby`, `/training`, `/generate`, `/subscription`, `/help` доступны в любом режиме; профильные действия — только в своём режиме.
`/start` показывает ровно одно welcome-сообщение без автоматического вызова помощи. При входе в любой режим показывается короткий onboarding. Главное меню содержит только четыре пользовательских режима; внутри каждого режима клавиатура состоит из кнопки «Главное меню». Команды `/training`, `/generate`, `/subscription`, `/help` доступны из любого режима; профильные действия — только в своём режиме.
## Первая версия
- Принимать запрос обычным сообщением.
@@ -48,7 +48,8 @@ Telegram-бот, который по запросу пользователя г
- Таблица `subscriptions`: `telegram_user_id`, `trial_started_at`, `last_paid_at`, `is_active`, `monthly_days`, `updated_at`.
- `/start` создаёт запись триала (`ensure_subscription`), если её нет.
- `/subscribe` отправляет инвойс на 100 звёзд; подписка продлевается только обработчиком `successful_payment` после подтверждённой оплаты.
- `/balance` показывает статус: триал активен (сколько дней осталось) / подписка активна (сколько дней, когда списание) / период истёк.
- Вход в «Подписка» сразу показывает активный тип периода и сколько дней осталось; inline-кнопка «Продлить подписку» открывает сообщение для покупки.
- `/balance` показывает статус: триал активен (сколько дней осталось) / подписка активна (сколько дней) / период истёк.
- Генерация поста и идеи блокируется, если подписка не активна (`_check_subscription`); переходы между режимами доступны всегда.
- `/balance` и `/subscribe` доступны в режиме «Подписка»; `/clear` доступна только в режиме «Обучение».
- `/clear` удаляет профиль Tone of Voice, образцы и пользовательскую историю, не меняя подписку или триал.
@@ -60,7 +61,7 @@ Telegram-бот, который по запросу пользователя г
- Coolify приложение: `social-post-writer-bot-development` (`d32u5zcq2g4odmdpq9hw91yn`) на `spb-easy-dale`.
- SQLite: `/app/data/social_writer.sqlite3`; persistent volume `d32u5zcq2g4odmdpq9hw91yn-social-post-writer-dev-data` монтируется в `/app/data`.
- Секреты хранятся в переменных окружения приложения Coolify; не сохраняются в репозитории.
- `DEPLOY_ENV` включает dev-поведение только при точном значении `development`; production, пустое и неизвестное значение отключают его. `BOT_ENV` отдельно выбирает Telegram Bot API. `DEV_CHAT_ID` используется для уведомления о версии; версия в файле `VERSION` (сейчас `0.1.0+29`).
- `DEPLOY_ENV` включает dev-поведение только при точном значении `development`; production, пустое и неизвестное значение отключают его. `BOT_ENV` отдельно выбирает Telegram Bot API. `DEV_CHAT_ID` используется для уведомления о версии; версия в файле `VERSION` (сейчас `0.1.0+30`).
- Вики Gitea: страницы `Home`, `User-Guide`, `Developer-Guide`, `Subscription`, `Deployment`.
## Статус
@@ -86,13 +87,13 @@ Telegram-бот, который по запросу пользователя г
- [x] Справка `/help` разбита на разделы с навигацией инлайн-кнопками во всю ширину сообщения: из любого раздела доступна кнопка на любой другой, текущий раздел — `type="disabled"` (aiogram 3.31.0: `send_rich_message` + `edit_message_text(rich_message=…)`)
- [x] Help command uses new Rich Message, with a plain-text fallback.
- [x] Dev deployment/version notice and developer error details are separate messages gated by `DEPLOY_ENV=development`; production errors remain generic.
- [x] Онбординг `/start` возвращает FSM в лобби, отправляет welcome Rich Message и показывает клавиатуру лобби
- [x] Онбординг `/start` отправляет одно welcome Rich Message и показывает главное меню без автопомощи
- [x] Подписка: триал 3 дня, `/subscribe` за 100 звёзд (30 дней), `/balance`, блокировка генерации без активной подписки
- [x] Пять FSM-режимов с reply-клавиатурами и slash-переключателями; профильные действия изолированы по режимам
- [x] FSM: четыре пользовательских режима, скрытое внутреннее состояние главного меню, короткий onboarding и кнопка возврата в каждом режиме
- [x] Исправлен баг: `_send_idea` была потеряна в коммите про подписку — `/idea` падал с `NameError`
- [x] Реальная оплата Stars: `sendInvoice` (XTR) → `pre_checkout_query` → `successful_payment`; подписка активируется только после оплаты, `telegram_payment_charge_id` защищает от двойного начисления
- [x] В `DEPLOY_ENV=development` после обычного сообщения об инвойсе отдельным сообщением доступна кнопка «🧪 Оплатил (dev)»; callback ограничен пользователем инвойса, а все dev-ветви fail-closed выключены вне development
- Версия разработки: `0.1.0+29`
- Версия разработки: `0.1.0+30`
- [x] Справка бота (`/help`) описывает `/balance` и `/subscribe`
- [x] Проект Gitea «Social Post Writer Bot Roadmap» с задачами (выполненные — в «Готово», будущие — в Backlog)
+20 -20
View File
@@ -33,28 +33,28 @@ Telegram-бот для генерации постов для соцсетей.
### Reply-клавиатура
В нижнем меню всегда доступны переключатели режимов:
После `/start` отображается одно welcome-сообщение с нижним меню; справка автоматически не вызывается.
В главном меню доступны только пользовательские режимы:
```
🏠 Лобби 🎓 Обучение
✍️ Генерация 💳 Подписка
📖 Помощь
🎓 Обучение ✍️ Генерация
💳 Подписка 📖 Помощь
```
У каждого режима своя клавиатура с профильными действиями: обучение — «Мой стиль» и «Сбросить данные»;
генерация — «Написать пост» и «Идея»; подписка — статус и оплата. Кнопки действий идут через
тот же роутинг, что и slash-команды. Команды действий работают только в своём режиме; переключатели
режимов доступны из любого режима.
«Главное меню» — внутренняя точка входа после `/start` и выхода из режима, а не отдельный режим.
Внутри любого режима reply-клавиатура содержит только «🏠 Главное меню». При входе показывается короткий
onboarding. В «Подписке» сразу отображаются тип активного периода и оставшиеся дни; inline-кнопка
«Продлить подписку» открывает предложение, а кнопка оплаты создаёт Stars-инвойс.
Команды перехода: `/lobby`, `/training`, `/generate`, `/subscription`, `/help`; `/start` возвращает
в лобби. Команды `/style` и `/clear` доступны только в обучении; `/post` и `/idea` — в генерации;
Команды перехода: `/start`, `/training`, `/generate`, `/subscription`, `/help`; `/start` возвращает
в главное меню. Команды `/style` и `/clear` доступны только в обучении; `/post` и `/idea` — в генерации;
`/balance` и `/subscribe` — в подписке. Все команды публикуются через `set_my_commands` при старте.
### Онбординг (`/start`)
`/start` создаёт запись триала, очищает старое состояние FSM и возвращает пользователя в лобби:
`/start` создаёт запись триала, очищает старое состояние FSM и возвращает пользователя в главное меню:
1. Отправляет `WELCOME_RICH_HTML` с примерами и описанием режимов.
2. Показывает клавиатуру лобби; справка открывается отдельно кнопкой или командой `/help`.
1. Отправляет ровно одно `WELCOME_RICH_HTML` с примерами и описанием режимов.
2. Показывает клавиатуру главного меню; справка открывается отдельно кнопкой или командой `/help`.
### Основные сценарии
@@ -65,7 +65,7 @@ Telegram-бот для генерации постов для соцсетей.
| Придумать идею | В режиме «Генерация» пишет «предложи тему» или `/idea` | Одна идея одним сообщением |
| Настроить стиль | В режиме «Обучение» присылает скриншоты постов | Бот распознаёт тексты, накапливает их и обновляет ToV |
| Голосом | В режиме «Генерация» отправляет голосовое | STT расшифровывает → обычный текстовый запрос |
| Узнать статус / оплатить | В режиме «Подписка» выбирает действие | Статус периода или счёт на 100 звёзд |
| Узнать статус / продлить | Входит в режим «Подписка», затем нажимает «Продлить подписку» и «Оплатить» | Текущий тип периода, остаток дней и Stars-инвойс на 100 звёзд |
| Очистить данные | В режиме «Обучение» вызывает `/clear` | Очищает стиль и историю, не меняя подписку или триал |
### Индикаторы и обратная связь
@@ -85,7 +85,7 @@ bot.py — роутинг, команды, обработка со
payments.py — оплата Stars: sendInvoice, pre_checkout_query, successful_payment, защита от двойного списания
apinex_client.py — клиент APInex (LLM, STT, vision), промпты, логирование вызовов
user_store.py — SQLite: пользователи, ToV, посты, идеи, API-логи, подписки
keyboards.py — reply-клавиатуры пяти режимов, маппинг кнопок на команды
keyboards.py — клавиатуры главного меню и режимов; маппинг кнопок на команды
help_message.py — Rich Message: `WELCOME_RICH_HTML` (онбординг) + `HELP_SECTIONS`/`help_view()` (справка с кнопками), plain fallback
VERSION — версия релиза
```
@@ -123,11 +123,11 @@ update_tone_from_image → _handle_image_message
| Функция | Что делает |
|---|---|
| `start(message, state)` | Возврат в лобби, welcome, клавиатура и создание триала |
| `start(message, state)` | Одно welcome-сообщение, главное меню и создание триала |
| `handle_command_text` | Роутинг глобальных переключателей и профильных команд по режиму |
| `_classify_and_respond` | Ядро: проверка подписки → классификация → генерация |
| `_check_subscription` | Проверяет активную подписку; если нет — показывает статус и блокирует |
| `_show_subscription_info` | Формирует сообщение о статусе (триал / подписка / истёк) |
| `_show_subscription_info` | Показывает тип периода, остаток дней и inline-кнопку продления в режиме подписки |
| `balance_command` | `/balance` — статус подписки |
| `subscribe_command` | `/subscribe` — отправляет Stars-инвойс на 30 дней (подписка активируется только после оплаты) |
| `handle_pre_checkout` | Подтверждает только свой инвойс в XTR на 100 звёзд, всё остальное отклоняет |
@@ -198,7 +198,7 @@ SUBSCRIPTION_PRICE_STARS = 100
| Команда | Что делает | Режим |
|---|---|---|
| `/start`, `/lobby` | Возврат в лобби | Любой |
| `/start` | Возврат в главное меню | Любой |
| `/training`, `/generate`, `/subscription`, `/help` | Переключение режима | Любой |
| `/style`, `/clear` | Профиль стиля; сброс данных обучения | Обучение |
| `/post`, `/idea` | Создать пост или идею | Генерация; для генерации нужна подписка |
@@ -214,7 +214,7 @@ SUBSCRIPTION_PRICE_STARS = 100
- **Coolify:** проект `Social Post Writer Bot`, окружение `development`, приложение `social-post-writer-bot-development` (`d32u5zcq2g4odmdpq9hw91yn`) на `spb-easy-dale`.
- **Деплой:** `.hermes/scripts/deploy.sh` (POST `/api/v1/deploy?uuid=...`).
- **Окружение:** `DEPLOY_ENV` включает dev-функции только при точном значении `development`; production, пустое и неизвестное значение отключают их. `BOT_ENV` отдельно выбирает Telegram Bot API и не используется для включения dev-поведения.
- **VERSION:** файл `VERSION` (`0.1.0+29`). При `DEPLOY_ENV=development` и изменении версии бот шлёт уведомление о версии в `DEV_CHAT_ID`.
- **VERSION:** файл `VERSION` (`0.1.0+30`). При `DEPLOY_ENV=development` и изменении версии бот шлёт уведомление о версии в `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"]`.
@@ -226,4 +226,4 @@ SUBSCRIPTION_PRICE_STARS = 100
python -m unittest discover -s tests -v
```
Покрытие: STT, LLM, vision, ToV, reply-поля, изоляция пользователей, API-логи, **режимные клавиатуры и роутинг** (переключатели, изоляция действий), **платежи** (Stars, защита от повторов, dev-подтверждение), **ошибки**, **справка**, **онбординг в лобби**, **подписки** (триал, оплата, баланс, блокировка, сохранение подписки при `/clear`).
Покрытие: STT, LLM, vision, ToV, reply-поля, изоляция пользователей, API-логи, **режимные клавиатуры и роутинг** (переключатели, возврат в главное меню), **платежи** (Stars, защита от повторов, dev-подтверждение), **ошибки**, **справка**, **onboarding**, **подписки** (статус при входе, продление, оплата, баланс, блокировка, сохранение подписки при `/clear`).
+1 -1
View File
@@ -1 +1 @@
0.1.0+29
0.1.0+30
+41
View File
@@ -0,0 +1,41 @@
# Логика диалогов бота — режимная навигация
Пользователь видит четыре режима. Главное меню — внутренняя точка входа/возврата, отдельной кнопки или команды «Лобби» нет.
```mermaid
flowchart TB
START([/start]) --> W[Одно welcome-сообщение<br/>без автопоказа справки]
W --> MENU[Главное меню<br/>Обучение · Генерация · Подписка · Помощь]
MENU --> SELECT{Выбран режим}
SELECT --> TRAIN[Обучение<br/>короткий onboarding]
TRAIN --> SCREEN[Пользователь присылает<br/>скриншоты постов]
SCREEN --> TONE[Бот обновляет Tone of Voice]
TONE --> TRAIN
SELECT --> GEN[Генерация<br/>короткий onboarding]
GEN --> REQUEST[Пользователь просит идею,<br/>пост или правку]
REQUEST --> ACCESS{Есть активный доступ?}
ACCESS -- Да --> RESULT[Бот выдаёт результат]
RESULT --> GEN
ACCESS -- Нет --> SUBS[Режим «Подписка»]
SELECT --> SUBS
SUBS --> STATUS[Показать активный период<br/>и остаток дней]
STATUS --> RENEW[Inline: «Продлить подписку»]
RENEW --> OFFER[Сообщение с предложением оплаты]
OFFER --> PAY[Inline: «Оплатить»]
PAY --> INVOICE[Stars-инвойс]
INVOICE --> SUCCESS[Оплата подтверждена<br/>successful_payment]
SELECT --> HELP[Помощь<br/>короткий onboarding и разделы]
HELP --> HELP_NAV[Переход между разделами]
TRAIN --> BACK[Кнопка «Главное меню»]
GEN --> BACK
SUBS --> BACK
HELP --> BACK
BACK --> MENU
SUCCESS --> MENU
HELP_NAV --> HELP
```
+129 -55
View File
@@ -22,6 +22,7 @@ from aiogram.types import (
InlineKeyboardMarkup,
InputRichMessage,
Message,
ReplyKeyboardMarkup,
)
import payments
@@ -29,7 +30,7 @@ import user_store
from apinex_client import APINEX_STT_URL, POST_HISTORY_CHAR_LIMIT, POST_HISTORY_LIMIT, classify_intent, create_tone_profile, extract_posts_from_images, extract_transcript, generate_ideas, generate_posts, transcribe_audio
from help_message import WELCOME_RICH_HTML, help_plain_view, help_view, rich_to_plain
from keyboards import mode_keyboard, resolve_button_text
from keyboards import MAIN_MENU_VALUE, main_menu_keyboard, mode_keyboard, resolve_button_text
from payments import send_subscription_invoice
from user_store import (
add_post_samples,
@@ -84,17 +85,16 @@ MODE_STATES: dict[str, State] = {
}
MODE_BY_STATE = {value.state: key for key, value in MODE_STATES.items()}
MODE_COMMANDS = {
"/lobby": "lobby",
"/training": "training",
"/generate": "generation",
"/subscription": "subscription",
"/help": "help",
}
MODE_COPY = {
"lobby": ("Лобби", "Выбери режим работы в меню ниже."),
"lobby": ("Главное меню", "Выбери режим работы в меню ниже."),
"training": ("Обучение", "Пришли скриншоты своих постов, чтобы настроить стиль."),
"generation": ("Генерация", "Опиши тему поста, попроси идею или ответь на мой вариант с правкой."),
"subscription": ("Подписка", "Здесь можно проверить статус или оплатить доступ."),
"subscription": ("Подписка", "Ниже показаны текущий статус и остаток дней; кнопка откроет продление."),
"help": ("Помощь", "Выбери нужный раздел справки в сообщении выше."),
}
@@ -103,6 +103,10 @@ async def current_mode(state: FSMContext) -> str:
return MODE_BY_STATE.get(await state.get_state(), "lobby")
def keyboard_for_mode(mode: str) -> ReplyKeyboardMarkup:
return main_menu_keyboard() if mode == "lobby" else mode_keyboard(mode)
async def enter_mode(message: Message, state: FSMContext, mode: str) -> None:
await state.clear()
await state.set_state(MODE_STATES[mode])
@@ -113,6 +117,15 @@ async def enter_mode(message: Message, state: FSMContext, mode: str) -> None:
f"Режим «{title}». {description}",
reply_markup=mode_keyboard(mode),
)
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)
async def show_main_menu(message: Message, state: FSMContext) -> None:
await state.clear()
await state.set_state(BotMode.lobby)
await message.answer("Выбери режим работы.", reply_markup=main_menu_keyboard())
async def require_mode(message: Message, state: FSMContext, mode: str) -> bool:
@@ -122,7 +135,7 @@ async def require_mode(message: Message, state: FSMContext, mode: str) -> bool:
command = next(command for command, target in MODE_COMMANDS.items() if target == mode)
await message.answer(
f"Эта команда работает только в режиме «{title}». Переключись: {command}.",
reply_markup=mode_keyboard(await current_mode(state)),
reply_markup=keyboard_for_mode(await current_mode(state)),
)
return False
@@ -191,20 +204,29 @@ async def notify_development_deploy(bot: Bot) -> None:
logger.info("Development deploy notice sent: version=%s", VERSION)
async def send_rich_message(message: Message, rich_html: str, stage: str) -> Message | None:
async def send_rich_message(
message: Message,
rich_html: str,
stage: str,
reply_markup=None,
) -> Message | None:
bot = message.bot
try:
sent = await bot.send_rich_message(
chat_id=message.chat.id,
rich_message=InputRichMessage(html=rich_html),
message_thread_id=message.message_thread_id,
reply_markup=reply_markup,
)
return sent
except Exception as exc:
logger.warning("Rich %s send failed (%s): %s", stage, type(exc).__name__, str(exc)[:180])
if is_development_environment():
await report_error(message, f"отправка Rich Message: {stage}", exc)
await message.answer(help_plain_view(None) if stage == "помощи" else rich_to_plain(rich_html))
await message.answer(
help_plain_view(None) if stage == "помощи" else rich_to_plain(rich_html),
reply_markup=reply_markup,
)
return None
@@ -271,56 +293,69 @@ async def start(message: Message, state: FSMContext) -> None:
await state.set_state(BotMode.lobby)
await notify_development_deploy(message.bot)
await asyncio.to_thread(ensure_subscription, message.from_user.id)
await message.answer(
"В нижнем меню выбери режим: обучение, генерация постов, подписка или помощь."
await send_rich_message(
message,
WELCOME_RICH_HTML,
"приветствия",
reply_markup=main_menu_keyboard(),
)
await send_rich_message(message, WELCOME_RICH_HTML, "приветствия")
await message.answer("Ты в лобби. Выбери режим работы.", reply_markup=mode_keyboard("lobby"))
async def _show_subscription_info(
message: Message, sub: sqlite3.Row | None, mode: str = "subscription"
) -> None:
if not sub:
payment_hint = (
"Переключись в режим «Подписка» командой /subscription, затем используй /subscribe."
if mode != "subscription"
else "Начни с команды /subscribe."
text = (
f"Подписка не активирована. Оформи её за {SUBSCRIPTION_PRICE_STARS} звёзд "
"за 30 дней, чтобы пользоваться генерацией."
)
await message.answer(
f"У тебя нет активной подписки или триала. {payment_hint}",
reply_markup=mode_keyboard(mode),
)
return
days_left = user_store._days_until(sub)
if days_left >= 0:
if sub["last_paid_at"]:
await message.answer(
f"Подписка активна — осталось {days_left} дн.\n"
f"Следующее списание через {days_left} дн."
)
else:
await message.answer(
f"Триал активен — осталось {days_left} дн.\n"
f"Когда триал закончится, нужно оплатить подписку — команда /subscribe."
)
else:
payment_hint = (
"Переключись в режим «Подписка» командой /subscription, затем используй /subscribe."
if mode != "subscription"
else "Оплати /subscribe, чтобы продолжить."
)
await message.answer(
f"Твой период (триал или подписка) истёк. {payment_hint} "
f"Стоимость — {SUBSCRIPTION_PRICE_STARS} звёзд в месяц.",
reply_markup=mode_keyboard(mode),
)
if sub["last_paid_at"] is None:
await message.answer(
f"Чтобы активировать — используй /subscribe в режиме «Подписка» "
f"и пришли {SUBSCRIPTION_PRICE_STARS} звёзд."
days_left = user_store._days_until(sub)
if sub["last_paid_at"]:
if days_left >= 0:
text = f"Активирована платная подписка. Осталось {days_left} дн."
else:
text = (
f"Срок платной подписки истёк. Продление — "
f"{SUBSCRIPTION_PRICE_STARS} звёзд за 30 дней."
)
elif days_left >= 0:
text = f"Активирован бесплатный пробный период. Осталось {days_left} дн."
else:
text = (
f"Бесплатный пробный период истёк. Подписка — "
f"{SUBSCRIPTION_PRICE_STARS} звёзд за 30 дней."
)
if mode == "subscription":
text += f"\nПродление на 30 дней — {SUBSCRIPTION_PRICE_STARS} ⭐."
markup = subscription_renew_keyboard()
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",
)
]]
)
def subscription_purchase_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[[
InlineKeyboardButton(
text=f"Оплатить {SUBSCRIPTION_PRICE_STARS} ⭐",
callback_data="subscription:buy",
)
]]
)
async def balance_command(message: Message, state: FSMContext) -> None:
if not await require_mode(message, state, "subscription"):
@@ -338,7 +373,11 @@ async def subscribe_command(message: Message, state: FSMContext) -> None:
if message.from_user is None:
await message.answer("Не удалось определить пользователя.")
return
await asyncio.to_thread(ensure_subscription, message.from_user.id)
await _send_subscription_invoice(message, message.from_user.id)
async def _send_subscription_invoice(message: Message, user_id: int) -> None:
await asyncio.to_thread(ensure_subscription, user_id)
try:
await send_subscription_invoice(
message.bot, message.chat.id, message.message_thread_id
@@ -361,7 +400,7 @@ async def subscribe_command(message: Message, state: FSMContext) -> None:
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[[InlineKeyboardButton(
text="🧪 Оплатил (dev)",
callback_data=f"dev_paid:{message.from_user.id}",
callback_data=f"dev_paid:{user_id}",
)]]
),
)
@@ -371,6 +410,36 @@ async def subscribe_command(message: Message, state: FSMContext) -> None:
)
@router.callback_query(F.data == "subscription:renew")
async def subscription_renew(callback: CallbackQuery, state: FSMContext) -> None:
if await current_mode(state) != "subscription":
await callback.answer("Открой режим «Подписка» из главного меню.", show_alert=True)
return
message = callback.message
if not isinstance(message, Message):
await callback.answer("Открой режим «Подписка» ещё раз.", show_alert=True)
return
await callback.answer()
await message.answer(
f"Продление подписки на 30 дней — {SUBSCRIPTION_PRICE_STARS} ⭐. "
"Нажми кнопку, чтобы открыть счёт.",
reply_markup=subscription_purchase_keyboard(),
)
@router.callback_query(F.data == "subscription:buy")
async def subscription_buy(callback: CallbackQuery, state: FSMContext) -> None:
if await current_mode(state) != "subscription":
await callback.answer("Открой режим «Подписка» из главного меню.", show_alert=True)
return
message = callback.message
if not isinstance(message, Message) or callback.from_user is None:
await callback.answer("Не удалось открыть оплату. Попробуй ещё раз.", show_alert=True)
return
await callback.answer()
await _send_subscription_invoice(message, callback.from_user.id)
async def style_command(message: Message, state: FSMContext) -> None:
if not await require_mode(message, state, "training"):
return
@@ -715,6 +784,9 @@ async def handle_text(message: Message, state: FSMContext) -> None:
if not text:
return
button = resolve_button_text(text)
if button == MAIN_MENU_VALUE:
await show_main_menu(message, state)
return
if button and button.startswith("/"):
await handle_command_text(message, state)
return
@@ -723,11 +795,14 @@ async def handle_text(message: Message, state: FSMContext) -> None:
if mode != "generation":
prompts = {
"lobby": "Выбери режим работы кнопкой ниже.",
"training": "В режиме «Обучение» пришли скриншоты постов или выбери действие в меню.",
"subscription": "В режиме «Подписка» выбери статус или оплату в меню.",
"training": "Пришли скриншоты своих постов или используй /style, чтобы посмотреть стиль.",
"subscription": "Статус уже показан выше. Для продления нажми кнопку «Продлить подписку».",
"help": "Открой нужный раздел справки выше или выбери другой режим.",
}
await message.answer(prompts.get(mode, "Выбери режим работы."), reply_markup=mode_keyboard(mode))
await message.answer(
prompts.get(mode, "Выбери режим работы."),
reply_markup=keyboard_for_mode(mode),
)
return
if button:
text = button
@@ -742,12 +817,12 @@ async def handle_other_message(message: Message, state: FSMContext) -> None:
elif mode == "generation":
text = "Для генерации отправь текстовый запрос или голосовое сообщение."
elif mode == "subscription":
text = "В режиме «Подписка» выбери действие в меню."
text = "Статус уже показан выше. Для продления нажми «Продлить подписку»."
elif mode == "help":
text = "Выбери раздел справки или другой режим в меню."
else:
text = "Выбери режим работы кнопкой ниже."
await message.answer(text, reply_markup=mode_keyboard(mode))
await message.answer(text, reply_markup=keyboard_for_mode(mode))
async def set_main_menu(bot: Bot) -> None:
@@ -755,8 +830,7 @@ async def set_main_menu(bot: Bot) -> None:
try:
await bot.set_my_commands(
commands=[
BotCommand(command="start", description="Вернуться в лобби"),
BotCommand(command="lobby", description="Режим: лобби"),
BotCommand(command="start", description="Открыть главное меню"),
BotCommand(command="training", description="Режим: обучение"),
BotCommand(command="generate", description="Режим: генерация"),
BotCommand(command="subscription", description="Режим: подписка"),
+3 -3
View File
@@ -10,7 +10,7 @@ WELCOME_RICH_HTML = """<h1>Привет! Я — бот для написания
<li>«Сделай пост-напоминание о вебинаре в официальном тоне»</li>
<li>«Обнови этот вариант, добавь эмодзи» — реплаем на мой текст</li>
</ul>
<p>Работай в одном из режимов: «Лобби», «Обучение», «Генерация», «Подписка» или «Помощь». Выбери режим кнопкой в нижнем меню или slash-командой: /lobby, /training, /generate, /subscription, /help. /start возвращает в лобби.</p>
<p>Выбери режим в нижнем меню: «Обучение», «Генерация», «Подписка» или «Помощь». Внутри режима кнопка «Главное меню» возвращает сюда. Команда /start тоже открывает главное меню.</p>
<p>В режиме «Обучение» пришли скриншоты своих постов; в режиме «Генерация» попроси идею или пост.</p>
"""
@@ -46,11 +46,11 @@ HELP_SECTIONS: dict[str, tuple[str, str]] = {
<li><code>/balance</code> — узнать статус: сколько осталось дней триала или подписки.</li>
<li><code>/subscribe</code> — оплатить подписку на 30 дней.</li>
</ul>
<p>Сначала открой режим «Подписка» кнопкой или командой <code>/subscription</code>, затем используй <code>/balance</code> или <code>/subscribe</code>. Когда период закончится, генерация постов и идей станет недоступна. Команды переключения режимов доступны всегда.</p>""",
<p>При входе в режим «Подписка» бот сразу показывает активный период и сколько дней осталось. Нажми «Продлить подписку», затем «Оплатить», чтобы открыть счёт. Когда период закончится, генерация постов и идей станет недоступна.</p>""",
),
"commands": (
"⌨️ Команды",
"""<p>Команды-переключатели доступны в любом режиме: <code>/start</code> и <code>/lobby</code> — лобби; <code>/training</code> — обучение; <code>/generate</code> — генерация; <code>/subscription</code> — подписка; <code>/help</code> — помощь.</p>
"""<p>Команды-переключатели доступны в любом режиме: <code>/start</code> — главное меню; <code>/training</code> — обучение; <code>/generate</code> — генерация; <code>/subscription</code> — подписка; <code>/help</code> — помощь.</p>
<p>Действия доступны только в своём режиме: <code>/style</code> и <code>/clear</code> — обучение; <code>/post</code> и <code>/idea</code> — генерация; <code>/balance</code> и <code>/subscribe</code> — подписка. <code>/clear</code> очищает пользовательские данные, но не меняет подписку и триал.</p>
<p>В режиме «Генерация» можно попросить идею обычными словами: «предложи идею для поста», «о чём написать?», «накидай тему».</p>""",
),
+23 -36
View File
@@ -1,4 +1,4 @@
"""Reply-клавиатуры пяти режимов бота."""
"""Клавиатуры главного меню и пользовательских режимов бота."""
from __future__ import annotations
from aiogram.types import (
@@ -9,56 +9,43 @@ from aiogram.types import (
from user_store import SUBSCRIPTION_PRICE_STARS
MODE_BUTTONS: dict[str, str] = {
"🏠 Лобби": "/lobby",
"🎓 Обучение": "/training",
"✍️ Генерация": "/generate",
"💳 Подписка": "/subscription",
"📖 Помощь": "/help",
}
MODE_ROWS = [
["🏠 Лобби", "🎓 Обучение"],
["✍️ Генерация", "💳 Подписка"],
["📖 Помощь"],
]
MODE_ACTIONS: dict[str, list[list[str]]] = {
"lobby": [],
"training": [["🎨 Мой стиль", "🗑 Сбросить данные"]],
"generation": [["✍️ Написать пост", "💡 Идея"]],
"subscription": [["👤 Статус подписки", "⭐ Оплатить подписку"]],
"help": [],
}
MAIN_MENU_BUTTON = "🏠 Главное меню"
MAIN_MENU_VALUE = "__main_menu__"
BUTTON_TEXT_MAP: dict[str, str] = {
**MODE_BUTTONS,
"🎨 Мой стиль": "/style",
"🗑 Сбросить данные": "/clear",
"✍️ Написать пост": "/post",
"💡 Идея": "/idea",
"👤 Статус подписки": "/balance",
"⭐ Оплатить подписку": "/subscribe",
MAIN_MENU_BUTTON: MAIN_MENU_VALUE,
"Главное меню": MAIN_MENU_VALUE,
}
def mode_keyboard(mode: str) -> ReplyKeyboardMarkup:
"""Создаёт клавиатуру режимных переходов и действий текущего режима."""
rows = [*MODE_ROWS, *MODE_ACTIONS.get(mode, [])]
def main_menu_keyboard() -> ReplyKeyboardMarkup:
"""Показывает только четыре доступных пользователю режима."""
return ReplyKeyboardMarkup(
keyboard=[[KeyboardButton(text=text) for text in row] for row in rows],
keyboard=[
[KeyboardButton(text="🎓 Обучение"), KeyboardButton(text="✍️ Генерация")],
[KeyboardButton(text="💳 Подписка"), KeyboardButton(text="📖 Помощь")],
],
resize_keyboard=True,
input_field_placeholder={
"lobby": "Выбери режим",
"training": "Пришли скриншоты постов",
"generation": "Опиши задачу для поста",
"subscription": "Выбери действие с подпиской",
"help": "Выбери раздел справки",
}.get(mode, "Выбери режим"),
input_field_placeholder="Выбери режим",
)
# Совместимые имена для мест, где ещё используется клавиатура главного меню.
MAIN_MENU = mode_keyboard("lobby")
def mode_keyboard(_mode: str) -> ReplyKeyboardMarkup:
"""Внутри любого режима оставляет только выход в главное меню."""
return ReplyKeyboardMarkup(
keyboard=[[KeyboardButton(text=MAIN_MENU_BUTTON)]],
resize_keyboard=True,
input_field_placeholder="Напиши сообщение или вернись в главное меню",
)
MAIN_MENU = main_menu_keyboard()
LOCKED_MENU = mode_keyboard("subscription")
@@ -70,4 +57,4 @@ def resolve_button_text(text: str | None) -> str | None:
def price_hint() -> str:
return f"Подписка {SUBSCRIPTION_PRICE_STARS} звёзд — команда /subscribe в режиме «Подписка»."
return f"Продление подписки — {SUBSCRIPTION_PRICE_STARS} звёзд."
+101 -41
View File
@@ -1,7 +1,10 @@
import unittest
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from aiogram.types import Chat, Message
import bot
import keyboards
@@ -22,23 +25,16 @@ class _StubState:
class ButtonTextTests(unittest.TestCase):
def test_mode_buttons_map_to_switch_commands(self):
self.assertEqual(keyboards.resolve_button_text("🏠 Лобби"), "/lobby")
self.assertEqual(keyboards.resolve_button_text("🎓 Обучение"), "/training")
self.assertEqual(keyboards.resolve_button_text("✍️ Генерация"), "/generate")
self.assertEqual(keyboards.resolve_button_text("💳 Подписка"), "/subscription")
self.assertEqual(keyboards.resolve_button_text("📖 Помощь"), "/help")
def test_mode_actions_map_to_slash_commands(self):
expected = {
"🎨 Мой стиль": "/style",
"🗑 Сбросить данные": "/clear",
"✍️ Написать пост": "/post",
"💡 Идея": "/idea",
"👤 Статус подписки": "/balance",
"⭐ Оплатить подписку": "/subscribe",
}
for label, command in expected.items():
self.assertEqual(keyboards.resolve_button_text(label), command)
def test_main_menu_button_maps_to_internal_transition(self):
self.assertEqual(
keyboards.resolve_button_text(keyboards.MAIN_MENU_BUTTON),
keyboards.MAIN_MENU_VALUE,
)
def test_regular_text_is_not_treated_as_button(self):
self.assertIsNone(keyboards.resolve_button_text("Напиши пост про запуск"))
@@ -50,30 +46,26 @@ class ButtonTextTests(unittest.TestCase):
class KeyboardLayoutTests(unittest.TestCase):
def test_every_mode_keyboard_has_all_mode_switches(self):
expected = set(keyboards.MODE_BUTTONS)
for mode in bot.MODE_STATES:
labels = {b.text for row in keyboards.mode_keyboard(mode).keyboard for b in row}
self.assertTrue(expected.issubset(labels), mode)
def test_main_menu_only_shows_user_facing_modes(self):
labels = {b.text for row in keyboards.main_menu_keyboard().keyboard for b in row}
self.assertEqual(labels, set(keyboards.MODE_BUTTONS))
self.assertNotIn("🏠 Лобби", labels)
def test_actions_are_limited_to_the_current_mode(self):
training = {b.text for row in keyboards.mode_keyboard("training").keyboard for b in row}
generation = {b.text for row in keyboards.mode_keyboard("generation").keyboard for b in row}
subscription = {b.text for row in keyboards.mode_keyboard("subscription").keyboard for b in row}
self.assertIn("🗑 Сбросить данные", training)
self.assertNotIn("🗑 Сбросить данные", generation)
self.assertIn("💡 Идея", generation)
self.assertNotIn("💡 Идея", training)
self.assertIn("⭐ Оплатить подписку", subscription)
self.assertNotIn("⭐ Оплатить подписку", generation)
def test_each_mode_keyboard_has_only_main_menu_button(self):
for mode in bot.MODE_STATES:
labels = [b.text for row in keyboards.mode_keyboard(mode).keyboard for b in row]
self.assertEqual(labels, [keyboards.MAIN_MENU_BUTTON], mode)
def test_every_button_is_resolvable(self):
for mode in bot.MODE_STATES:
for row in keyboards.mode_keyboard(mode).keyboard:
markups = [keyboards.main_menu_keyboard()]
markups.extend(keyboards.mode_keyboard(mode) for mode in bot.MODE_STATES)
for markup in markups:
for row in markup.keyboard:
for button in row:
self.assertIsNotNone(keyboards.resolve_button_text(button.text), button.text)
def test_keyboards_are_resize_keyboard(self):
self.assertTrue(keyboards.main_menu_keyboard().resize_keyboard)
for mode in bot.MODE_STATES:
self.assertTrue(keyboards.mode_keyboard(mode).resize_keyboard)
@@ -132,6 +124,19 @@ class ModeRoutingTests(unittest.IsolatedAsyncioTestCase):
await bot.handle_text(message, state)
self.assertEqual(await state.get_state(), bot.BotMode.training.state)
async def test_main_menu_button_returns_to_mode_selection(self):
message = _StubMessage(keyboards.MAIN_MENU_BUTTON)
state = _StubState("training")
with patch.object(bot, "notify_development_deploy", new=AsyncMock()):
await bot.handle_text(message, state)
self.assertEqual(await state.get_state(), bot.BotMode.lobby.state)
labels = [
button.text
for row in message.answers[0][1]["reply_markup"].keyboard
for button in row
]
self.assertEqual(set(labels), set(keyboards.MODE_BUTTONS))
async def test_slash_switch_command_works_from_another_mode(self):
message = _StubMessage("/training")
state = _StubState("subscription")
@@ -148,24 +153,79 @@ class ModeRoutingTests(unittest.IsolatedAsyncioTestCase):
await bot.handle_text(message, state)
self.assertEqual(await state.get_state(), bot.BotMode.help.state)
async def test_idea_button_generates_only_in_generation_mode(self):
message = _StubMessage("💡 Идея")
state = _StubState("generation")
async def test_subscription_entry_shows_status_and_renew_button(self):
message = _StubMessage("💳 Подписка")
state = _StubState("lobby")
with (
patch.object(bot, "notify_development_deploy", new=AsyncMock()),
patch.object(bot, "_classify_and_respond", new=AsyncMock()) as classify,
patch.object(bot.asyncio, "to_thread", new=AsyncMock(return_value=None)),
):
await bot.handle_text(message, state)
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(mode_keyboard.keyboard[0][0].text, keyboards.MAIN_MENU_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",
)
async def test_renew_callback_opens_purchase_prompt(self):
message_bot = AsyncMock()
message = Message(
message_id=91,
date=datetime.now(),
chat=Chat(id=555, type="private"),
).as_(message_bot)
callback = SimpleNamespace(
message=message,
answer=AsyncMock(),
)
await bot.subscription_renew(callback, _StubState("subscription"))
callback.answer.assert_awaited_once_with()
sent_call = message_bot.await_args
if sent_call is None:
self.fail("purchase prompt was not sent")
sent = sent_call.args[0]
self.assertIn("Продление подписки", sent.text)
self.assertEqual(
sent.reply_markup.inline_keyboard[0][0].callback_data,
"subscription:buy",
)
async def test_buy_callback_sends_invoice_for_clicking_user(self):
message = Message(
message_id=92,
date=datetime.now(),
chat=Chat(id=555, type="private"),
).as_(AsyncMock())
callback = SimpleNamespace(
message=message,
from_user=SimpleNamespace(id=42),
answer=AsyncMock(),
)
with patch.object(bot, "_send_subscription_invoice", new=AsyncMock()) as send_invoice:
await bot.subscription_buy(callback, _StubState("subscription"))
callback.answer.assert_awaited_once_with()
send_invoice.assert_awaited_once_with(message, 42)
async def test_idea_command_generates_only_in_generation_mode(self):
message = _StubMessage("/idea")
state = _StubState("generation")
with patch.object(bot, "_classify_and_respond", new=AsyncMock()) as classify:
await bot.handle_command_text(message, state)
classify.assert_awaited_once_with(message, "предложи идею для нового поста", state=state)
async def test_post_button_generates_only_in_generation_mode(self):
message = _StubMessage("✍️ Написать пост")
async def test_post_command_generates_only_in_generation_mode(self):
message = _StubMessage("/post")
state = _StubState("generation")
with (
patch.object(bot, "notify_development_deploy", new=AsyncMock()),
patch.object(bot, "_classify_and_respond", new=AsyncMock()) as classify,
):
await bot.handle_text(message, state)
with patch.object(bot, "_classify_and_respond", new=AsyncMock()) as classify:
await bot.handle_command_text(message, state)
classify.assert_awaited_once_with(message, "Напиши пост", state=state)
async def test_plain_text_is_only_sent_to_generator_in_generation_mode(self):
@@ -236,7 +296,7 @@ class MenuPublishTests(unittest.IsolatedAsyncioTestCase):
fake_bot = AsyncMock()
await bot.set_main_menu(fake_bot)
names = [c.command for c in fake_bot.set_my_commands.await_args.kwargs["commands"]]
for expected in ("lobby", "training", "generate", "subscription", "help", "clear", "post"):
for expected in ("start", "training", "generate", "subscription", "help", "clear", "post"):
self.assertIn(expected, names)
async def test_publish_failure_does_not_crash_startup(self):
+21 -7
View File
@@ -97,7 +97,6 @@ class HelpRoutingTests(unittest.TestCase):
self.assertEqual(
bot.MODE_COMMANDS,
{
"/lobby": "lobby",
"/training": "training",
"/generate": "generation",
"/subscription": "subscription",
@@ -176,14 +175,14 @@ class HelpNavigationTests(unittest.TestCase):
class OnboardingTests(unittest.IsolatedAsyncioTestCase):
async def test_start_sends_welcome_and_enters_lobby(self):
async def test_start_sends_only_one_welcome_with_main_menu_keyboard(self):
message = AsyncMock()
message.from_user.id = 42
message.message_thread_id = None
calls: list[str] = []
calls = []
async def fake_rich(_message, html, _stage):
calls.append(html)
async def fake_rich(_message, html, _stage, *, reply_markup=None):
calls.append((html, reply_markup))
with (
patch.object(bot, "notify_development_deploy", new=AsyncMock()),
@@ -193,7 +192,20 @@ class OnboardingTests(unittest.IsolatedAsyncioTestCase):
):
state = _StubState("help")
await bot.start(message, state)
self.assertEqual(calls, [bot.WELCOME_RICH_HTML])
self.assertEqual(len(calls), 1)
self.assertEqual(calls[0][0], bot.WELCOME_RICH_HTML)
markup = calls[0][1]
self.assertIsNotNone(markup)
if markup is None:
return
labels = {button.text for row in markup.keyboard for button in row}
expected = {
button.text
for row in bot.main_menu_keyboard().keyboard
for button in row
}
self.assertEqual(labels, expected)
message.answer.assert_not_awaited()
self.assertEqual(await state.get_state(), bot.BotMode.lobby.state)
def _callback(self, data: str, message: Message | None):
@@ -258,8 +270,10 @@ class OnboardingTests(unittest.IsolatedAsyncioTestCase):
from help_message import WELCOME_RICH_HTML, rich_to_plain
text = rich_to_plain(WELCOME_RICH_HTML)
for term in ("Привет", "Напиши пост", "скриншот"):
for term in ("Привет", "Напиши пост", "скриншот", "Главное меню"):
self.assertIn(term, text)
self.assertNotIn("Лобби", text)
self.assertNotIn("/lobby", text)
if __name__ == "__main__":
+6
View File
@@ -580,6 +580,12 @@ class SubscriptionTests(unittest.IsolatedAsyncioTestCase):
await bot.balance_command(message, _StubState("subscription"))
self.assertIn("осталось", message.answer.call_args.args[0].lower())
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",
)
async def test_blocked_when_subscription_expired(self):
message = SimpleNamespace(