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(