Files
social-post-writer-bot/README.md
T
admin 971bf115da feat: reply-клавиатура + фикс пропавший _send_idea
- keyboards.py: MAIN_MENU (✍️ Написать пост / 💡 Идея / 🎨 Мой стиль / 📖 Помощь / 💳 Подписка / 👤 Баланс)
  и LOCKED_MENU — при блокировке первой кнопкой идёт «💳 Оплатить подписку»
- кнопки не обходят роутинг: resolve_button_text превращает их в slash-команду
  (/help, /balance, /subscribe, /style) или готовый запрос («Напиши пост», «предложи идею…»)
- новая команда /style показывает сохранённый Tone of Voice и подсказывает, как его настроить
- set_my_commands публикует slash-команды при старте, ошибка не роняет бот
- fix: _send_idea вызывалась на bot.py, но была потеряна в коммите про подписку —
  /idea падал с NameError; функция восстановлена
- 17 новых тестов клавиатуры; 92 теста OK; VERSION 0.1.0+26
2026-10-07 11:30:39 +02:00

220 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Social Post Writer Bot — development
Telegram-бот для генерации постов для соцсетей. Готовит текст по явной просьбе пользователя, адаптируя под его Tone of Voice, и работает по модели подписки (триал 3 дня, затем 100 звёзд в месяц).
---
## Содержание
- [Как работает бот (UX)](#как-работает-бот-ux)
- [Архитектура кода](#архитектура-кода)
- [Подписка и монетизация](#подписка-и-монетизация)
- [Данные и хранилище](#данные-и-хранилище)
- [Команды](#команды)
- [Деплой и конфигурация](#деплой-и-конфигурация)
- [Тесты](#тесты)
---
## Как работает бот (UX)
### Reply-клавиатура
Основное меню показывается в `/start`:
```
✍️ Написать пост 💡 Идея
🎨 Мой стиль 📖 Помощь
💳 Подписка 👤 Баланс
```
Если подписки нет, `_show_subscription_info` ставит `LOCKED_MENU` — с кнопкой «💳 Оплатить подписку»
на первом месте. Нажатие кнопки отправляет текст, который `resolve_button_text` превращает
либо в slash-команду (`/help`, `/balance`, `/subscribe`, `/style`), либо в готовый запрос
(«Напиши пост», «предложи идею для нового поста») — то есть кнопки не обходят роутинг, а идут через него.
Slash-команды публикуются в Telegram через `set_my_commands` при старте (`set_main_menu`).
### Онбординг (`/start`)
Первое сообщение пользователя запускает онбординг — **два сообщения с паузой 0.8 сек** между ними:
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` автоматически меняют и онбординг.
### Основные сценарии
| Что хочет пользователь | Что делает | Что видит |
|---|---|---|
| Написать пост | Пишет «напиши пост про запуск продукта» | Индикатор «печатает», затем **3 отдельных сообщения** — по одному варианту в каждом |
| Изменить пост | Отвечает реплаем на вариант бота: «сделай короче» | Снова 3 новых варианта, с учётом исходного текста |
| Придумать идею | «предложи тему» или `/idea` | Одна идея одним сообщением (заголовок, угол, зачем) |
| Настроить стиль | Присылает скриншот(ы) своих постов | Бот распознаёт тексты, накапливает их и обновляет ToV; показывает итоговый профиль стиля |
| Голосом | Отправляет голосовое | STT расшифровывает → как обычный текстовый запрос |
| Узнать статус | `/balance` | Триал активен (сколько дней) / подписка активна / период истёк |
| Оплатить | `/subscribe` | Просит 100 звёзд, подтверждает активацию на 30 дней |
| Начать заново | `/clear` | Полный сброс данных + обнуление подписки |
### Индикаторы и обратная связь
- Пока **идёт обработка** (классификация, расшифровка, чтение скриншота, генерация) — бот показывает «печатает» (`typing_status`), обновляя каждые 4 секунды.
- При **ошибке** пользователю уходит понятное сообщение: «❌ Не получилось: {этап}. Причина: …» — секреты (ключи, токены) и URL вырезаются.
- Служебные тексты — **всегда на русском**. Язык самого поста определяется языком запроса.
### Блокировка по подписке
Если подписка не активна (триал/подписка истекли или `is_active = 0`):
- Генерация поста и идеи **блокируется**, бот пишет, что период истёк, и предлагает `/subscribe`.
- `/help`, `/start`, `/balance`, `/subscribe` **доступны всегда** — пользователь не остаётся в тупике.
---
## Архитектура кода
```
bot.py — роутинг, команды, обработка сообщений (aiogram 3.x)
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` (без подписки), маппинг кнопок на команды
help_message.py — Rich Message: `WELCOME_RICH_HTML` (онбординг) + `HELP_SECTIONS`/`help_view()` (справка с кнопками), plain fallback
VERSION — версия релиза (сейчас 0.1.0+26)
```
### Основные потоки
**Текстовый запрос:**
```
handle_text → _classify_and_respond
├─ /help? → help_command (без проверки подписки)
├─ _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 (пока всё идёт)
```
**Голосовое:**
```
transcribe_voice → _process_voice → transcribe_audio (APInex STT) → _classify_and_respond
```
**Скриншоты (ToV):**
```
update_tone_from_image → _handle_image_message
├─ _collect_media_group (собирает галерею, ждёт 1 сек)
├─ extract_posts_from_images (APInex vision, inline base64)
├─ add_post_samples (накапливает по пользователю)
└─ create_tone_profile → save_tone_of_voice → показывает стиль
```
### Ключевые функции (`bot.py`)
| Функция | Что делает |
|---|---|
| `start(message)` | Онбординг (welcome Rich Message + вызов `help_command`) + создание триала |
| `handle_command_text` | Роутинг команд: `/help`, `/start`, `/clear`, `/idea`, `/balance`, `/subscribe` |
| `_classify_and_respond` | Ядро: проверка подписки → классификация → генерация |
| `_check_subscription` | Проверяет активную подписку; если нет — показывает статус и блокирует |
| `_show_subscription_info` | Формирует сообщение о статусе (триал / подписка / истёк) |
| `balance_command` | `/balance` — статус подписки |
| `subscribe_command` | `/subscribe` — отправляет Stars-инвойс на 30 дней (подписка активируется только после оплаты) |
| `handle_pre_checkout` | Подтверждает только свой инвойс в XTR на 100 звёзд, всё остальное отклоняет |
| `handle_successful_payment` | Активирует подписку после реальной оплаты; `telegram_payment_charge_id` пишется в `payments` для защиты от повторного начисления |
| `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-сообщения на новый раздел |
| `typing_status` | Индикатор «печатает» во время обработки |
| `report_error` | Понятная ошибка пользователю, секреты вырезаны |
### Ключевые функции (`user_store.py`)
| Функция | Что делает |
|---|---|
| `init_db` | Создаёт таблицы: `users`, `user_post_samples`, `generated_posts`, `generated_ideas`, `api_call_logs`, `subscriptions` |
| `ensure_subscription` | Создаёт запись подписки с триалом, если нет (`INSERT OR IGNORE`) |
| `activate_subscription` | Активирует/продлевает подписку на 30 дней от момента оплаты |
| `deactivate_subscription` | `is_active = 0` |
| `is_subscribed` | Проверяет: запись есть, `is_active = 1`, срок не истёк |
| `subscription_row` | Возвращает строку подписки |
| `_days_until` | Сколько дней осталось (триал — `trial_started_at + 3 дня`; подписка — `last_paid_at`); `-1` если истёкло/отключено |
---
## Подписка и монетизация
**Модель:** триал 3 дня → подписка 30 дней за **100 звёзд** (Telegram Stars).
**Константы** (`user_store.py`):
```python
TRIAL_DAYS = 3
SUBSCRIPTION_MONTH_DAYS = 30
SUBSCRIPTION_PRICE_STARS = 100
```
**Таблица `subscriptions`:**
- `telegram_user_id` (PK)
- `trial_started_at` — момент создания (SQLite `CURRENT_TIMESTAMP`, UTC-нейтральный)
- `last_paid_at` — момент/конец оплаченного периода; `NULL` = ещё триал
- `is_active` — 1/0
- `monthly_days` — длительность периода (30)
- `updated_at`
**Жизненный цикл:**
1. `/start` → `ensure_subscription` → триал 3 дня, `is_active = 1`.
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`.
**Оплата реальная:** `payments.py` проводит настоящий платёж Telegram Stars. Подписка активируется **только** в `successful_payment` — сам `/subscribe` её не продлевает. `telegram_payment_charge_id` каждого платежа пишется в таблицу `payments`, поэтому один платёж не может активировать подписку дважды; по этому же id делается возврат через `refundStarPayment`. Платится валюта `XTR`, `provider_token` пустой (цифровой товар).
**Часовые пояса:** SQLite `CURRENT_TIMESTAMP` возвращает UTC без tz. Код аккуратно приводит все значения к naive UTC перед сравнением, чтобы не было сдвигов.
---
## Данные и хранилище
- SQLite: `/app/data/social_writer.sqlite3` (persistent Coolify volume).
- **Per-user данные:** ToV, образцы постов со скриншотов, история сгенерированных постов, история идей. История диалогов и исходные изображения **не сохраняются**.
- **API-логи:** JSON запросов/ответов APInex + ошибки разбора, 30 дней. Системные промпты не дублируются — только имя и SHA-256. base64 изображений и бинарное аудио **не пишутся**. Содержимое логов — приватные данные пользователя.
- `/clear` удаляет пользовательские данные (ToV, скриншоты, посты, идеи, настройки) и обнуляет подписку; **API-логи сохраняются**.
---
## Команды
| Команда | Что делает | Доступна без подписки |
|---|---|---|
| `/start` | Онбординг (welcome Rich Message + справка) + создание триала | ✅ |
| `/help` | Меню справки: полноширинные кнопки разделов, переход в любой раздел + plain fallback | ✅ |
| `/balance` | Статус подписки / триала | ✅ |
| `/subscribe` | Счёт на оплату: 100 звёзд (XTR) за 30 дней; активация — после оплаты | ✅ |
| `/idea` | Одна идея для поста | ❌ (нужна подписка) |
| `/clear` | Полный сброс данных + обнуление подписки | ✅ |
Русские фразы («придумай идею», «о чём написать?») работают как `/idea`.
---
## Деплой и конфигурация
- **Gitea:** `admin/social-post-writer-bot`, ветка `dev`. Push → автодеплой.
- **Coolify:** проект `Social Post Writer Bot`, окружение `development`, приложение `social-post-writer-bot-development` (`d32u5zcq2g4odmdpq9hw91yn`) на `spb-easy-dale`.
- **Деплой:** `.hermes/scripts/deploy.sh` (POST `/api/v1/deploy?uuid=...`).
- **VERSION:** файл `VERSION` (`0.1.0+19`). При `DEPLOY_ENV=development` и изменении версии бот шлёт уведомление «🚀 Бот обновлён / Версия: X» в `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"]`.
---
## Тесты
```bash
python -m unittest discover -s tests -v
```
Покрытие: STT, LLM, vision, ToV, reply-поля, изоляция пользователей, API-логи, **reply-клавиатура** (маппинг кнопок, роутинг, `/style`), **платежи** (инвойс, pre_checkout, successful_payment, защита от двойного списания), **справка с кнопками** (меню, разделы, callback-навигация, plain fallback), **онбординг** (welcome → вызов `help_command`), **подписки** (6 тестов: таблица, триал после `/start`, оплата, баланс, блокировка, обнуление через `/clear`).