diff --git a/IDEA.MD b/IDEA.MD index b5eeaa4..5840327 100644 --- a/IDEA.MD +++ b/IDEA.MD @@ -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) diff --git a/README.md b/README.md index b30e177..052bce1 100644 --- a/README.md +++ b/README.md @@ -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`). diff --git a/VERSION b/VERSION index eac87aa..28ae29b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+21 +0.1.0+22 diff --git a/bot.py b/bot.py index d5d360e..1119069 100644 --- a/bot.py +++ b/bot.py @@ -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, "помощи") diff --git a/help_message.py b/help_message.py index 17cb08b..969ed48 100644 --- a/help_message.py +++ b/help_message.py @@ -1,6 +1,17 @@ from __future__ import annotations import html +WELCOME_RICH_HTML = """

Привет! Я — бот для написания постов

+

Помогаю писать и редактировать посты для соцсетей. Расскажи, о чём нужен пост, а я верну три варианта: короткий, развёрнутый и с призывом к действию.

+

Примеры запросов

+ +

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

+

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

+""" HELP_RICH_HTML = """

Помощь по боту

Я помогаю писать и редактировать посты для социальных сетей. Генерирую посты только по явной просьбе.


diff --git a/tests/test_release_help.py b/tests/test_release_help.py index a3b17f9..fd25e7e 100644 --- a/tests/test_release_help.py +++ b/tests/test_release_help.py @@ -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()