feat: add mode-based bot navigation

This commit is contained in:
admin committed 2026-10-08 15:56:32 +02:00
1 parent c02db5ada9
commit 36a17f7639
10 files changed
+591 -259

No files matched your search

+17 -7
View File
@@ -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)
+41 -46
View File
@@ -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:<section>` → `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`).
+1 -1
View File
@@ -1 +1 @@
0.1.0+28
0.1.0+29
+202 -73
View File
@@ -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"<b>Твой стиль</b>\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)
# Ставим меню один раз при старте, чтобы оно было у всех, кто писал боту.
+7 -6
View File
@@ -10,8 +10,8 @@ WELCOME_RICH_HTML = """<h1>Привет! Я — бот для написания
<li>«Сделай пост-напоминание о вебинаре в официальном тоне»</li>
<li>«Обнови этот вариант, добавь эмодзи» — реплаем на мой текст</li>
</ul>
<p>Также я умею запоминать твой стиль: пришли скриншоты своих постов. Хочешь идею — напиши «предложи тему».</p>
<p>Ниже — подробная инструкция по работе с ботом.</p>
<p>Работай в одном из режимов: «Лобби», «Обучение», «Генерация», «Подписка» или «Помощь». Выбери режим кнопкой в нижнем меню или slash-командой: /lobby, /training, /generate, /subscription, /help. /start возвращает в лобби.</p>
<p>В режиме «Обучение» пришли скриншоты своих постов; в режиме «Генерация» попроси идею или пост.</p>
"""
HELP_MENU_TITLE = "Помощь по боту"
@@ -20,7 +20,7 @@ HELP_SECTIONS: dict[str, tuple[str, str]] = {
"posts": (
"✍️ Написать пост",
"""<ol>
<li>Напиши сообщение или отправь голосовое с прямой просьбой, например: «Придумай пост для Telegram про запуск нового продукта».</li>
<li>В режиме «Генерация» напиши сообщение или отправь голосовое с прямой просьбой, например: «Придумай пост для Telegram про запуск нового продукта».</li>
<li>Можешь указать площадку, аудиторию, факты, длину, цель и другие требования. Можно надиктовать подробное ТЗ или просто тему.</li>
<li>Я распознаю речь, проверю, просишь ли ты именно написать пост, и верну три варианта отдельными сообщениями.</li>
</ol>
@@ -46,12 +46,13 @@ HELP_SECTIONS: dict[str, tuple[str, str]] = {
<li><code>/balance</code> — узнать статус: сколько осталось дней триала или подписки.</li>
<li><code>/subscribe</code> — оплатить подписку на 30 дней.</li>
</ul>
<p>Когда период закончится, генерация постов и идей станет недоступна — бот предложит оформить подписку. Команды <code>/help</code>, <code>/balance</code>, <code>/subscribe</code>, <code>/start</code> и <code>/clear</code> работают всегда.</p>""",
<p>Сначала открой режим «Подписка» кнопкой или командой <code>/subscription</code>, затем используй <code>/balance</code> или <code>/subscribe</code>. Когда период закончится, генерация постов и идей станет недоступна. Команды переключения режимов доступны всегда.</p>""",
),
"commands": (
"⌨️ Команды",
"""<p>Быстрые slash-команды только на английском: <code>/help</code> — эта справка; <code>/start</code> — краткое описание бота; <code>/clear</code> — полностью обнулить твои данные бота, кроме технических API-логов; <code>/idea</code> — предложить одну идею для нового поста; <code>/balance</code> — статус подписки; <code>/subscribe</code> — оплатить подписку на 30 дней.</p>
<p>Можно попросить идею обычными словами: «предложи идею для поста», «о чём написать?», «накидай тему».</p>""",
"""<p>Команды-переключатели доступны в любом режиме: <code>/start</code> и <code>/lobby</code> — лобби; <code>/training</code> — обучение; <code>/generate</code> — генерация; <code>/subscription</code> — подписка; <code>/help</code> — помощь.</p>
<p>Действия доступны только в своём режиме: <code>/style</code> и <code>/clear</code> — обучение; <code>/post</code> и <code>/idea</code> — генерация; <code>/balance</code> и <code>/subscribe</code> — подписка. <code>/clear</code> очищает пользовательские данные, но не меняет подписку и триал.</p>
<p>В режиме «Генерация» можно попросить идею обычными словами: «предложи идею для поста», «о чём написать?», «накидай тему».</p>""",
),
}
+52 -49
View File
@@ -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 в режиме «Подписка»."
+158 -49
View File
@@ -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()
unittest.main()
+14 -7
View File
@@ -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])
+51 -11
View File
@@ -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
+48 -10
View File
@@ -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__":