feat: add mode-based bot navigation
This commit is contained in:
1 parent
c02db5ada9
commit
36a17f7639
10 files changed
+591
-259
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
@@ -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`).
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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__":
|
||||
|
||||
Reference in new issue
Block a user