From 6dd685a6a5a1ff1740868c33bf8f0e193329cbf9 Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 8 Oct 2026 16:34:24 +0200 Subject: [PATCH] feat: refine mode navigation and subscription renewal UX --- IDEA.MD | 17 ++-- README.md | 40 ++++---- VERSION | 2 +- bot-dialog-flow.md | 41 ++++++++ bot.py | 184 ++++++++++++++++++++++++---------- help_message.py | 6 +- keyboards.py | 59 +++++------ tests/test_keyboards.py | 142 ++++++++++++++++++-------- tests/test_release_help.py | 28 ++++-- tests/test_writer_features.py | 6 ++ 10 files changed, 354 insertions(+), 171 deletions(-) create mode 100644 bot-dialog-flow.md diff --git a/IDEA.MD b/IDEA.MD index 770c0b3..be901bb 100644 --- a/IDEA.MD +++ b/IDEA.MD @@ -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) diff --git a/README.md b/README.md index 96c5de9..4ef0f88 100644 --- a/README.md +++ b/README.md @@ -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`). diff --git a/VERSION b/VERSION index 5894c60..e73944a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+29 +0.1.0+30 diff --git a/bot-dialog-flow.md b/bot-dialog-flow.md new file mode 100644 index 0000000..84d1ab6 --- /dev/null +++ b/bot-dialog-flow.md @@ -0,0 +1,41 @@ +# Логика диалогов бота — режимная навигация + +Пользователь видит четыре режима. Главное меню — внутренняя точка входа/возврата, отдельной кнопки или команды «Лобби» нет. + +```mermaid +flowchart TB + START([/start]) --> W[Одно welcome-сообщение
без автопоказа справки] + W --> MENU[Главное меню
Обучение · Генерация · Подписка · Помощь] + MENU --> SELECT{Выбран режим} + + SELECT --> TRAIN[Обучение
короткий onboarding] + TRAIN --> SCREEN[Пользователь присылает
скриншоты постов] + SCREEN --> TONE[Бот обновляет Tone of Voice] + TONE --> TRAIN + + SELECT --> GEN[Генерация
короткий onboarding] + GEN --> REQUEST[Пользователь просит идею,
пост или правку] + REQUEST --> ACCESS{Есть активный доступ?} + ACCESS -- Да --> RESULT[Бот выдаёт результат] + RESULT --> GEN + ACCESS -- Нет --> SUBS[Режим «Подписка»] + + SELECT --> SUBS + SUBS --> STATUS[Показать активный период
и остаток дней] + STATUS --> RENEW[Inline: «Продлить подписку»] + RENEW --> OFFER[Сообщение с предложением оплаты] + OFFER --> PAY[Inline: «Оплатить»] + PAY --> INVOICE[Stars-инвойс] + INVOICE --> SUCCESS[Оплата подтверждена
successful_payment] + + SELECT --> HELP[Помощь
короткий onboarding и разделы] + HELP --> HELP_NAV[Переход между разделами] + + TRAIN --> BACK[Кнопка «Главное меню»] + GEN --> BACK + SUBS --> BACK + HELP --> BACK + BACK --> MENU + SUCCESS --> MENU + HELP_NAV --> HELP +``` diff --git a/bot.py b/bot.py index 94c01c1..8abe19f 100644 --- a/bot.py +++ b/bot.py @@ -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="Режим: подписка"), diff --git a/help_message.py b/help_message.py index 456eede..3cfc069 100644 --- a/help_message.py +++ b/help_message.py @@ -10,7 +10,7 @@ WELCOME_RICH_HTML = """

Привет! Я — бот для написания
  • «Сделай пост-напоминание о вебинаре в официальном тоне»
  • «Обнови этот вариант, добавь эмодзи» — реплаем на мой текст
  • -

    Работай в одном из режимов: «Лобби», «Обучение», «Генерация», «Подписка» или «Помощь». Выбери режим кнопкой в нижнем меню или slash-командой: /lobby, /training, /generate, /subscription, /help. /start возвращает в лобби.

    +

    Выбери режим в нижнем меню: «Обучение», «Генерация», «Подписка» или «Помощь». Внутри режима кнопка «Главное меню» возвращает сюда. Команда /start тоже открывает главное меню.

    В режиме «Обучение» пришли скриншоты своих постов; в режиме «Генерация» попроси идею или пост.

    """ @@ -46,11 +46,11 @@ HELP_SECTIONS: dict[str, tuple[str, str]] = {
  • /balance — узнать статус: сколько осталось дней триала или подписки.
  • /subscribe — оплатить подписку на 30 дней.
  • -

    Сначала открой режим «Подписка» кнопкой или командой /subscription, затем используй /balance или /subscribe. Когда период закончится, генерация постов и идей станет недоступна. Команды переключения режимов доступны всегда.

    """, +

    При входе в режим «Подписка» бот сразу показывает активный период и сколько дней осталось. Нажми «Продлить подписку», затем «Оплатить», чтобы открыть счёт. Когда период закончится, генерация постов и идей станет недоступна.

    """, ), "commands": ( "⌨️ Команды", - """

    Команды-переключатели доступны в любом режиме: /start и /lobby — лобби; /training — обучение; /generate — генерация; /subscription — подписка; /help — помощь.

    + """

    Команды-переключатели доступны в любом режиме: /start — главное меню; /training — обучение; /generate — генерация; /subscription — подписка; /help — помощь.

    Действия доступны только в своём режиме: /style и /clear — обучение; /post и /idea — генерация; /balance и /subscribe — подписка. /clear очищает пользовательские данные, но не меняет подписку и триал.

    В режиме «Генерация» можно попросить идею обычными словами: «предложи идею для поста», «о чём написать?», «накидай тему».

    """, ), diff --git a/keyboards.py b/keyboards.py index 97faf50..fa394c0 100644 --- a/keyboards.py +++ b/keyboards.py @@ -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} звёзд." diff --git a/tests/test_keyboards.py b/tests/test_keyboards.py index 14a401b..95fdf5b 100644 --- a/tests/test_keyboards.py +++ b/tests/test_keyboards.py @@ -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): diff --git a/tests/test_release_help.py b/tests/test_release_help.py index 2a0ccd3..9d5dcc8 100644 --- a/tests/test_release_help.py +++ b/tests/test_release_help.py @@ -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__": diff --git a/tests/test_writer_features.py b/tests/test_writer_features.py index 72dc139..7649fb8 100644 --- a/tests/test_writer_features.py +++ b/tests/test_writer_features.py @@ -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(