feat: онбординг после /start — welcome Rich Message + вызов help_command
- help_message.py: добавлен WELCOME_RICH_HTML (приветствие, примеры, скриншоты) - bot.py: send_rich_help -> send_rich_html(html, stage) + send_rich_message с plain-fallback - bot.py: start теперь шлёт welcome Rich Message, пауза 0.8с, затем help_command - help_command вызывает общий send_rich_message — правки HELP_RICH_HTML меняют и онбординг - тесты: OnboardingTests (3), README/IDEA/VERSION 0.1.0+22
This commit is contained in:
1 parent
d618fd8a61
commit
0ff0e1abe7
6 files changed
+78
-44
No files matched your search
@@ -44,7 +44,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=development`, `DEV_CHAT_ID` — для уведомления о новой версии; версия в файле `VERSION` (сейчас `0.1.0+19`).
|
||||
- `DEPLOY_ENV=development`, `DEV_CHAT_ID` — для уведомления о новой версии; версия в файле `VERSION` (сейчас `0.1.0+22`).
|
||||
- Вики Gitea: страницы `Home`, `User-Guide`, `Developer-Guide`, `Subscription`, `Deployment`.
|
||||
|
||||
## Статус
|
||||
@@ -69,7 +69,7 @@ Telegram-бот, который по запросу пользователя г
|
||||
- [x] Служебные ответы по-русски, язык текстов постов определяется запросом пользователя
|
||||
- [x] Help command uses new Rich Message, with a plain-text fallback.
|
||||
- [x] Development deployment/version notice and user-visible error details.
|
||||
- [x] Онбординг после `/start`: три сообщения (что делаю → примеры → стиль и идеи), golden path, остальное в `/help`
|
||||
- [x] Онбординг после `/start`: welcome Rich Message (`WELCOME_RICH_HTML`) → пауза → вызов `help_command`; правки справки автоматически меняют онбординг
|
||||
- [x] Подписка: триал 3 дня, `/subscribe` за 100 звёзд (30 дней), `/balance`, блокировка генерации без активной подписки
|
||||
- [x] Справка бота (`/help`) описывает `/balance` и `/subscribe`
|
||||
- [x] Проект Gitea «Social Post Writer Bot Roadmap» с задачами (выполненные — в «Готово», будущие — в Backlog)
|
||||
|
||||
@@ -18,15 +18,14 @@ Telegram-бот для генерации постов для соцсетей.
|
||||
## Как работает бот (UX)
|
||||
|
||||
### Онбординг (`/start`)
|
||||
Первое сообщение пользователя запускает онбординг — **три сообщения с паузой 0.8 сек** между ними:
|
||||
Первое сообщение пользователя запускает онбординг — **два сообщения с паузой 0.8 сек** между ними:
|
||||
|
||||
1. **Что делаю** — «Привет! Я помогаю писать посты для соцсетей. Расскажи, о чём нужен пост — а я верну три варианта: короткий, развёрнутый и с призывом к действию.»
|
||||
2. **Примеры запросов** — три сценария: написать пост, официальный тон, реплай-правка.
|
||||
3. **Стиль и идеи** — «пришли скриншоты своих постов, чтобы я запомнил стиль. Хочешь идею — напиши «предложи тему». Подробно — /help»
|
||||
1. **Welcome (Rich Message)** — `WELCOME_RICH_HTML`: приветствие, примеры запросов, упоминание скриншотов для Tone of Voice.
|
||||
2. **Вызов `help_command`** — пауза 0.8 сек, затем тот же Rich Message справки, что и по команде `/help`.
|
||||
|
||||
Одновременно `/start` создаёт запись триала на 3 дня.
|
||||
|
||||
**Логика:** golden path (основной путь) показываем сразу, всё остальное (тон, голоса, правки, `/clear`, `/idea`) — в `/help`. Пользователь не тонет в тексте.
|
||||
**Логика:** welcome содержит только golden path, всё остальное (тон, голоса, правки, `/clear`, `/idea`) — в `/help`. Онбординг не дублирует текст справки, а **вызывает `help_command`**, поэтому правки `HELP_RICH_HTML` автоматически меняют и онбординг.
|
||||
|
||||
### Основные сценарии
|
||||
|
||||
@@ -59,8 +58,8 @@ Telegram-бот для генерации постов для соцсетей.
|
||||
bot.py — роутинг, команды, обработка сообщений (aiogram 3.x)
|
||||
apinex_client.py — клиент APInex (LLM, STT, vision), промпты, логирование вызовов
|
||||
user_store.py — SQLite: пользователи, ToV, посты, идеи, API-логи, подписки
|
||||
help_message.py — Rich Message помощь (HTML + plain fallback)
|
||||
VERSION — версия релиза (сейчас 0.1.0+18)
|
||||
help_message.py — Rich Message: `WELCOME_RICH_HTML` (онбординг) + `HELP_RICH_HTML` (справка), plain fallback
|
||||
VERSION — версия релиза (сейчас 0.1.0+22)
|
||||
```
|
||||
|
||||
### Основные потоки
|
||||
@@ -95,7 +94,7 @@ update_tone_from_image → _handle_image_message
|
||||
|
||||
| Функция | Что делает |
|
||||
|---|---|
|
||||
| `start(message)` | Онбординг (3 сообщения) + создание триала |
|
||||
| `start(message)` | Онбординг (welcome Rich Message + вызов `help_command`) + создание триала |
|
||||
| `handle_command_text` | Роутинг команд: `/help`, `/start`, `/clear`, `/idea`, `/balance`, `/subscribe` |
|
||||
| `_classify_and_respond` | Ядро: проверка подписки → классификация → генерация |
|
||||
| `_check_subscription` | Проверяет активную подписку; если нет — показывает статус и блокирует |
|
||||
@@ -166,7 +165,7 @@ SUBSCRIPTION_PRICE_STARS = 100
|
||||
|
||||
| Команда | Что делает | Доступна без подписки |
|
||||
|---|---|---|
|
||||
| `/start` | Онбординг (3 сообщения) + создание триала | ✅ |
|
||||
| `/start` | Онбординг (welcome Rich Message + справка) + создание триала | ✅ |
|
||||
| `/help` | Подробная справка (Rich Message + plain fallback) | ✅ |
|
||||
| `/balance` | Статус подписки / триала | ✅ |
|
||||
| `/subscribe` | Активация подписки (100 звёзд, 30 дней) | ✅ |
|
||||
@@ -194,4 +193,4 @@ SUBSCRIPTION_PRICE_STARS = 100
|
||||
python -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
Покрытие: STT, LLM, vision, ToV, reply-поля, изоляция пользователей, API-логи, Rich help, **подписки** (6 тестов: таблица, триал после `/start`, оплата, баланс, блокировка, обнуление через `/clear`).
|
||||
Покрытие: STT, LLM, vision, ToV, reply-поля, изоляция пользователей, API-логи, Rich help, **онбординг** (welcome → вызов `help_command`), **подписки** (6 тестов: таблица, триал после `/start`, оплата, баланс, блокровка, обнуление через `/clear`).
|
||||
@@ -18,7 +18,7 @@ from aiogram.types import Message
|
||||
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 HELP_RICH_HTML, rich_to_plain
|
||||
from help_message import HELP_RICH_HTML, WELCOME_RICH_HTML, rich_to_plain
|
||||
from user_store import (
|
||||
add_post_samples,
|
||||
clear_user_data,
|
||||
@@ -114,9 +114,9 @@ async def notify_development_deploy(bot: Bot) -> None:
|
||||
logger.info("Development deploy notice sent: version=%s", VERSION)
|
||||
|
||||
|
||||
async def send_rich_help(message: Message) -> bool:
|
||||
async def send_rich_html(message: Message, rich_html: str, stage: str = "помощи") -> bool:
|
||||
bot = message.bot
|
||||
payload = {"chat_id": message.chat.id, "rich_message": {"html": HELP_RICH_HTML}}
|
||||
payload = {"chat_id": message.chat.id, "rich_message": {"html": rich_html}}
|
||||
if message.message_thread_id is not None:
|
||||
payload["message_thread_id"] = message.message_thread_id
|
||||
url = bot.session.api.api_url(token=bot.token, method="sendRichMessage")
|
||||
@@ -130,13 +130,26 @@ async def send_rich_help(message: Message) -> bool:
|
||||
data = await response.json(content_type=None)
|
||||
if response.status == 200 and data.get("ok"):
|
||||
return True
|
||||
logger.warning("Rich help rejected with HTTP %s", response.status)
|
||||
logger.warning("Rich %s rejected with HTTP %s", stage, response.status)
|
||||
return False
|
||||
except Exception as exc:
|
||||
logger.warning("Rich help delivery error (%s): %s", type(exc).__name__, str(exc)[:160])
|
||||
logger.warning("Rich %s delivery error (%s): %s", stage, type(exc).__name__, str(exc)[:160])
|
||||
return False
|
||||
|
||||
|
||||
async def send_rich_message(message: Message, rich_html: str, stage: str) -> None:
|
||||
try:
|
||||
sent = await send_rich_html(message, rich_html, stage)
|
||||
except Exception as exc:
|
||||
sent = False
|
||||
logger.warning("Rich %s send failed (%s): %s", stage, type(exc).__name__, str(exc)[:180])
|
||||
if DEPLOY_ENV == "development":
|
||||
await report_error(message, f"отправка Rich Message: {stage}", exc)
|
||||
if not sent:
|
||||
if DEPLOY_ENV == "development":
|
||||
await report_error(message, f"отправка Rich Message: {stage}", RuntimeError("sendRichMessage returned false"))
|
||||
await message.answer(rich_to_plain(rich_html))
|
||||
|
||||
|
||||
@router.message(CommandStart())
|
||||
async def start(message: Message) -> None:
|
||||
@@ -145,22 +158,9 @@ async def start(message: Message) -> None:
|
||||
return
|
||||
await notify_development_deploy(message.bot)
|
||||
await asyncio.to_thread(ensure_subscription, message.from_user.id)
|
||||
await message.answer(
|
||||
"Привет! Я помогаю писать посты для соцсетей. Расскажи, о чём нужен пост — "
|
||||
"а я верну три варианта: короткий, развёрнутый и с призывом к действию."
|
||||
)
|
||||
await send_rich_message(message, WELCOME_RICH_HTML, "приветствия")
|
||||
await asyncio.sleep(0.8)
|
||||
await message.answer(
|
||||
"Примеры запросов:\n"
|
||||
"• «Напиши пост про запуск нового продукта»\n"
|
||||
"• «Сделай пост-напоминание о вебинаре в официальном тоне»\n"
|
||||
"• «Обнови этот вариант, добавь эмодзи» (реплаем на мой текст)"
|
||||
)
|
||||
await asyncio.sleep(0.8)
|
||||
await message.answer(
|
||||
"Также я умею запоминать твой стиль: пришли скриншоты своих постов. "
|
||||
"Хочешь идею — напиши «предложи тему». Подробно — /help"
|
||||
)
|
||||
await help_command(message)
|
||||
|
||||
|
||||
async def _show_subscription_info(message: Message, sub: sqlite3.Row | None) -> None:
|
||||
@@ -234,17 +234,7 @@ async def clear_command(message: Message) -> None:
|
||||
|
||||
|
||||
async def help_command(message: Message) -> None:
|
||||
try:
|
||||
rich_sent = await send_rich_help(message)
|
||||
except Exception as exc:
|
||||
rich_sent = False
|
||||
logger.warning("Rich help send failed (%s): %s", type(exc).__name__, str(exc)[:180])
|
||||
if DEPLOY_ENV == "development":
|
||||
await report_error(message, "отправка Rich Message помощи", exc)
|
||||
if not rich_sent:
|
||||
if DEPLOY_ENV == "development":
|
||||
await report_error(message, "отправка Rich Message помощи", RuntimeError("sendRichMessage returned false"))
|
||||
await message.answer(rich_to_plain(HELP_RICH_HTML))
|
||||
await send_rich_message(message, HELP_RICH_HTML, "помощи")
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
WELCOME_RICH_HTML = """<h1>Привет! Я — бот для написания постов</h1>
|
||||
<p>Помогаю писать и редактировать посты для соцсетей. Расскажи, о чём нужен пост, а я верну три варианта: короткий, развёрнутый и с призывом к действию.</p>
|
||||
<h2>Примеры запросов</h2>
|
||||
<ul>
|
||||
<li>«Напиши пост про запуск нового продукта»</li>
|
||||
<li>«Сделай пост-напоминание о вебинаре в официальном тоне»</li>
|
||||
<li>«Обнови этот вариант, добавь эмодзи» — реплаем на мой текст</li>
|
||||
</ul>
|
||||
<p>Также я умею запоминать твой стиль: пришли скриншоты своих постов. Хочешь идею — напиши «предложи тему».</p>
|
||||
<p>Ниже — подробная инструкция по работе с ботом.</p>
|
||||
"""
|
||||
HELP_RICH_HTML = """<h1>Помощь по боту</h1>
|
||||
<p>Я помогаю писать и редактировать посты для социальных сетей. Генерирую посты только по явной просьбе.</p>
|
||||
<hr/>
|
||||
|
||||
@@ -62,5 +62,39 @@ class HelpRoutingTests(unittest.TestCase):
|
||||
self.assertIn(term, text)
|
||||
|
||||
|
||||
class OnboardingTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_start_sends_welcome_rich_then_help_command(self):
|
||||
message = AsyncMock()
|
||||
message.from_user.id = 42
|
||||
message.message_thread_id = None
|
||||
calls: list[str] = []
|
||||
|
||||
async def fake_rich(_message, html, _stage):
|
||||
calls.append(html)
|
||||
|
||||
with (
|
||||
patch.object(bot, "notify_development_deploy", new=AsyncMock()),
|
||||
patch.object(bot, "ensure_subscription"),
|
||||
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_RICH_HTML])
|
||||
|
||||
async def test_start_help_step_reuses_help_command(self):
|
||||
from pathlib import Path
|
||||
|
||||
source = Path(bot.__file__).read_text(encoding="utf-8")
|
||||
start_body = source.split("async def start(message: Message) -> None:")[1].split("\nasync def ")[0]
|
||||
self.assertIn("await help_command(message)", start_body)
|
||||
|
||||
def test_welcome_message_has_examples_and_help_handoff(self):
|
||||
from help_message import WELCOME_RICH_HTML, rich_to_plain
|
||||
|
||||
text = rich_to_plain(WELCOME_RICH_HTML)
|
||||
for term in ("Привет", "Напиши пост", "скриншот"):
|
||||
self.assertIn(term, text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user