feat: /help разбита на разделы с навигацией инлайн-кнопками (aiogram 3.31.0)

- requirements.txt: aiogram 3.26.0 -> 3.31.0 (нативный send_rich_message + edit_message_text(rich_message=...))
- help_message.py: HELP_RICH_HTML -> HELP_SECTIONS (6 разделов) + help_menu_html/help_section_html/help_view/help_plain_view
- навигация на HTML-тегах <tg-button-row>/<tg-button type="callback_data"> (в меню 2 кнопки в ряд, в разделе «В меню» + «Предыдущий»/«Следующий»)
- bot.py: самописный raw-запрос sendRichMessage заменён на bot.send_rich_message; edit_rich_message + callback help_navigation
- онбординг по-прежнему вызывает help_command — кнопки появляются и там
- тест поймал реальный баг: в help_navigation пропущен await у edit_rich_message
- 58 тестов, VERSION 0.1.0+23
This commit is contained in:
admin committed 2026-10-06 23:15:47 +02:00
1 parent 0ff0e1abe7
commit c85f0a34fe
7 files changed
+236 -69

No files matched your search

+2 -1
View File
@@ -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-теги `<tg-button-row>`/`<tg-button type="callback_data">`)
- [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`; правки справки автоматически меняют онбординг
+8 -6
View File
@@ -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:<section>` → `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`).
+1 -1
View File
@@ -1 +1 @@
0.1.0+22
0.1.0+23
+40 -33
View File
@@ -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), "помощи")
+94 -16
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import html
WELCOME_RICH_HTML = """<h1>Привет! Я — бот для написания постов</h1>
<p>Помогаю писать и редактировать посты для соцсетей. Расскажи, о чём нужен пост, а я верну три варианта: короткий, развёрнутый и с призывом к действию.</p>
<h2>Примеры запросов</h2>
@@ -12,30 +13,107 @@ WELCOME_RICH_HTML = """<h1>Привет! Я — бот для написания
<p>Также я умею запоминать твой стиль: пришли скриншоты своих постов. Хочешь идею — напиши «предложи тему».</p>
<p>Ниже — подробная инструкция по работе с ботом.</p>
"""
HELP_RICH_HTML = """<h1>Помощь по боту</h1>
<p>Я помогаю писать и редактировать посты для социальных сетей. Генерирую посты только по явной просьбе.</p>
<hr/>
<h2>Написать пост</h2>
<ol>
HELP_MENU_TITLE = "Помощь по боту"
HELP_MENU_INTRO = "Выбери раздел — откроется только он."
HELP_SECTIONS: dict[str, tuple[str, str]] = {
"posts": (
"✍️ Написать пост",
"""<ol>
<li>Напиши сообщение или отправь голосовое с прямой просьбой, например: «Придумай пост для Telegram про запуск нового продукта».</li>
<li>Можешь указать площадку, аудиторию, факты, длину, цель и другие требования. Можно надиктовать подробное ТЗ или просто тему.</li>
<li>Я распознаю речь, проверю, просишь ли ты именно написать пост, и верну три варианта отдельными сообщениями.</li>
</ol>
<p>В сообщениях с вариантами будет только текст поста: без нумерации и вступлений, с абзацами, переносами строк и уместными эмодзи.</p>
<h2>Настроить Tone of Voice</h2>
<p>Пришли скриншот или галерею скриншотов своих опубликованных постов. Я распознаю текст постов и сохраню его как образцы, дополню предыдущие примеры и обновлю только правила Tone of Voice. Сами публикации не переписываю.</p>
<h2>Попросить правку</h2>
<p>Ответь реплаем на сообщение с вариантом и укажи, что изменить. Например: «Сделай короче и добавь призыв». Я учту оригинальный текст и верну три новых варианта отдельными сообщениями.</p>
<h2>Подписка</h2>
<p>У тебя есть пробный период — 3 дня бесплатно. Дальше — подписка на 30 дней за 100 звёзд.</p>
<p>В сообщениях с вариантами будет только текст поста: без нумерации и вступлений, с абзацами, переносами строк и уместными эмодзи.</p>""",
),
"tone": (
"🎨 Tone of Voice",
"""<p>Пришли скриншот или галерею скриншотов своих опубликованных постов. Я распознаю текст постов и сохраню его как образцы, дополню предыдущие примеры и обновлю только правила Tone of Voice. Сами публикации не переписываю.</p>""",
),
"edit": (
"✏️ Правка поста",
"""<p>Ответь реплаем на сообщение с вариантом и укажи, что изменить. Например: «Сделай короче и добавь призыв». Я учту оригинальный текст и верну три новых варианта отдельными сообщениями.</p>""",
),
"idea": (
"💡 Идея для поста",
"""<p>Напиши «предложи идею для поста», «о чём написать?» или «накидай тему» — верну одну идею. Команда <code>/idea</code> работает так же.</p>
<p>Идеи сохраняются, чтобы не повторяться: каждая новая идея отличается от предыдущих.</p>""",
),
"subscription": (
"💳 Подписка",
"""<p>У тебя есть пробный период — 3 дня бесплатно. Дальше — подписка на 30 дней за 100 звёзд.</p>
<ul>
<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>
<h2>Команды</h2>
<p>Быстрые slash-команды только на английском: <code>/help</code> — эта инструкция; <code>/start</code> — краткое описание бота; <code>/clear</code> — полностью обнулить твои данные бота, кроме технических API-логов; <code>/idea</code> — предложить одну идею для нового поста; <code>/balance</code> — статус подписки; <code>/subscribe</code> — оплатить подписку на 30 дней. Можно попросить идею обычными словами: «предложи идею для поста», «о чём написать?», «накидай тему».</p>
"""
<p>Когда период закончится, генерация постов и идей станет недоступна — бот предложит оформить подписку. Команды <code>/help</code>, <code>/balance</code>, <code>/subscribe</code>, <code>/start</code> и <code>/clear</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>""",
),
}
HELP_SECTION_ORDER: tuple[str, ...] = tuple(HELP_SECTIONS)
HELP_BACK = "⬅️ В меню"
HELP_PREV = "⬅️ Предыдущий"
HELP_NEXT = "➡️ Следующий"
def _section_nav(section: str) -> str:
buttons = [f'<tg-button type="callback_data" data="help:menu" style="primary">{HELP_BACK}</tg-button>']
index = HELP_SECTION_ORDER.index(section)
if index > 0:
previous = HELP_SECTION_ORDER[index - 1]
buttons.append(
f'<tg-button type="callback_data" data="help:{previous}">{HELP_PREV}</tg-button>'
)
if index + 1 < len(HELP_SECTION_ORDER):
following = HELP_SECTION_ORDER[index + 1]
buttons.append(
f'<tg-button type="callback_data" data="help:{following}">{HELP_NEXT}</tg-button>'
)
return '<tg-button-row align="center">' + "".join(buttons) + "</tg-button-row>"
def help_menu_html() -> str:
buttons = [
f'<tg-button type="callback_data" data="help:{section}" style="primary">'
f"{HELP_SECTIONS[section][0]}</tg-button>"
for section in HELP_SECTION_ORDER
]
pairs = "".join(
'<tg-button-row align="center">' + "".join(buttons[i : i + 2]) + "</tg-button-row>"
for i in range(0, len(buttons), 2)
)
return (
f"<h1>{HELP_MENU_TITLE}</h1>"
f"<p>{HELP_MENU_INTRO}</p>"
f"<p>Я помогаю писать и редактировать посты для соцсетей. "
f"Генерирую посты только по явной просьбе.</p>"
f"{pairs}"
)
def help_section_html(section: str) -> str:
title, body = HELP_SECTIONS[section]
return f"<h1>{title}</h1>{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:
+1 -1
View File
@@ -1,2 +1,2 @@
aiogram==3.26.0
aiogram==3.31.0
aiohttp>=3.9,<4
+90 -11
View File
@@ -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("<tg-button-row", menu)
for section in HELP_SECTIONS:
self.assertIn(f'data="help:{section}"', menu)
self.assertNotIn('data="help:menu"', menu)
def test_section_has_menu_back_and_step_navigation(self):
from help_message import help_section_html
posts = help_section_html("posts")
self.assertIn('data="help:menu"', posts)
self.assertIn('data="help:tone"', posts)
self.assertNotIn('data="help:commands"', posts)
last = help_section_html("commands")
self.assertIn('data="help:menu"', last)
self.assertNotIn("Следующий", last)
def test_unknown_section_falls_back_to_menu(self):
from help_message import help_view
self.assertEqual(help_view("nope"), help_view(None))
def test_plain_fallback_has_menu_and_section_text(self):
from help_message import help_plain_view
self.assertIn("Помощь по боту", help_plain_view(None))
self.assertIn("Написать пост", help_plain_view("posts"))
class OnboardingTests(unittest.IsolatedAsyncioTestCase):
async def test_start_sends_welcome_rich_then_help_command(self):
async def test_start_sends_welcome_rich_then_help_menu(self):
message = AsyncMock()
message.from_user.id = 42
message.message_thread_id = None
@@ -79,14 +121,51 @@ class OnboardingTests(unittest.IsolatedAsyncioTestCase):
patch.object(bot, "send_rich_message", new=fake_rich),
):
await bot.start(message)
self.assertEqual(calls, [bot.WELCOME_RICH_HTML, bot.HELP_RICH_HTML])
self.assertEqual(calls, [bot.WELCOME_RICH_HTML, bot.help_view(None)])
async def test_start_help_step_reuses_help_command(self):
from pathlib import Path
def _callback(self, data: str, message: Message | None):
from aiogram.types import CallbackQuery
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)
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()