From 36a17f76393e9c1e3fb262f361b4d1460eace433 Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 8 Oct 2026 15:56:32 +0200 Subject: [PATCH] feat: add mode-based bot navigation --- IDEA.MD | 24 ++- README.md | 87 +++++------ VERSION | 2 +- bot.py | 275 +++++++++++++++++++++++++--------- help_message.py | 13 +- keyboards.py | 101 +++++++------ tests/test_keyboards.py | 207 +++++++++++++++++++------ tests/test_payments.py | 21 ++- tests/test_release_help.py | 62 ++++++-- tests/test_writer_features.py | 58 +++++-- 10 files changed, 591 insertions(+), 259 deletions(-) diff --git a/IDEA.MD b/IDEA.MD index b16e148..770c0b3 100644 --- a/IDEA.MD +++ b/IDEA.MD @@ -17,6 +17,15 @@ Telegram-бот, который по запросу пользователя г 2. При необходимости задаёт тон, длину и дополнительные требования. 3. Бот генерирует текст; пользователь может попросить изменить или переписать его. +## Режимы работы +- **Лобби** — точка входа и возврат по `/start` или `/lobby`. +- **Обучение** — принимает скриншоты, накапливает образцы, показывает стиль (`/style`); `/clear` очищает профиль и историю, не затрагивая подписку или триал. +- **Генерация** — создаёт и редактирует посты, предлагает идеи (`/post`, `/idea`); генерация требует активного доступа. +- **Подписка** — показывает статус и выставляет счёт (`/balance`, `/subscribe`). +- **Помощь** — справка и навигация по её разделам (`/help`). + +Переключатели `/lobby`, `/training`, `/generate`, `/subscription`, `/help` доступны в любом режиме; профильные действия — только в своём режиме. + ## Первая версия - Принимать запрос обычным сообщением. - Уточнять площадку и стиль, если они не указаны. @@ -40,8 +49,9 @@ Telegram-бот, который по запросу пользователя г - `/start` создаёт запись триала (`ensure_subscription`), если её нет. - `/subscribe` отправляет инвойс на 100 звёзд; подписка продлевается только обработчиком `successful_payment` после подтверждённой оплаты. - `/balance` показывает статус: триал активен (сколько дней осталось) / подписка активна (сколько дней, когда списание) / период истёк. -- Генерация поста и идеи блокируется, если подписка не активна (`_check_subscription`). `/help`, `/start`, `/balance`, `/subscribe` доступны всегда. -- `/clear` обнуляет подписку (`deactivate_subscription`) и данные пользователя. +- Генерация поста и идеи блокируется, если подписка не активна (`_check_subscription`); переходы между режимами доступны всегда. +- `/balance` и `/subscribe` доступны в режиме «Подписка»; `/clear` доступна только в режиме «Обучение». +- `/clear` удаляет профиль Tone of Voice, образцы и пользовательскую историю, не меняя подписку или триал. - Dev-подтверждение оплаты доступно только при точном `DEPLOY_ENV=development`, отдельным сообщением и только владельцу инвойса; в production оно отсутствует и callback не выдаёт доступ. ## Деплой и конфигурация @@ -50,7 +60,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+28`). +- `DEPLOY_ENV` включает dev-поведение только при точном значении `development`; production, пустое и неизвестное значение отключают его. `BOT_ENV` отдельно выбирает Telegram Bot API. `DEV_CHAT_ID` используется для уведомления о версии; версия в файле `VERSION` (сейчас `0.1.0+29`). - Вики Gitea: страницы `Home`, `User-Guide`, `Developer-Guide`, `Subscription`, `Deployment`. ## Статус @@ -69,20 +79,20 @@ Telegram-бот, который по запросу пользователя г - [x] Текст со скриншотов сохраняется отдельно и накапливается по пользователю; новый профиль строится по старым и новым примерам - [x] OCR не переписывает публикации; команды и факты со скриншотов не используются как правила или факты для генерации - [x] APInex JSON-вызовы и ошибки разбора журналируются в SQLite без системного промпта и image base64; срок хранения — 30 дней -- [x] `/clear` полностью очищает данные текущего пользователя: ToV, скриншоты, историю постов, историю идей и настройки; API-логи сохраняются +- [x] `/clear` в режиме «Обучение» очищает данные текущего пользователя: ToV, скриншоты, историю постов и идей; подписка и триал не меняются, API-логи сохраняются - [x] `/idea` возвращает одну идею одним сообщением и сохраняет её для исключения повторов - [x] Все отправленные варианты постов сохраняются по пользователю, включая исправленные; история используется для новых идей - [x] Служебные ответы по-русски, язык текстов постов определяется запросом пользователя - [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`: welcome Rich Message (`WELCOME_RICH_HTML`) → пауза → вызов `help_command`; правки справки автоматически меняют онбординг +- [x] Онбординг `/start` возвращает FSM в лобби, отправляет welcome Rich Message и показывает клавиатуру лобби - [x] Подписка: триал 3 дня, `/subscribe` за 100 звёзд (30 дней), `/balance`, блокировка генерации без активной подписки -- [x] Reply-клавиатура (`keyboards.py`): основное меню при `/start`, отдельное меню с кнопкой оплаты при блокировке, кнопки мапятся на команды и запросы, slash-команды публикуются через `set_my_commands` +- [x] Пять FSM-режимов с reply-клавиатурами и slash-переключателями; профильные действия изолированы по режимам - [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+28` +- Версия разработки: `0.1.0+29` - [x] Справка бота (`/help`) описывает `/balance` и `/subscribe` - [x] Проект Gitea «Social Post Writer Bot Roadmap» с задачами (выполненные — в «Готово», будущие — в Backlog) diff --git a/README.md b/README.md index df7d79e..96c5de9 100644 --- a/README.md +++ b/README.md @@ -33,43 +33,40 @@ Telegram-бот для генерации постов для соцсетей. ### Reply-клавиатура -Основное меню показывается в `/start`: +В нижнем меню всегда доступны переключатели режимов: ``` -✍️ Написать пост 💡 Идея -🎨 Мой стиль 📖 Помощь -💳 Подписка 👤 Баланс +🏠 Лобби 🎓 Обучение +✍️ Генерация 💳 Подписка +📖 Помощь ``` -Если подписки нет, `_show_subscription_info` ставит `LOCKED_MENU` — с кнопкой «💳 Оплатить подписку» -на первом месте. Нажатие кнопки отправляет текст, который `resolve_button_text` превращает -либо в slash-команду (`/help`, `/balance`, `/subscribe`, `/style`), либо в готовый запрос -(«Напиши пост», «предложи идею для нового поста») — то есть кнопки не обходят роутинг, а идут через него. +У каждого режима своя клавиатура с профильными действиями: обучение — «Мой стиль» и «Сбросить данные»; +генерация — «Написать пост» и «Идея»; подписка — статус и оплата. Кнопки действий идут через +тот же роутинг, что и slash-команды. Команды действий работают только в своём режиме; переключатели +режимов доступны из любого режима. -Slash-команды публикуются в Telegram через `set_my_commands` при старте (`set_main_menu`). +Команды перехода: `/lobby`, `/training`, `/generate`, `/subscription`, `/help`; `/start` возвращает +в лобби. Команды `/style` и `/clear` доступны только в обучении; `/post` и `/idea` — в генерации; +`/balance` и `/subscribe` — в подписке. Все команды публикуются через `set_my_commands` при старте. ### Онбординг (`/start`) -Первое сообщение пользователя запускает онбординг — **два сообщения с паузой 0.8 сек** между ними: +`/start` создаёт запись триала, очищает старое состояние FSM и возвращает пользователя в лобби: -1. **Welcome (Rich Message)** — `WELCOME_RICH_HTML`: приветствие, примеры запросов, упоминание скриншотов для Tone of Voice. -2. **Вызов `help_command`** — пауза 0.8 сек, затем меню справки с инлайн-кнопками (тот же хендлер, что и по команде `/help`). - -Одновременно `/start` создаёт запись триала на 3 дня. - -**Логика:** welcome содержит только golden path, всё остальное (тон, голоса, правки, `/clear`, `/idea`) — в `/help`. Онбординг не дублирует текст справки, а **вызывает `help_command`**, поэтому правки `HELP_RICH_HTML` автоматически меняют и онбординг. +1. Отправляет `WELCOME_RICH_HTML` с примерами и описанием режимов. +2. Показывает клавиатуру лобби; справка открывается отдельно кнопкой или командой `/help`. ### Основные сценарии | Что хочет пользователь | Что делает | Что видит | |---|---|---| -| Написать пост | Пишет «напиши пост про запуск продукта» | Индикатор «печатает», затем **3 отдельных сообщения** — по одному варианту в каждом | -| Изменить пост | Отвечает реплаем на вариант бота: «сделай короче» | Снова 3 новых варианта, с учётом исходного текста | -| Придумать идею | «предложи тему» или `/idea` | Одна идея одним сообщением (заголовок, угол, зачем) | -| Настроить стиль | Присылает скриншот(ы) своих постов | Бот распознаёт тексты, накапливает их и обновляет ToV; показывает итоговый профиль стиля | -| Голосом | Отправляет голосовое | STT расшифровывает → как обычный текстовый запрос | -| Узнать статус | `/balance` | Триал активен (сколько дней) / подписка активна / период истёк | -| Оплатить | `/subscribe` | Просит 100 звёзд, подтверждает активацию на 30 дней | -| Начать заново | `/clear` | Полный сброс данных + обнуление подписки | +| Написать пост | В режиме «Генерация» пишет «напиши пост про запуск продукта» | Индикатор «печатает», затем **3 отдельных сообщения** | +| Изменить пост | В режиме «Генерация» отвечает реплаем на вариант бота | Три новых варианта с учётом исходного текста | +| Придумать идею | В режиме «Генерация» пишет «предложи тему» или `/idea` | Одна идея одним сообщением | +| Настроить стиль | В режиме «Обучение» присылает скриншоты постов | Бот распознаёт тексты, накапливает их и обновляет ToV | +| Голосом | В режиме «Генерация» отправляет голосовое | STT расшифровывает → обычный текстовый запрос | +| Узнать статус / оплатить | В режиме «Подписка» выбирает действие | Статус периода или счёт на 100 звёзд | +| Очистить данные | В режиме «Обучение» вызывает `/clear` | Очищает стиль и историю, не меняя подписку или триал | ### Индикаторы и обратная связь - Пока **идёт обработка** (классификация, расшифровка, чтение скриншота, генерация) — бот показывает «печатает» (`typing_status`), обновляя каждые 4 секунды. @@ -77,9 +74,7 @@ Slash-команды публикуются в Telegram через `set_my_comma - Служебные тексты — **всегда на русском**. Язык самого поста определяется языком запроса. ### Блокировка по подписке -Если подписка не активна (триал/подписка истекли или `is_active = 0`): -- Генерация поста и идеи **блокируется**, бот пишет, что период истёк, и предлагает `/subscribe`. -- `/help`, `/start`, `/balance`, `/subscribe` **доступны всегда** — пользователь не остаётся в тупике. +Если подписка не активна (триал/подписка истекли или `is_active = 0`), генерация блокируется. Переключатели режимов доступны всегда; для оплаты пользователь переходит в режим «Подписка». --- @@ -90,23 +85,24 @@ 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-клавиатура: `MAIN_MENU` (основная) и `LOCKED_MENU` (без подписки), маппинг кнопок на команды +keyboards.py — reply-клавиатуры пяти режимов, маппинг кнопок на команды help_message.py — Rich Message: `WELCOME_RICH_HTML` (онбординг) + `HELP_SECTIONS`/`help_view()` (справка с кнопками), plain fallback -VERSION — версия релиза (сейчас 0.1.0+27) +VERSION — версия релиза ``` ### Основные потоки **Текстовый запрос:** ``` -handle_text → _classify_and_respond - ├─ /help? → help_command (без проверки подписки) +handle_text (только в режиме генерации) → _classify_and_respond ├─ _check_subscription (блокировка, если нет подписки) ├─ classify_intent (APInex LLM) → intent │ ├─ help → help_command │ ├─ generate_ideas → generate_ideas → _send_idea │ └─ write_post / revise_post → generate_posts → _send_three_posts └─ typing_status (пока всё идёт) + +handle_command_text → переключатели режимов доступны всегда; действия проходят режимную проверку ``` **Голосовое:** @@ -127,8 +123,8 @@ update_tone_from_image → _handle_image_message | Функция | Что делает | |---|---| -| `start(message)` | Онбординг (welcome Rich Message + вызов `help_command`) + создание триала | -| `handle_command_text` | Роутинг команд: `/help`, `/start`, `/clear`, `/idea`, `/balance`, `/subscribe` | +| `start(message, state)` | Возврат в лобби, welcome, клавиатура и создание триала | +| `handle_command_text` | Роутинг глобальных переключателей и профильных команд по режиму | | `_classify_and_respond` | Ядро: проверка подписки → классификация → генерация | | `_check_subscription` | Проверяет активную подписку; если нет — показывает статус и блокирует | | `_show_subscription_info` | Формирует сообщение о статусе (триал / подписка / истёк) | @@ -136,7 +132,7 @@ update_tone_from_image → _handle_image_message | `subscribe_command` | `/subscribe` — отправляет Stars-инвойс на 30 дней (подписка активируется только после оплаты) | | `handle_pre_checkout` | Подтверждает только свой инвойс в XTR на 100 звёзд, всё остальное отклоняет | | `handle_successful_payment` | Активирует подписку после реальной оплаты; `telegram_payment_charge_id` пишется в `payments` для защиты от повторного начисления | -| `clear_command` | `/clear` — сброс данных + обнуление подписки | +| `clear_command` | `/clear` — сброс пользовательских данных без изменения подписки и триала; только в обучении | | `help_command` | `/help` — меню справки (Rich Message с кнопками) + fallback на plain text | | `help_navigation` | Callback `help:
` → `edit_message_text(rich_message=...)` — переключение разделов | | `edit_rich_message` | Редактирование текущего rich-сообщения на новый раздел | @@ -181,7 +177,7 @@ SUBSCRIPTION_PRICE_STARS = 100 2. Генерация доступна, пока `_days_until > 0`. 3. Триал кончился → `_check_subscription` блокирует, предлагает `/subscribe`. 4. `/subscribe` → `sendInvoice` (XTR, 100 звёзд) → `pre_checkout_query` (проверка суммы/валюты/payload) → `successful_payment` → `activate_subscription(months=1)` → +30 дней. -5. `/clear` → `deactivate_subscription` → `is_active = 0`; нужен `/subscribe`. +5. `/clear` очищает данные обучения и историю; подписка и триал не изменяются. **Оплата реальная:** `payments.py` проводит настоящий платёж Telegram Stars. Подписка активируется **только** в `successful_payment` — сам `/subscribe` её не продлевает. `telegram_payment_charge_id` каждого платежа пишется в таблицу `payments`, поэтому один платёж не может активировать подписку дважды; по этому же id делается возврат через `refundStarPayment`. Платится валюта `XTR`, `provider_token` пустой (цифровой товар). @@ -194,22 +190,21 @@ SUBSCRIPTION_PRICE_STARS = 100 - SQLite: `/app/data/social_writer.sqlite3` (persistent Coolify volume). - **Per-user данные:** ToV, образцы постов со скриншотов, история сгенерированных постов, история идей. История диалогов и исходные изображения **не сохраняются**. - **API-логи:** JSON запросов/ответов APInex + ошибки разбора, 30 дней. Системные промпты не дублируются — только имя и SHA-256. base64 изображений и бинарное аудио **не пишутся**. Содержимое логов — приватные данные пользователя. -- `/clear` удаляет пользовательские данные (ToV, скриншоты, посты, идеи, настройки) и обнуляет подписку; **API-логи сохраняются**. +- `/clear` (только режим «Обучение») удаляет пользовательские данные (ToV, скриншоты, посты, идеи, настройки), не меняя подписку и триал; **API-логи сохраняются**. --- ## Команды -| Команда | Что делает | Доступна без подписки | +| Команда | Что делает | Режим | |---|---|---| -| `/start` | Онбординг (welcome Rich Message + справка) + создание триала | ✅ | -| `/help` | Меню справки: полноширинные кнопки разделов, переход в любой раздел + plain fallback | ✅ | -| `/balance` | Статус подписки / триала | ✅ | -| `/subscribe` | Счёт на оплату: 100 звёзд (XTR) за 30 дней; активация — после оплаты | ✅ | -| `/idea` | Одна идея для поста | ❌ (нужна подписка) | -| `/clear` | Полный сброс данных + обнуление подписки | ✅ | +| `/start`, `/lobby` | Возврат в лобби | Любой | +| `/training`, `/generate`, `/subscription`, `/help` | Переключение режима | Любой | +| `/style`, `/clear` | Профиль стиля; сброс данных обучения | Обучение | +| `/post`, `/idea` | Создать пост или идею | Генерация; для генерации нужна подписка | +| `/balance`, `/subscribe` | Статус или оплата Stars | Подписка | -Русские фразы («придумай идею», «о чём написать?») работают как `/idea`. +В режиме «Генерация» русские фразы («придумай идею», «о чём написать?») работают как `/idea`. --- @@ -219,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+28`). При `DEPLOY_ENV=development` и изменении версии бот шлёт уведомление о версии в `DEV_CHAT_ID`. +- **VERSION:** файл `VERSION` (`0.1.0+29`). При `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"]`. @@ -231,4 +226,4 @@ SUBSCRIPTION_PRICE_STARS = 100 python -m unittest discover -s tests -v ``` -Покрытие: STT, LLM, vision, ToV, reply-поля, изоляция пользователей, API-логи, **reply-клавиатура** (маппинг кнопок, роутинг, `/style`), **платежи** (инвойс, pre_checkout, successful_payment, защита от двойного начисления, dev-подтверждение оплаты, проверка владельца кнопки и fail-closed отключение вне development), **ошибки** (общий ответ в production, отдельная диагностика только в development), **справка с кнопками** (меню, разделы, callback-навигация, plain fallback), **онбординг** (welcome → вызов `help_command`), **подписки** (триал после `/start`, оплата, баланс, блокировка, обнуление через `/clear`). +Покрытие: STT, LLM, vision, ToV, reply-поля, изоляция пользователей, API-логи, **режимные клавиатуры и роутинг** (переключатели, изоляция действий), **платежи** (Stars, защита от повторов, dev-подтверждение), **ошибки**, **справка**, **онбординг в лобби**, **подписки** (триал, оплата, баланс, блокировка, сохранение подписки при `/clear`). diff --git a/VERSION b/VERSION index 19cd880..5894c60 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+28 +0.1.0+29 diff --git a/bot.py b/bot.py index 45875a4..94c01c1 100644 --- a/bot.py +++ b/bot.py @@ -12,6 +12,9 @@ from aiogram import Bot, Dispatcher, F, Router from aiogram.client.session.aiohttp import AiohttpSession from aiogram.client.telegram import TEST from aiogram.filters import CommandStart +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup +from aiogram.fsm.storage.memory import MemoryStorage from aiogram.types import ( BotCommand, CallbackQuery, @@ -21,21 +24,18 @@ from aiogram.types import ( Message, ) -import keyboards import payments 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 LOCKED_MENU, MAIN_MENU, resolve_button_text +from keyboards import mode_keyboard, resolve_button_text from payments import send_subscription_invoice from user_store import ( add_post_samples, clear_user_data, - deactivate_subscription, get_deploy_notice_version, get_idea_history, - get_post_history, get_post_samples, get_recent_post_history_text, get_tone_of_voice, @@ -55,8 +55,6 @@ logger = logging.getLogger(__name__) VERSION = Path(__file__).with_name("VERSION").read_text(encoding="utf-8").strip() BOT_ENV = os.getenv("BOT_ENV", "test").strip().lower() DEPLOY_ENV = os.getenv("DEPLOY_ENV", "production") -DEV_CHAT_ID = os.getenv("DEV_CHAT_ID", "").strip() -HELP_COMMANDS = {"help", "помощь"} _deploy_notice_lock = asyncio.Lock() router = Router() @@ -66,8 +64,67 @@ TYPING_REFRESH_SECONDS = 4 MEDIA_GROUP_COLLECT_SECONDS = 0.8 _media_groups: dict[tuple[int, str], dict[str, object]] = {} _media_groups_lock = asyncio.Lock() -_deploy_notice_sent = False -_deploy_notice_lock = asyncio.Lock() + + + +class BotMode(StatesGroup): + lobby = State() + training = State() + generation = State() + subscription = State() + help = State() + + +MODE_STATES: dict[str, State] = { + "lobby": BotMode.lobby, + "training": BotMode.training, + "generation": BotMode.generation, + "subscription": BotMode.subscription, + "help": BotMode.help, +} +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": ("Лобби", "Выбери режим работы в меню ниже."), + "training": ("Обучение", "Пришли скриншоты своих постов, чтобы настроить стиль."), + "generation": ("Генерация", "Опиши тему поста, попроси идею или ответь на мой вариант с правкой."), + "subscription": ("Подписка", "Здесь можно проверить статус или оплатить доступ."), + "help": ("Помощь", "Выбери нужный раздел справки в сообщении выше."), +} + + +async def current_mode(state: FSMContext) -> str: + return MODE_BY_STATE.get(await state.get_state(), "lobby") + + +async def enter_mode(message: Message, state: FSMContext, mode: str) -> None: + await state.clear() + await state.set_state(MODE_STATES[mode]) + title, description = MODE_COPY[mode] + if mode == "help": + await send_rich_message(message, help_view(None), "помощи") + await message.answer( + f"Режим «{title}». {description}", + reply_markup=mode_keyboard(mode), + ) + + +async def require_mode(message: Message, state: FSMContext, mode: str) -> bool: + if await current_mode(state) == mode: + return True + title = MODE_COPY[mode][0] + 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)), + ) + return False def is_development_environment() -> bool: @@ -168,10 +225,13 @@ async def edit_rich_message(message: Message, rich_html: str) -> bool: @router.callback_query(F.data.startswith("help:")) -async def help_navigation(callback: CallbackQuery) -> None: +async def help_navigation(callback: CallbackQuery, state: FSMContext) -> None: message = callback.message if not isinstance(message, Message): return + if await current_mode(state) != "help": + await callback.answer("Сначала открой режим /help.", show_alert=True) + return section = (callback.data or "help:menu").split(":", 1)[1] if not await edit_rich_message(message, help_view(section)): await callback.answer("Не удалось обновить справку, открой /help заново", show_alert=True) @@ -203,24 +263,33 @@ async def confirm_development_payment(callback: CallbackQuery) -> None: @router.message(CommandStart()) -async def start(message: Message) -> None: +async def start(message: Message, state: FSMContext) -> None: if message.from_user is None: await message.answer("Не удалось определить пользователя.") return + await state.clear() + 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 message.answer( + "В нижнем меню выбери режим: обучение, генерация постов, подписка или помощь." + ) await send_rich_message(message, WELCOME_RICH_HTML, "приветствия") - await asyncio.sleep(0.8) - await help_command(message) - await message.answer("Меню обновлено.", reply_markup=MAIN_MENU) + await message.answer("Ты в лобби. Выбери режим работы.", reply_markup=mode_keyboard("lobby")) -async def _show_subscription_info(message: Message, sub: sqlite3.Row | None) -> None: +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." + ) await message.answer( - "У тебя нет активной подписки или триала. Начни с команды /subscribe.", - reply_markup=LOCKED_MENU, + f"У тебя нет активной подписки или триала. {payment_hint}", + reply_markup=mode_keyboard(mode), ) return days_left = user_store._days_until(sub) @@ -236,18 +305,26 @@ async def _show_subscription_info(message: Message, sub: sqlite3.Row | None) -> f"Когда триал закончится, нужно оплатить подписку — команда /subscribe." ) else: + payment_hint = ( + "Переключись в режим «Подписка» командой /subscription, затем используй /subscribe." + if mode != "subscription" + else "Оплати /subscribe, чтобы продолжить." + ) await message.answer( - "Твой период (триал или подписка) истёк. " - "Оплати /subscribe, чтобы продолжить. Стоимость — 100 звёзд в месяц.", - reply_markup=LOCKED_MENU, + f"Твой период (триал или подписка) истёк. {payment_hint} " + f"Стоимость — {SUBSCRIPTION_PRICE_STARS} звёзд в месяц.", + reply_markup=mode_keyboard(mode), ) if sub["last_paid_at"] is None: await message.answer( - f"Чтобы активировать — пришли {SUBSCRIPTION_PRICE_STARS} звёзд командой /subscribe." + f"Чтобы активировать — используй /subscribe в режиме «Подписка» " + f"и пришли {SUBSCRIPTION_PRICE_STARS} звёзд." ) -async def balance_command(message: Message) -> None: +async def balance_command(message: Message, state: FSMContext) -> None: + if not await require_mode(message, state, "subscription"): + return if message.from_user is None: await message.answer("Не удалось определить пользователя.") return @@ -255,7 +332,9 @@ async def balance_command(message: Message) -> None: await _show_subscription_info(message, sub) -async def subscribe_command(message: Message) -> None: +async def subscribe_command(message: Message, state: FSMContext) -> None: + if not await require_mode(message, state, "subscription"): + return if message.from_user is None: await message.answer("Не удалось определить пользователя.") return @@ -292,7 +371,9 @@ async def subscribe_command(message: Message) -> None: ) -async def style_command(message: Message) -> None: +async def style_command(message: Message, state: FSMContext) -> None: + if not await require_mode(message, state, "training"): + return if message.from_user is None: await message.answer("Не удалось определить пользователя.") return @@ -302,37 +383,42 @@ async def style_command(message: Message) -> None: await message.answer( "Пока не настроен. Пришли скриншоты своих опубликованных постов — " "я распознаю текст, сохраню образцы и построю правила твоего тона.", - reply_markup=MAIN_MENU, + reply_markup=mode_keyboard("training"), ) return await message.answer( f"Твой стиль\n\n{tone or 'Правила пока не сформированы.'}", parse_mode="HTML", - reply_markup=MAIN_MENU, + reply_markup=mode_keyboard("training"), ) if samples: await message.answer(f"Образцов сохранено: {len(samples)}.") -async def clear_command(message: Message) -> None: +async def clear_command(message: Message, state: FSMContext) -> None: + if not await require_mode(message, state, "training"): + return if message.from_user is None: await message.answer("Не удалось определить пользователя.") return await asyncio.to_thread(clear_user_data, message.from_user.id) - deactivate_subscription(message.from_user.id) await message.answer( - "Команда /clear полностью обнулила твои данные: Tone of Voice, тексты со скриншотов, историю постов, историю идей и настройки. " - "Служебные API-логи сохраняются по установленному сроку хранения. Пришли новые скриншоты, чтобы настроить стиль заново." - ) - await asyncio.sleep(0.5) - await message.answer( - "Подписка обнулена. У тебя нет активной подписки и триала — сгенерировать пост нельзя. " - f"Чтобы продолжить, купи подписку за {user_store.SUBSCRIPTION_PRICE_STARS} звёзд командой /subscribe." + "Данные обучения и история очищены: стиль, примеры постов, история публикаций и идей. " + "Подписка и триал не изменены. Служебные API-логи сохраняются. " + "Пришли новые скриншоты, чтобы настроить стиль заново.", + reply_markup=mode_keyboard("training"), ) -async def help_command(message: Message) -> None: +async def help_command(message: Message, state: FSMContext | None = None) -> None: + if state is not None: + await enter_mode(message, state, "help") + return await send_rich_message(message, help_view(None), "помощи") + await message.answer( + "Режим «Помощь». Выбери раздел выше или другой режим в меню.", + reply_markup=mode_keyboard("help"), + ) @@ -341,7 +427,7 @@ async def _check_subscription(message: Message) -> bool: return False if not await asyncio.to_thread(is_subscribed, message.from_user.id): sub = await asyncio.to_thread(subscription_row, message.from_user.id) - await _show_subscription_info(message, sub) + await _show_subscription_info(message, sub, mode="generation") return False return True @@ -385,15 +471,18 @@ async def typing_status(bot: Bot, chat_id: int): pass -async def _classify_and_respond(message: Message, text: str, show_typing: bool = True) -> None: +async def _classify_and_respond( + message: Message, + text: str, + show_typing: bool = True, + state: FSMContext | None = None, +) -> None: if message.from_user is None: await message.answer("Не удалось определить пользователя.") return - # /help всегда доступен, даже без подписки if text.strip().casefold() == "/help": - await help_command(message) + await help_command(message, state) return - # Если подписка не активна — блокируем генерацию if not await _check_subscription(message): return reply_message = message.reply_to_message @@ -404,7 +493,7 @@ async def _classify_and_respond(message: Message, text: str, show_typing: bool = async with (typing_status(message.bot, message.chat.id) if show_typing else nullcontext()): intent = await classify_intent(text, original_post or None, user_id=message.from_user.id) if intent["intent"] == "help": - await help_command(message) + await help_command(message, state) return if intent["intent"] == "generate_ideas": tone = await asyncio.to_thread(get_tone_of_voice, message.from_user.id) @@ -544,11 +633,13 @@ async def _handle_image_message(message: Message, bot: Bot) -> None: @router.message(F.photo | (F.document & F.document.mime_type.startswith("image/"))) -async def update_tone_from_image(message: Message, bot: Bot) -> None: +async def update_tone_from_image(message: Message, bot: Bot, state: FSMContext) -> None: + if not await require_mode(message, state, "training"): + return await _handle_image_message(message, bot) -async def _process_voice(message: Message, bot: Bot) -> None: +async def _process_voice(message: Message, bot: Bot, state: FSMContext) -> None: if not os.environ.get("APINEX_API_KEY", "").strip(): await message.answer("Расшифровка голосовых пока не настроена.") return @@ -572,72 +663,110 @@ async def _process_voice(message: Message, bot: Bot) -> None: if not transcript: await message.answer("Не удалось распознать речь в аудио.") return - await _classify_and_respond(message, transcript, show_typing=False) + await _classify_and_respond(message, transcript, show_typing=False, state=state) @router.message(F.voice | F.audio) -async def transcribe_voice(message: Message, bot: Bot) -> None: +async def transcribe_voice(message: Message, bot: Bot, state: FSMContext) -> None: + if not await require_mode(message, state, "generation"): + return await notify_development_deploy(bot) try: async with typing_status(bot, message.chat.id): - await _process_voice(message, bot) + await _process_voice(message, bot, state) except Exception as exc: await report_error(message, "обработка голосового и генерация", exc) @router.message(F.text & F.text.startswith("/")) -async def handle_command_text(message: Message) -> None: +async def handle_command_text(message: Message, state: FSMContext) -> None: raw = (message.text or "").strip() - # Кнопки reply-клавиатуры отправляют текст, который мапится на команду/запрос. resolved = resolve_button_text(raw) or raw command = resolved.split(maxsplit=1)[0].split("@", 1)[0].casefold() - if command == "/help": - await help_command(message) - elif command == "/start": - await start(message) + + if command == "/start": + await start(message, state) + elif command in MODE_COMMANDS: + await enter_mode(message, state, MODE_COMMANDS[command]) elif command == "/clear": - await clear_command(message) + await clear_command(message, state) elif command == "/style": - await style_command(message) + await style_command(message, state) elif command == "/idea": - await _classify_and_respond(message, "предложи идею для нового поста") + if await require_mode(message, state, "generation"): + await _classify_and_respond( + message, "предложи идею для нового поста", state=state + ) + elif command == "/post": + if await require_mode(message, state, "generation"): + await _classify_and_respond(message, "Напиши пост", state=state) elif command == "/balance": - await balance_command(message) + await balance_command(message, state) elif command == "/subscribe": - await subscribe_command(message) - elif resolved != raw: - # Это была кнопка меню, а не команда — отправляем её как обычный запрос. - await _classify_and_respond(message, resolved) + await subscribe_command(message, state) else: - await message.answer("Неизвестная команда. Используй /help.") + await message.answer("Неизвестная команда. Выбери режим или открой /help.") @router.message(F.text & ~F.text.startswith("/")) -async def handle_text(message: Message) -> None: +async def handle_text(message: Message, state: FSMContext) -> None: await notify_development_deploy(message.bot) text = (message.text or "").strip() if not text: return button = resolve_button_text(text) + if button and button.startswith("/"): + await handle_command_text(message, state) + return + + mode = await current_mode(state) + if mode != "generation": + prompts = { + "lobby": "Выбери режим работы кнопкой ниже.", + "training": "В режиме «Обучение» пришли скриншоты постов или выбери действие в меню.", + "subscription": "В режиме «Подписка» выбери статус или оплату в меню.", + "help": "Открой нужный раздел справки выше или выбери другой режим.", + } + await message.answer(prompts.get(mode, "Выбери режим работы."), reply_markup=mode_keyboard(mode)) + return if button: - if button.startswith("/"): - await handle_command_text(message) - return text = button - await _classify_and_respond(message, text) + await _classify_and_respond(message, text, state=state) + + +@router.message() +async def handle_other_message(message: Message, state: FSMContext) -> None: + mode = await current_mode(state) + if mode == "training": + text = "В режиме «Обучение» пришли скриншоты постов." + elif mode == "generation": + text = "Для генерации отправь текстовый запрос или голосовое сообщение." + elif mode == "subscription": + text = "В режиме «Подписка» выбери действие в меню." + elif mode == "help": + text = "Выбери раздел справки или другой режим в меню." + else: + text = "Выбери режим работы кнопкой ниже." + await message.answer(text, reply_markup=mode_keyboard(mode)) async def set_main_menu(bot: Bot) -> None: - """Публикует reply-клавиатуру для всех пользователей бота.""" + """Публикует команды переключения режимов и действия текущих режимов.""" try: await bot.set_my_commands( commands=[ - BotCommand(command="help", description="Справка по боту"), - BotCommand(command="style", description="Мой Tone of Voice"), - BotCommand(command="idea", description="Идея для поста"), + BotCommand(command="start", description="Вернуться в лобби"), + BotCommand(command="lobby", description="Режим: лобби"), + BotCommand(command="training", description="Режим: обучение"), + BotCommand(command="generate", description="Режим: генерация"), + BotCommand(command="subscription", description="Режим: подписка"), + BotCommand(command="help", description="Режим: помощь"), + BotCommand(command="post", description="Написать пост (генерация)"), + BotCommand(command="style", description="Мой стиль (обучение)"), + BotCommand(command="clear", description="Сбросить данные (обучение)"), + BotCommand(command="idea", description="Идея (генерация)"), BotCommand(command="balance", description="Статус подписки"), BotCommand(command="subscribe", description="Оплатить подписку"), - BotCommand(command="clear", description="Сбросить мои данные"), ] ) except Exception as exc: @@ -655,7 +784,7 @@ async def main() -> None: session = AiohttpSession(api=TEST) if test_env else None bot = Bot(token=token, session=session) await notify_development_deploy(bot) - dp = Dispatcher() + dp = Dispatcher(storage=MemoryStorage()) dp.include_router(payments.router) dp.include_router(router) # Ставим меню один раз при старте, чтобы оно было у всех, кто писал боту. diff --git a/help_message.py b/help_message.py index 3ce06b7..456eede 100644 --- a/help_message.py +++ b/help_message.py @@ -10,8 +10,8 @@ WELCOME_RICH_HTML = """

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

    Также я умею запоминать твой стиль: пришли скриншоты своих постов. Хочешь идею — напиши «предложи тему».

    -

    Ниже — подробная инструкция по работе с ботом.

    +

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

    +

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

    """ HELP_MENU_TITLE = "Помощь по боту" @@ -20,7 +20,7 @@ HELP_SECTIONS: dict[str, tuple[str, str]] = { "posts": ( "✍️ Написать пост", """
      -
    1. Напиши сообщение или отправь голосовое с прямой просьбой, например: «Придумай пост для Telegram про запуск нового продукта».
    2. +
    3. В режиме «Генерация» напиши сообщение или отправь голосовое с прямой просьбой, например: «Придумай пост для Telegram про запуск нового продукта».
    4. Можешь указать площадку, аудиторию, факты, длину, цель и другие требования. Можно надиктовать подробное ТЗ или просто тему.
    5. Я распознаю речь, проверю, просишь ли ты именно написать пост, и верну три варианта отдельными сообщениями.
    @@ -46,12 +46,13 @@ HELP_SECTIONS: dict[str, tuple[str, str]] = {
  • /balance — узнать статус: сколько осталось дней триала или подписки.
  • /subscribe — оплатить подписку на 30 дней.
  • -

    Когда период закончится, генерация постов и идей станет недоступна — бот предложит оформить подписку. Команды /help, /balance, /subscribe, /start и /clear работают всегда.

    """, +

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

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

    Быстрые slash-команды только на английском: /help — эта справка; /start — краткое описание бота; /clear — полностью обнулить твои данные бота, кроме технических API-логов; /idea — предложить одну идею для нового поста; /balance — статус подписки; /subscribe — оплатить подписку на 30 дней.

    -

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

    """, + """

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

    +

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

    +

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

    """, ), } diff --git a/keyboards.py b/keyboards.py index 3fff55d..97faf50 100644 --- a/keyboards.py +++ b/keyboards.py @@ -1,63 +1,66 @@ -"""Reply-клавиатура бота: быстрый доступ к главным действиям. - -Кнопки отправляют те же тексты, что и slash-команды, поэтому роутинг в -`handle_command_text` обрабатывает их без изменений. -""" +"""Reply-клавиатуры пяти режимов бота.""" from __future__ import annotations from aiogram.types import ( KeyboardButton, ReplyKeyboardMarkup, - ReplyKeyboardRemove, ) from user_store import SUBSCRIPTION_PRICE_STARS -# Основное меню. Текст кнопки = текст команды, который обрабатывает handle_command_text. -MAIN_MENU = ReplyKeyboardMarkup( - keyboard=[ - [ - KeyboardButton(text="✍️ Написать пост"), - KeyboardButton(text="💡 Идея"), - ], - [ - KeyboardButton(text="🎨 Мой стиль"), - KeyboardButton(text="📖 Помощь"), - ], - [ - KeyboardButton(text="💳 Подписка"), - KeyboardButton(text="👤 Баланс"), - ], - ], - resize_keyboard=True, - input_field_placeholder="Напиши тему поста или нажми кнопку", -) - -# Меню без подписки: главное — оплатить, а не генерация. -LOCKED_MENU = ReplyKeyboardMarkup( - keyboard=[ - [ - KeyboardButton(text="💳 Оплатить подписку"), - KeyboardButton(text="👤 Баланс"), - ], - [KeyboardButton(text="📖 Помощь")], - ], - resize_keyboard=True, - input_field_placeholder="Нужна подписка для генерации", -) - -# Соответствие подписей кнопок текстам, которые понимает бот. -BUTTON_TEXT_MAP: dict[str, str] = { - "✍️ Написать пост": "Напиши пост", - "💡 Идея": "предложи идею для нового поста", - "🎨 Мой стиль": "/style", +MODE_BUTTONS: dict[str, str] = { + "🏠 Лобби": "/lobby", + "🎓 Обучение": "/training", + "✍️ Генерация": "/generate", + "💳 Подписка": "/subscription", "📖 Помощь": "/help", - "💳 Подписка": "/subscribe", - "👤 Баланс": "/balance", - "💳 Оплатить подписку": "/subscribe", - "🗑 Сбросить всё": "/clear", } +MODE_ROWS = [ + ["🏠 Лобби", "🎓 Обучение"], + ["✍️ Генерация", "💳 Подписка"], + ["📖 Помощь"], +] + +MODE_ACTIONS: dict[str, list[list[str]]] = { + "lobby": [], + "training": [["🎨 Мой стиль", "🗑 Сбросить данные"]], + "generation": [["✍️ Написать пост", "💡 Идея"]], + "subscription": [["👤 Статус подписки", "⭐ Оплатить подписку"]], + "help": [], +} + +BUTTON_TEXT_MAP: dict[str, str] = { + **MODE_BUTTONS, + "🎨 Мой стиль": "/style", + "🗑 Сбросить данные": "/clear", + "✍️ Написать пост": "/post", + "💡 Идея": "/idea", + "👤 Статус подписки": "/balance", + "⭐ Оплатить подписку": "/subscribe", +} + + +def mode_keyboard(mode: str) -> ReplyKeyboardMarkup: + """Создаёт клавиатуру режимных переходов и действий текущего режима.""" + rows = [*MODE_ROWS, *MODE_ACTIONS.get(mode, [])] + return ReplyKeyboardMarkup( + keyboard=[[KeyboardButton(text=text) for text in row] for row in rows], + resize_keyboard=True, + input_field_placeholder={ + "lobby": "Выбери режим", + "training": "Пришли скриншоты постов", + "generation": "Опиши задачу для поста", + "subscription": "Выбери действие с подпиской", + "help": "Выбери раздел справки", + }.get(mode, "Выбери режим"), + ) + + +# Совместимые имена для мест, где ещё используется клавиатура главного меню. +MAIN_MENU = mode_keyboard("lobby") +LOCKED_MENU = mode_keyboard("subscription") + def resolve_button_text(text: str | None) -> str | None: """Возвращает команду или текст запроса для нажатой кнопки, иначе None.""" @@ -67,4 +70,4 @@ def resolve_button_text(text: str | None) -> str | None: def price_hint() -> str: - return f"Подписка {SUBSCRIPTION_PRICE_STARS} звёзд — кнопка «{LOCKED_MENU.keyboard[0][0].text}»." + return f"Подписка {SUBSCRIPTION_PRICE_STARS} звёзд — команда /subscribe в режиме «Подписка»." diff --git a/tests/test_keyboards.py b/tests/test_keyboards.py index 4036a0b..14a401b 100644 --- a/tests/test_keyboards.py +++ b/tests/test_keyboards.py @@ -6,18 +6,39 @@ import bot import keyboards -class ButtonTextTests(unittest.TestCase): - def test_buttons_map_to_commands_and_requests(self): - self.assertEqual(keyboards.resolve_button_text("📖 Помощь"), "/help") - self.assertEqual(keyboards.resolve_button_text("👤 Баланс"), "/balance") - self.assertEqual(keyboards.resolve_button_text("💳 Подписка"), "/subscribe") - self.assertEqual(keyboards.resolve_button_text("🎨 Мой стиль"), "/style") +class _StubState: + def __init__(self, mode="lobby"): + self.value = bot.MODE_STATES[mode].state - def test_idea_button_sends_request_not_command(self): - self.assertEqual( - keyboards.resolve_button_text("💡 Идея"), - "предложи идею для нового поста", - ) + async def get_state(self): + return self.value + + async def set_state(self, state): + self.value = state.state + + async def clear(self): + self.value = None + + +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_regular_text_is_not_treated_as_button(self): self.assertIsNone(keyboards.resolve_button_text("Напиши пост про запуск")) @@ -29,30 +50,35 @@ class ButtonTextTests(unittest.TestCase): class KeyboardLayoutTests(unittest.TestCase): - def test_main_menu_has_core_actions(self): - labels = [b.text for row in keyboards.MAIN_MENU.keyboard for b in row] - for expected in ("✍️ Написать пост", "💡 Идея", "🎨 Мой стиль", "📖 Помощь"): - self.assertIn(expected, labels) + 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_locked_menu_offers_payment_first(self): - self.assertEqual(keyboards.LOCKED_MENU.keyboard[0][0].text, "💳 Оплатить подписку") + 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_every_button_is_resolvable(self): - for markup in (keyboards.MAIN_MENU, keyboards.LOCKED_MENU): - for row in markup.keyboard: + for mode in bot.MODE_STATES: + for row in keyboards.mode_keyboard(mode).keyboard: for button in row: - self.assertIsNotNone( - keyboards.resolve_button_text(button.text), button.text - ) + self.assertIsNotNone(keyboards.resolve_button_text(button.text), button.text) def test_keyboards_are_resize_keyboard(self): - self.assertTrue(keyboards.MAIN_MENU.resize_keyboard) - self.assertTrue(keyboards.LOCKED_MENU.resize_keyboard) + for mode in bot.MODE_STATES: + self.assertTrue(keyboards.mode_keyboard(mode).resize_keyboard) class _StubMessage: - """Минимальный Message: нужны from_user, text и собираемые ответы.""" - def __init__(self, text=None, user_id=42, chat_id=555): self.text = text self.from_user = SimpleNamespace(id=user_id) @@ -69,56 +95,140 @@ class StyleCommandTests(unittest.IsolatedAsyncioTestCase): async def test_style_without_tone_explains_how_to_set_it(self): message = _StubMessage() with patch.object(bot.asyncio, "to_thread", new=AsyncMock(side_effect=["", []])): - await bot.style_command(message) + await bot.style_command(message, _StubState("training")) self.assertIn("скриншот", message.answers[0][0]) async def test_style_shows_saved_tone(self): message = _StubMessage() with patch.object(bot.asyncio, "to_thread", new=AsyncMock(side_effect=["Короткие фразы", ["a", "b"]])): - await bot.style_command(message) + await bot.style_command(message, _StubState("training")) self.assertIn("Короткие фразы", message.answers[0][0]) self.assertIn("Образцов сохранено: 2", message.answers[1][0]) + async def test_style_is_rejected_outside_training(self): + message = _StubMessage() + with patch.object(bot, "get_tone_of_voice") as get_tone: + await bot.style_command(message, _StubState("generation")) + get_tone.assert_not_called() + self.assertIn("только в режиме", message.answers[0][0]) -class ButtonRoutingTests(unittest.IsolatedAsyncioTestCase): - async def test_button_with_command_routes_to_command_handler(self): + +class ModeRoutingTests(unittest.IsolatedAsyncioTestCase): + async def test_mode_state_is_stored_by_aiogram_memory_storage(self): + from aiogram.fsm.context import FSMContext + from aiogram.fsm.storage.base import StorageKey + from aiogram.fsm.storage.memory import MemoryStorage + + storage = MemoryStorage() + state = FSMContext(storage, StorageKey(bot_id=1, chat_id=2, user_id=3)) + await bot.enter_mode(_StubMessage(), state, "generation") + self.assertEqual(await bot.current_mode(state), "generation") + await storage.close() + + async def test_mode_button_switches_from_any_mode(self): + message = _StubMessage("🎓 Обучение") + state = _StubState("generation") + with patch.object(bot, "notify_development_deploy", new=AsyncMock()): + await bot.handle_text(message, state) + self.assertEqual(await state.get_state(), bot.BotMode.training.state) + + async def test_slash_switch_command_works_from_another_mode(self): + message = _StubMessage("/training") + state = _StubState("subscription") + await bot.handle_command_text(message, state) + self.assertEqual(await state.get_state(), bot.BotMode.training.state) + + async def test_help_button_switches_to_help_mode(self): message = _StubMessage("📖 Помощь") - with patch.object(bot, "help_command", new=AsyncMock()) as help_cmd: - await bot.handle_text(message) - help_cmd.assert_awaited_once_with(message) + state = _StubState("lobby") + with ( + patch.object(bot, "notify_development_deploy", new=AsyncMock()), + patch.object(bot, "send_rich_message", new=AsyncMock()), + ): + await bot.handle_text(message, state) + self.assertEqual(await state.get_state(), bot.BotMode.help.state) - async def test_button_with_request_goes_to_classifier(self): + async def test_idea_button_generates_only_in_generation_mode(self): message = _StubMessage("💡 Идея") + 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) - classify.assert_awaited_once_with(message, "предложи идею для нового поста") + await bot.handle_text(message, state) + classify.assert_awaited_once_with(message, "предложи идею для нового поста", state=state) - async def test_write_post_button_sends_topic_request(self): + async def test_post_button_generates_only_in_generation_mode(self): message = _StubMessage("✍️ Написать пост") + 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) - classify.assert_awaited_once_with(message, "Напиши пост") + await bot.handle_text(message, state) + classify.assert_awaited_once_with(message, "Напиши пост", state=state) - async def test_plain_text_is_not_rewritten(self): + async def test_plain_text_is_only_sent_to_generator_in_generation_mode(self): message = _StubMessage("Напиши пост про запуск продукта") + 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) - classify.assert_awaited_once_with(message, "Напиши пост про запуск продукта") + await bot.handle_text(message, state) + classify.assert_awaited_once_with(message, "Напиши пост про запуск продукта", state=state) - async def test_style_command_is_routed(self): + async def test_plain_text_in_lobby_does_not_trigger_generation(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, + ): + await bot.handle_text(message, state) + classify.assert_not_awaited() + self.assertIn("Выбери режим", message.answers[0][0]) + + async def test_style_command_is_rejected_outside_training(self): message = _StubMessage("/style") + state = _StubState("generation") with patch.object(bot, "style_command", new=AsyncMock()) as style: - await bot.handle_command_text(message) - style.assert_awaited_once_with(message) + await bot.handle_command_text(message, state) + style.assert_awaited_once_with(message, state) + + async def test_clear_command_is_rejected_outside_training(self): + message = _StubMessage("/clear") + state = _StubState("generation") + with patch.object(bot, "clear_user_data") as clear_data: + await bot.handle_command_text(message, state) + clear_data.assert_not_called() + self.assertIn("только в режиме «Обучение»", message.answers[0][0]) + + async def test_subscription_command_is_rejected_outside_subscription_mode(self): + message = _StubMessage("/balance") + state = _StubState("generation") + with patch.object(bot, "subscription_row") as get_subscription: + await bot.handle_command_text(message, state) + get_subscription.assert_not_called() + self.assertIn("только в режиме «Подписка»", message.answers[0][0]) + + async def test_image_is_rejected_outside_training(self): + message = _StubMessage() + with patch.object(bot, "_handle_image_message", new=AsyncMock()) as process_image: + await bot.update_tone_from_image(message, AsyncMock(), _StubState("generation")) + process_image.assert_not_awaited() + self.assertIn("только в режиме «Обучение»", message.answers[0][0]) + + async def test_voice_is_rejected_outside_generation(self): + message = _StubMessage() + with ( + patch.object(bot, "notify_development_deploy", new=AsyncMock()) as notify, + patch.object(bot, "_process_voice", new=AsyncMock()) as process_voice, + ): + await bot.transcribe_voice(message, AsyncMock(), _StubState("training")) + notify.assert_not_awaited() + process_voice.assert_not_awaited() + self.assertIn("только в режиме «Генерация»", message.answers[0][0]) class MenuPublishTests(unittest.IsolatedAsyncioTestCase): @@ -126,9 +236,8 @@ 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"]] - self.assertIn("help", names) - self.assertIn("subscribe", names) - self.assertIn("style", names) + for expected in ("lobby", "training", "generate", "subscription", "help", "clear", "post"): + self.assertIn(expected, names) async def test_publish_failure_does_not_crash_startup(self): fake_bot = AsyncMock() @@ -137,4 +246,4 @@ class MenuPublishTests(unittest.IsolatedAsyncioTestCase): if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() diff --git a/tests/test_payments.py b/tests/test_payments.py index 1d39fc1..ccb5df2 100644 --- a/tests/test_payments.py +++ b/tests/test_payments.py @@ -12,6 +12,13 @@ import payments import user_store +class _StubState: + value = bot.BotMode.subscription.state + + async def get_state(self): + return self.value + + class PaymentStoreTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.NamedTemporaryFile(suffix=".sqlite3", delete=False) @@ -153,12 +160,12 @@ class SuccessfulPaymentTests(unittest.IsolatedAsyncioTestCase): async def test_duplicate_payment_does_not_extend_twice(self): message = _paid_message(charge_id="charge-dup") with ( - patch.object(payments, "payment_recorded", new=AsyncMock(return_value=True)), - patch.object(payments, "save_payment", new=AsyncMock()) as save, + patch.object(payments, "payment_recorded", return_value=True), + patch.object(payments, "save_payment") as save, patch.object(payments.asyncio, "to_thread", new=_direct_thread_mock()), ): await payments.handle_successful_payment(message) - save.assert_not_awaited() + save.assert_not_called() self.assertIn("уже учтён", message.answers[0]) async def test_wrong_amount_is_rejected_without_activation(self): @@ -169,7 +176,7 @@ class SuccessfulPaymentTests(unittest.IsolatedAsyncioTestCase): ): await payments.handle_successful_payment(message) activate.assert_not_called() - save.assert_not_awaited() + save.assert_not_called() self.assertIn("поддержке", message.answers[0]) async def test_wrong_currency_is_rejected(self): @@ -205,7 +212,7 @@ class SubscribeCommandTests(unittest.IsolatedAsyncioTestCase): patch.object(bot.asyncio, "to_thread", new=AsyncMock()), patch.object(bot, "send_subscription_invoice", new=AsyncMock()) as invoice, ): - await bot.subscribe_command(message) + await bot.subscribe_command(message, _StubState()) invoice.assert_awaited_once_with(message.bot, 555, None) self.assertIn("Счёт", message.answers[0]) self.assertEqual(len(message.answers), 2) @@ -219,7 +226,7 @@ class SubscribeCommandTests(unittest.IsolatedAsyncioTestCase): patch.object(bot.asyncio, "to_thread", new=AsyncMock()), patch.object(bot, "send_subscription_invoice", new=AsyncMock()), ): - await bot.subscribe_command(message) + await bot.subscribe_command(message, _StubState()) self.assertIn("без реального платежа", message.answers[-1]) self.assertEqual(len(message.answers), 2) self.assertIn("Счёт", message.answers[0]) @@ -281,7 +288,7 @@ class SubscribeCommandTests(unittest.IsolatedAsyncioTestCase): patch.object(bot.asyncio, "to_thread", new=AsyncMock()), patch.object(bot, "send_subscription_invoice", new=AsyncMock(side_effect=RuntimeError("boom"))), ): - await bot.subscribe_command(message) + await bot.subscribe_command(message, _StubState()) self.assertIn("поддержке", message.answers[0]) diff --git a/tests/test_release_help.py b/tests/test_release_help.py index f66887f..2a0ccd3 100644 --- a/tests/test_release_help.py +++ b/tests/test_release_help.py @@ -10,6 +10,20 @@ from aiogram.types import Chat, Message import bot +class _StubState: + def __init__(self, mode="lobby"): + self.value = bot.MODE_STATES[mode].state + + async def get_state(self): + return self.value + + async def set_state(self, state): + self.value = state.state + + async def clear(self): + self.value = None + + class DeployNoticeTests(unittest.IsolatedAsyncioTestCase): async def test_sends_once_per_new_version_only_in_development(self): fake_bot = AsyncMock() @@ -80,14 +94,24 @@ class UserFacingErrorTests(unittest.IsolatedAsyncioTestCase): class HelpRoutingTests(unittest.TestCase): def test_commands_are_routed_in_command_handler(self): - self.assertEqual(bot.HELP_COMMANDS, {"help", "помощь"}) + self.assertEqual( + bot.MODE_COMMANDS, + { + "/lobby": "lobby", + "/training": "training", + "/generate": "generation", + "/subscription": "subscription", + "/help": "help", + }, + ) from pathlib import Path + source = Path(bot.__file__).read_text(encoding="utf-8") self.assertIn("async def handle_command_text", source) - self.assertIn("if command == \"/help\"", source) - self.assertIn("elif command == \"/start\"", source) - self.assertIn("elif command == \"/clear\"", source) - self.assertIn("elif command == \"/idea\"", source) + self.assertIn('if command == "/start":', source) + self.assertIn("elif command in MODE_COMMANDS:", source) + self.assertIn('elif command == "/clear":', source) + self.assertIn('elif command == "/idea":', source) self.assertNotIn("idea_voice", source) def test_help_sections_cover_usage_scenarios(self): @@ -152,7 +176,7 @@ class HelpNavigationTests(unittest.TestCase): class OnboardingTests(unittest.IsolatedAsyncioTestCase): - async def test_start_sends_welcome_rich_then_help_menu(self): + async def test_start_sends_welcome_and_enters_lobby(self): message = AsyncMock() message.from_user.id = 42 message.message_thread_id = None @@ -167,8 +191,10 @@ class OnboardingTests(unittest.IsolatedAsyncioTestCase): patch.object(bot.asyncio, "to_thread", new=AsyncMock()), patch.object(bot, "send_rich_message", new=fake_rich), ): - await bot.start(message) - self.assertEqual(calls, [bot.WELCOME_RICH_HTML, bot.help_view(None)]) + state = _StubState("help") + await bot.start(message, state) + self.assertEqual(calls, [bot.WELCOME_RICH_HTML]) + self.assertEqual(await state.get_state(), bot.BotMode.lobby.state) def _callback(self, data: str, message: Message | None): from aiogram.types import CallbackQuery @@ -188,7 +214,7 @@ class OnboardingTests(unittest.IsolatedAsyncioTestCase): callback = self._callback("help:tone", message) with patch.object(bot, "edit_rich_message", new=AsyncMock(return_value=True)) as edit: - await bot.help_navigation(callback) + await bot.help_navigation(callback, _StubState("help")) edit.assert_awaited_once_with(message, bot.help_view("tone")) callback.answer.assert_awaited_once_with() @@ -202,7 +228,7 @@ class OnboardingTests(unittest.IsolatedAsyncioTestCase): callback = self._callback("help:menu", message) with patch.object(bot, "edit_rich_message", new=AsyncMock(return_value=False)): - await bot.help_navigation(callback) + await bot.help_navigation(callback, _StubState("help")) callback.answer.assert_awaited_once_with("Не удалось обновить справку, открой /help заново", show_alert=True) @@ -210,10 +236,24 @@ class OnboardingTests(unittest.IsolatedAsyncioTestCase): callback = self._callback("help:posts", None) with patch.object(bot, "edit_rich_message", new=AsyncMock()) as edit: - await bot.help_navigation(callback) + await bot.help_navigation(callback, _StubState("help")) edit.assert_not_awaited() + async def test_help_callback_is_rejected_outside_help_mode(self): + message = Message( + message_id=79, + date=datetime.now(), + chat=Chat(id=5, type="private"), + ) + callback = self._callback("help:posts", message) + + with patch.object(bot, "edit_rich_message", new=AsyncMock()) as edit: + await bot.help_navigation(callback, _StubState("lobby")) + + edit.assert_not_awaited() + callback.answer.assert_awaited_once_with("Сначала открой режим /help.", show_alert=True) + def test_welcome_message_has_examples_and_help_handoff(self): from help_message import WELCOME_RICH_HTML, rich_to_plain diff --git a/tests/test_writer_features.py b/tests/test_writer_features.py index 31281c3..72dc139 100644 --- a/tests/test_writer_features.py +++ b/tests/test_writer_features.py @@ -2,6 +2,7 @@ import asyncio import json import unittest from types import SimpleNamespace +from typing import Any, cast from unittest.mock import AsyncMock, patch import apinex_client @@ -9,6 +10,20 @@ import bot import user_store +class _StubState: + def __init__(self, mode="lobby"): + self.value = bot.MODE_STATES[mode].state + + async def get_state(self): + return self.value + + async def set_state(self, state): + self.value = state.state + + async def clear(self): + self.value = None + + class FakeResponse: def __init__(self, status, payload, headers=None): self.status = status @@ -347,11 +362,12 @@ class ScreenshotToneWorkflowTests(unittest.IsolatedAsyncioTestCase): patch.object(bot, "clear_user_data") as clear_data, patch.object(bot.asyncio, "to_thread", side_effect=run_in_thread), ): - await bot.clear_command(message) + await bot.clear_command(message, _StubState("training")) clear_data.assert_called_once_with(25) msg_calls = [c.args[0] for c in message.answer.call_args_list] self.assertTrue(any("API-логи" in m for m in msg_calls)) self.assertTrue(any("скриншоты" in m for m in msg_calls)) + self.assertTrue(any("Подписка и триал не изменены" in m for m in msg_calls)) class StorageTests(unittest.TestCase): @@ -506,6 +522,8 @@ class SubscriptionTests(unittest.IsolatedAsyncioTestCase): async def test_start_creates_trial_subscription(self): message = SimpleNamespace( from_user=SimpleNamespace(id=101), + chat=SimpleNamespace(id=555), + message_thread_id=None, answer=AsyncMock(), bot=AsyncMock(), ) @@ -515,7 +533,7 @@ class SubscriptionTests(unittest.IsolatedAsyncioTestCase): with patch.object(bot, "notify_development_deploy", new=AsyncMock()): with patch.object(bot.asyncio, "to_thread", side_effect=run_in_thread): - await bot.start(message) + await bot.start(message, _StubState("lobby")) sub = user_store.subscription_row(101, self.db_path) self.assertIsNotNone(sub) @@ -538,7 +556,7 @@ class SubscriptionTests(unittest.IsolatedAsyncioTestCase): with patch.object(bot.asyncio, "to_thread", side_effect=run_in_thread): with patch.object(bot, "send_subscription_invoice", new=AsyncMock()) as invoice: - await bot.subscribe_command(message) + await bot.subscribe_command(message, _StubState("subscription")) invoice.assert_awaited_once() # Подписка не активируется до реальной оплаты. @@ -559,7 +577,7 @@ class SubscriptionTests(unittest.IsolatedAsyncioTestCase): return function(*args, **kwargs) with patch.object(bot.asyncio, "to_thread", side_effect=run_in_thread): - await bot.balance_command(message) + await bot.balance_command(message, _StubState("subscription")) self.assertIn("осталось", message.answer.call_args.args[0].lower()) @@ -575,14 +593,16 @@ class SubscriptionTests(unittest.IsolatedAsyncioTestCase): with patch.object(bot, "notify_development_deploy", new=AsyncMock()): with patch.object(bot.asyncio, "to_thread", side_effect=lambda f, *a: f(*a)): - await bot._classify_and_respond(message, "напиши пост", show_typing=False) + await bot._classify_and_respond( + message, "напиши пост", show_typing=False, state=_StubState("generation") + ) # ответ должен быть о блокировке, а не о генерации msg_calls = [c.args[0] for c in message.answer.call_args_list] self.assertTrue(any("истёк" in msg for msg in msg_calls)) self.assertTrue(any("звёзд" in msg for msg in msg_calls)) - async def test_clear_deactivates_subscription(self): + async def test_clear_preserves_subscription(self): user_store.ensure_subscription(105, self.db_path) user_store.activate_subscription(105, 1, self.db_path) message = SimpleNamespace( @@ -594,13 +614,31 @@ class SubscriptionTests(unittest.IsolatedAsyncioTestCase): return function(*args, **kwargs) with patch.object(bot.asyncio, "to_thread", side_effect=run_in_thread): - with patch.object(bot, "clear_user_data", new=AsyncMock()): - await bot.clear_command(message) + await bot.clear_command(message, _StubState("training")) msg_calls = [c.args[0] for c in message.answer.call_args_list] - self.assertTrue(any("Подписка обнулена" in msg for msg in msg_calls)) + self.assertTrue(any("Подписка и триал не изменены" in msg for msg in msg_calls)) sub = user_store.subscription_row(105, self.db_path) - self.assertEqual(sub["is_active"], 0) + self.assertEqual(sub["is_active"], 1) + + async def test_clear_preserves_trial(self): + user_store.ensure_subscription(106, self.db_path) + before = user_store.subscription_row(106, self.db_path) + assert before is not None + message = SimpleNamespace( + from_user=SimpleNamespace(id=106), + answer=AsyncMock(), + ) + + with patch.object(bot.asyncio, "to_thread", side_effect=lambda f, *a: f(*a)): + await bot.clear_command(cast(Any, message), cast(Any, _StubState("training"))) + + after = user_store.subscription_row(106, self.db_path) + assert after is not None + self.assertEqual(after["is_active"], before["is_active"]) + self.assertEqual(after["trial_started_at"], before["trial_started_at"]) + self.assertEqual(after["monthly_days"], before["monthly_days"]) + self.assertIsNone(after["last_paid_at"]) if __name__ == "__main__":