diff --git a/IDEA.MD b/IDEA.MD index 5840327..0fa0322 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+22`). +- `DEPLOY_ENV=development`, `DEV_CHAT_ID` — для уведомления о новой версии; версия в файле `VERSION` (сейчас `0.1.0+23`). - Вики Gitea: страницы `Home`, `User-Guide`, `Developer-Guide`, `Subscription`, `Deployment`. ## Статус @@ -67,6 +67,7 @@ Telegram-бот, который по запросу пользователя г - [x] `/idea` возвращает одну идею одним сообщением и сохраняет её для исключения повторов - [x] Все отправленные варианты постов сохраняются по пользователю, включая исправленные; история используется для новых идей - [x] Служебные ответы по-русски, язык текстов постов определяется запросом пользователя +- [x] Справка `/help` разбита на разделы с навигацией инлайн-кнопками (aiogram 3.31.0: `send_rich_message` + `edit_message_text(rich_message=…)` + HTML-теги ``/``) - [x] Help command uses new Rich Message, with a plain-text fallback. - [x] Development deployment/version notice and user-visible error details. - [x] Онбординг после `/start`: welcome Rich Message (`WELCOME_RICH_HTML`) → пауза → вызов `help_command`; правки справки автоматически меняют онбординг diff --git a/README.md b/README.md index 052bce1..2d9e154 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Telegram-бот для генерации постов для соцсетей. Первое сообщение пользователя запускает онбординг — **два сообщения с паузой 0.8 сек** между ними: 1. **Welcome (Rich Message)** — `WELCOME_RICH_HTML`: приветствие, примеры запросов, упоминание скриншотов для Tone of Voice. -2. **Вызов `help_command`** — пауза 0.8 сек, затем тот же Rich Message справки, что и по команде `/help`. +2. **Вызов `help_command`** — пауза 0.8 сек, затем меню справки с инлайн-кнопками (тот же хендлер, что и по команде `/help`). Одновременно `/start` создаёт запись триала на 3 дня. @@ -58,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: `WELCOME_RICH_HTML` (онбординг) + `HELP_RICH_HTML` (справка), plain fallback -VERSION — версия релиза (сейчас 0.1.0+22) +help_message.py — Rich Message: `WELCOME_RICH_HTML` (онбординг) + `HELP_SECTIONS`/`help_view()` (справка с кнопками), plain fallback +VERSION — версия релиза (сейчас 0.1.0+23) ``` ### Основные потоки @@ -102,7 +102,9 @@ update_tone_from_image → _handle_image_message | `balance_command` | `/balance` — статус подписки | | `subscribe_command` | `/subscribe` — активация подписки (оплата 100 звёзд) | | `clear_command` | `/clear` — сброс данных + обнуление подписки | -| `help_command` | `/help` — Rich Message с fallback на plain text | +| `help_command` | `/help` — меню справки (Rich Message с кнопками) + fallback на plain text | +| `help_navigation` | Callback `help:
` → `edit_message_text(rich_message=...)` — переключение разделов | +| `edit_rich_message` | Редактирование текущего rich-сообщения на новый раздел | | `typing_status` | Индикатор «печатает» во время обработки | | `report_error` | Понятная ошибка пользователю, секреты вырезаны | @@ -166,7 +168,7 @@ SUBSCRIPTION_PRICE_STARS = 100 | Команда | Что делает | Доступна без подписки | |---|---|---| | `/start` | Онбординг (welcome Rich Message + справка) + создание триала | ✅ | -| `/help` | Подробная справка (Rich Message + plain fallback) | ✅ | +| `/help` | Меню справки: разделы с инлайн-кнопками + plain fallback | ✅ | | `/balance` | Статус подписки / триала | ✅ | | `/subscribe` | Активация подписки (100 звёзд, 30 дней) | ✅ | | `/idea` | Одна идея для поста | ❌ (нужна подписка) | @@ -193,4 +195,4 @@ SUBSCRIPTION_PRICE_STARS = 100 python -m unittest discover -s tests -v ``` -Покрытие: STT, LLM, vision, ToV, reply-поля, изоляция пользователей, API-логи, Rich help, **онбординг** (welcome → вызов `help_command`), **подписки** (6 тестов: таблица, триал после `/start`, оплата, баланс, блокровка, обнуление через `/clear`). +Покрытие: STT, LLM, vision, ToV, reply-поля, изоляция пользователей, API-логи, **справка с кнопками** (меню, разделы, callback-навигация, plain fallback), **онбординг** (welcome → вызов `help_command`), **подписки** (6 тестов: таблица, триал после `/start`, оплата, баланс, блокировка, обнуление через `/clear`). diff --git a/VERSION b/VERSION index 28ae29b..ac52cf9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+22 +0.1.0+23 diff --git a/bot.py b/bot.py index 1119069..54677d5 100644 --- a/bot.py +++ b/bot.py @@ -8,17 +8,16 @@ import sqlite3 from contextlib import asynccontextmanager, nullcontext from pathlib import Path -import aiohttp 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.types import Message +from aiogram.types import CallbackQuery, InputRichMessage, 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, WELCOME_RICH_HTML, rich_to_plain +from help_message import WELCOME_RICH_HTML, help_plain_view, help_view, rich_to_plain from user_store import ( add_post_samples, clear_user_data, @@ -114,41 +113,49 @@ async def notify_development_deploy(bot: Bot) -> None: logger.info("Development deploy notice sent: version=%s", VERSION) -async def send_rich_html(message: Message, rich_html: str, stage: str = "помощи") -> bool: +async def send_rich_message(message: Message, rich_html: str, stage: str) -> Message | None: bot = message.bot - 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") - timeout = aiohttp.ClientTimeout(total=15) try: - async with aiohttp.ClientSession(timeout=timeout) as session: - async with session.post( - url, json=payload, - headers={"User-Agent": "Mozilla/5.0 (compatible; SocialPostWriterBot/1.0)"}, - ) as response: - data = await response.json(content_type=None) - if response.status == 200 and data.get("ok"): - return True - logger.warning("Rich %s rejected with HTTP %s", stage, response.status) - return False + sent = await bot.send_rich_message( + chat_id=message.chat.id, + rich_message=InputRichMessage(html=rich_html), + message_thread_id=message.message_thread_id, + ) + return sent except Exception as exc: - 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)) + await message.answer(help_plain_view(None) if stage == "помощи" else rich_to_plain(rich_html)) + return None + + +async def edit_rich_message(message: Message, rich_html: str) -> bool: + bot = message.bot + if message.message_id is None: + return False + try: + await bot.edit_message_text( + chat_id=message.chat.id, + message_id=message.message_id, + rich_message=InputRichMessage(html=rich_html), + ) + return True + except Exception as exc: + logger.warning("Rich help edit failed (%s): %s", type(exc).__name__, str(exc)[:180]) + return False + + +@router.callback_query(F.data.startswith("help:")) +async def help_navigation(callback: CallbackQuery) -> None: + message = callback.message + if not isinstance(message, Message): + 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) + return + await callback.answer() @router.message(CommandStart()) @@ -234,7 +241,7 @@ async def clear_command(message: Message) -> None: async def help_command(message: Message) -> None: - await send_rich_message(message, HELP_RICH_HTML, "помощи") + await send_rich_message(message, help_view(None), "помощи") diff --git a/help_message.py b/help_message.py index 969ed48..7c2a051 100644 --- a/help_message.py +++ b/help_message.py @@ -1,6 +1,7 @@ from __future__ import annotations import html + WELCOME_RICH_HTML = """

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

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

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

@@ -12,30 +13,107 @@ WELCOME_RICH_HTML = """

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

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

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

""" -HELP_RICH_HTML = """

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

-

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

-
-

Написать пост

-
    + +HELP_MENU_TITLE = "Помощь по боту" +HELP_MENU_INTRO = "Выбери раздел — откроется только он." +HELP_SECTIONS: dict[str, tuple[str, str]] = { + "posts": ( + "✍️ Написать пост", + """
    1. Напиши сообщение или отправь голосовое с прямой просьбой, например: «Придумай пост для Telegram про запуск нового продукта».
    2. Можешь указать площадку, аудиторию, факты, длину, цель и другие требования. Можно надиктовать подробное ТЗ или просто тему.
    3. Я распознаю речь, проверю, просишь ли ты именно написать пост, и верну три варианта отдельными сообщениями.
    -

    В сообщениях с вариантами будет только текст поста: без нумерации и вступлений, с абзацами, переносами строк и уместными эмодзи.

    -

    Настроить Tone of Voice

    -

    Пришли скриншот или галерею скриншотов своих опубликованных постов. Я распознаю текст постов и сохраню его как образцы, дополню предыдущие примеры и обновлю только правила Tone of Voice. Сами публикации не переписываю.

    -

    Попросить правку

    -

    Ответь реплаем на сообщение с вариантом и укажи, что изменить. Например: «Сделай короче и добавь призыв». Я учту оригинальный текст и верну три новых варианта отдельными сообщениями.

    -

    Подписка

    -

    У тебя есть пробный период — 3 дня бесплатно. Дальше — подписка на 30 дней за 100 звёзд.

    +

    В сообщениях с вариантами будет только текст поста: без нумерации и вступлений, с абзацами, переносами строк и уместными эмодзи.

    """, + ), + "tone": ( + "🎨 Tone of Voice", + """

    Пришли скриншот или галерею скриншотов своих опубликованных постов. Я распознаю текст постов и сохраню его как образцы, дополню предыдущие примеры и обновлю только правила Tone of Voice. Сами публикации не переписываю.

    """, + ), + "edit": ( + "✏️ Правка поста", + """

    Ответь реплаем на сообщение с вариантом и укажи, что изменить. Например: «Сделай короче и добавь призыв». Я учту оригинальный текст и верну три новых варианта отдельными сообщениями.

    """, + ), + "idea": ( + "💡 Идея для поста", + """

    Напиши «предложи идею для поста», «о чём написать?» или «накидай тему» — верну одну идею. Команда /idea работает так же.

    +

    Идеи сохраняются, чтобы не повторяться: каждая новая идея отличается от предыдущих.

    """, + ), + "subscription": ( + "💳 Подписка", + """

    У тебя есть пробный период — 3 дня бесплатно. Дальше — подписка на 30 дней за 100 звёзд.

    • /balance — узнать статус: сколько осталось дней триала или подписки.
    • /subscribe — оплатить подписку на 30 дней.
    -

    Когда период закончится, генерация постов и идей станет недоступна — бот предложит оформить подписку. Команды /help, /balance, /subscribe, /start и /clear работают всегда.

    -

    Команды

    -

    Быстрые slash-команды только на английском: /help — эта инструкция; /start — краткое описание бота; /clear — полностью обнулить твои данные бота, кроме технических API-логов; /idea — предложить одну идею для нового поста; /balance — статус подписки; /subscribe — оплатить подписку на 30 дней. Можно попросить идею обычными словами: «предложи идею для поста», «о чём написать?», «накидай тему».

    -""" +

    Когда период закончится, генерация постов и идей станет недоступна — бот предложит оформить подписку. Команды /help, /balance, /subscribe, /start и /clear работают всегда.

    """, + ), + "commands": ( + "⌨️ Команды", + """

    Быстрые slash-команды только на английском: /help — эта справка; /start — краткое описание бота; /clear — полностью обнулить твои данные бота, кроме технических API-логов; /idea — предложить одну идею для нового поста; /balance — статус подписки; /subscribe — оплатить подписку на 30 дней.

    +

    Можно попросить идею обычными словами: «предложи идею для поста», «о чём написать?», «накидай тему».

    """, + ), +} + +HELP_SECTION_ORDER: tuple[str, ...] = tuple(HELP_SECTIONS) +HELP_BACK = "⬅️ В меню" +HELP_PREV = "⬅️ Предыдущий" +HELP_NEXT = "➡️ Следующий" + + +def _section_nav(section: str) -> str: + buttons = [f'{HELP_BACK}'] + index = HELP_SECTION_ORDER.index(section) + if index > 0: + previous = HELP_SECTION_ORDER[index - 1] + buttons.append( + f'{HELP_PREV}' + ) + if index + 1 < len(HELP_SECTION_ORDER): + following = HELP_SECTION_ORDER[index + 1] + buttons.append( + f'{HELP_NEXT}' + ) + return '' + "".join(buttons) + "" + + +def help_menu_html() -> str: + buttons = [ + f'' + f"{HELP_SECTIONS[section][0]}" + for section in HELP_SECTION_ORDER + ] + pairs = "".join( + '' + "".join(buttons[i : i + 2]) + "" + for i in range(0, len(buttons), 2) + ) + return ( + f"

    {HELP_MENU_TITLE}

    " + f"

    {HELP_MENU_INTRO}

    " + f"

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

    " + f"{pairs}" + ) + + +def help_section_html(section: str) -> str: + title, body = HELP_SECTIONS[section] + return f"

    {title}

    {body}{_section_nav(section)}" + + +def help_view(section: str | None = None) -> str: + if section and section in HELP_SECTIONS: + return help_section_html(section) + return help_menu_html() + + +def help_plain_view(section: str | None = None) -> str: + if section and section in HELP_SECTIONS: + title, body = HELP_SECTIONS[section] + return f"{HELP_MENU_TITLE} — {title}\n\n{rich_to_plain(body)}" + lines = [HELP_MENU_TITLE, HELP_MENU_INTRO] + lines += [f"• {HELP_SECTIONS[name][0]}" for name in HELP_SECTION_ORDER] + return "\n".join(lines) def rich_to_plain(rich_html: str) -> str: diff --git a/requirements.txt b/requirements.txt index beac509..ba443f0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -aiogram==3.26.0 +aiogram==3.31.0 aiohttp>=3.9,<4 diff --git a/tests/test_release_help.py b/tests/test_release_help.py index fd25e7e..7bc9a36 100644 --- a/tests/test_release_help.py +++ b/tests/test_release_help.py @@ -1,7 +1,12 @@ +from __future__ import annotations + import asyncio import unittest +from datetime import datetime from unittest.mock import AsyncMock, patch +from aiogram.types import Chat, Message + import bot @@ -51,19 +56,56 @@ class HelpRoutingTests(unittest.TestCase): self.assertIn("elif command == \"/idea\"", source) self.assertNotIn("idea_voice", source) - def test_help_rich_message_contains_usage_scenarios(self): - from help_message import HELP_RICH_HTML, rich_to_plain + def test_help_sections_cover_usage_scenarios(self): + from help_message import HELP_SECTIONS, help_plain_view, rich_to_plain - text = rich_to_plain(HELP_RICH_HTML) + text = "\n".join(rich_to_plain(body) for _title, body in HELP_SECTIONS.values()) self.assertIn("/clear", text) self.assertIn("/idea", text) self.assertNotIn("/idea_voice", text) for term in ("пост", "голосовое", "скриншот", "реплаем", "/help"): self.assertIn(term, text) + menu = help_plain_view(None) + for title, _body in HELP_SECTIONS.values(): + self.assertIn(title, menu) + + +class HelpNavigationTests(unittest.TestCase): + def test_menu_lists_every_section_as_callback_button(self): + from help_message import HELP_SECTIONS, help_menu_html + + menu = help_menu_html() + self.assertIn(" None:")[1].split("\nasync def ")[0] - self.assertIn("await help_command(message)", start_body) + callback = AsyncMock(spec=CallbackQuery) + callback.data = data + callback.answer = AsyncMock() + callback.message = message + return callback + + async def test_help_callback_edits_rich_message(self): + message = Message( + message_id=77, + date=datetime.now(), + chat=Chat(id=5, type="private"), + ) + 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) + + edit.assert_awaited_once_with(message, bot.help_view("tone")) + callback.answer.assert_awaited_once_with() + + async def test_help_callback_reports_error_when_edit_fails(self): + message = Message( + message_id=78, + date=datetime.now(), + chat=Chat(id=5, type="private"), + ) + callback = self._callback("help:menu", message) + + with patch.object(bot, "edit_rich_message", new=AsyncMock(return_value=False)): + await bot.help_navigation(callback) + + callback.answer.assert_awaited_once_with("Не удалось обновить справку, открой /help заново", show_alert=True) + + async def test_help_callback_ignores_inaccessible_message(self): + callback = self._callback("help:posts", None) + + with patch.object(bot, "edit_rich_message", new=AsyncMock()) as edit: + await bot.help_navigation(callback) + + edit.assert_not_awaited() def test_welcome_message_has_examples_and_help_handoff(self): from help_message import WELCOME_RICH_HTML, rich_to_plain @@ -97,4 +176,4 @@ class OnboardingTests(unittest.IsolatedAsyncioTestCase): if __name__ == "__main__": - unittest.main() + unittest.main() \ No newline at end of file