feat: полноширинные кнопки справки + переход в любой раздел

- каждая кнопка в отдельном <tg-button-row align="left"> — во всю ширину сообщения
- из любого раздела доступны кнопки на все остальные (было: «предыдущий»/«следующий»)
- текущий раздел рендерится как <tg-button type="disabled">, чтобы не было активной кнопки на себя
- «⌂ В меню» остаётся отдельной полноширинной кнопкой
- 59 тестов, VERSION 0.1.0+24
This commit is contained in:
admin committed 2026-10-07 09:52:57 +02:00
1 parent c85f0a34fe
commit b586d3aa36
5 files changed
+56 -47

No files matched your search

+2 -2
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+23`).
- `DEPLOY_ENV=development`, `DEV_CHAT_ID` — для уведомления о новой версии; версия в файле `VERSION` (сейчас `0.1.0+24`).
- Вики Gitea: страницы `Home`, `User-Guide`, `Developer-Guide`, `Subscription`, `Deployment`.
## Статус
@@ -67,7 +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` разбита на разделы с навигацией инлайн-кнопками во всю ширину сообщения: из любого раздела доступна кнопка на любой другой, текущий раздел — `type="disabled"` (aiogram 3.31.0: `send_rich_message` + `edit_message_text(rich_message=…)`)
- [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`; правки справки автоматически меняют онбординг
+2 -2
View File
@@ -59,7 +59,7 @@ bot.py — роутинг, команды, обработка со
apinex_client.py — клиент APInex (LLM, STT, vision), промпты, логирование вызовов
user_store.py — SQLite: пользователи, ToV, посты, идеи, API-логи, подписки
help_message.py — Rich Message: `WELCOME_RICH_HTML` (онбординг) + `HELP_SECTIONS`/`help_view()` (справка с кнопками), plain fallback
VERSION — версия релиза (сейчас 0.1.0+23)
VERSION — версия релиза (сейчас 0.1.0+24)
```
### Основные потоки
@@ -168,7 +168,7 @@ SUBSCRIPTION_PRICE_STARS = 100
| Команда | Что делает | Доступна без подписки |
|---|---|---|
| `/start` | Онбординг (welcome Rich Message + справка) + создание триала | ✅ |
| `/help` | Меню справки: разделы с инлайн-кнопками + plain fallback | ✅ |
| `/help` | Меню справки: полноширинные кнопки разделов, переход в любой раздел + plain fallback | ✅ |
| `/balance` | Статус подписки / триала | ✅ |
| `/subscribe` | Активация подписки (100 звёзд, 30 дней) | ✅ |
| `/idea` | Одна идея для поста | ❌ (нужна подписка) |
+1 -1
View File
@@ -1 +1 @@
0.1.0+23
0.1.0+24
+26 -30
View File
@@ -15,7 +15,7 @@ WELCOME_RICH_HTML = """<h1>Привет! Я — бот для написания
"""
HELP_MENU_TITLE = "Помощь по боту"
HELP_MENU_INTRO = "Выбери раздел — откроется только он."
HELP_MENU_INTRO = "Выбери раздел — откроется только он. Из любого раздела можно перейти в любой."
HELP_SECTIONS: dict[str, tuple[str, str]] = {
"posts": (
"✍️ Написать пост",
@@ -56,55 +56,51 @@ HELP_SECTIONS: dict[str, tuple[str, str]] = {
}
HELP_SECTION_ORDER: tuple[str, ...] = tuple(HELP_SECTIONS)
HELP_BACK = "⬅️ В меню"
HELP_PREV = "⬅️ Предыдущий"
HELP_NEXT = "➡️ Следующий"
HELP_MENU_BUTTON = "⌂ В меню"
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 _menu_buttons(current: str | None = None) -> str:
rows = []
for section in HELP_SECTION_ORDER:
title = HELP_SECTIONS[section][0]
if section == current:
button = f'<tg-button type="disabled">{title}</tg-button>'
else:
button = (
f'<tg-button type="callback_data" data="help:{section}" style="primary">'
f"{title}</tg-button>"
)
rows.append(f'<tg-button-row align="left">{button}</tg-button-row>')
return "".join(rows)
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)
def _menu_button_row() -> str:
return (
f'<tg-button-row align="left">'
f'<tg-button type="callback_data" data="help:menu" style="primary">'
f"{HELP_MENU_BUTTON}</tg-button></tg-button-row>"
)
def help_menu_html(current: str | None = None) -> str:
return (
f"<h1>{HELP_MENU_TITLE}</h1>"
f"<p>{HELP_MENU_INTRO}</p>"
f"<p>Я помогаю писать и редактировать посты для соцсетей. "
f"Генерирую посты только по явной просьбе.</p>"
f"{pairs}"
f"{_menu_buttons(current)}"
)
def help_section_html(section: str) -> str:
title, body = HELP_SECTIONS[section]
return f"<h1>{title}</h1>{body}{_section_nav(section)}"
return f"<h1>{title}</h1>{body}{_menu_button_row()}{_menu_buttons(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()
return help_menu_html(section)
def help_plain_view(section: str | None = None) -> str:
+25 -12
View File
@@ -71,26 +71,39 @@ class HelpRoutingTests(unittest.TestCase):
class HelpNavigationTests(unittest.TestCase):
def test_menu_lists_every_section_as_callback_button(self):
from help_message import HELP_SECTIONS, help_menu_html
def test_menu_lists_every_section_as_full_width_button(self):
from help_message import HELP_SECTIONS, HELP_SECTION_ORDER, help_menu_html
menu = help_menu_html()
self.assertIn("<tg-button-row", menu)
rows = menu.split("<tg-button-row")[1:]
self.assertEqual(len(rows), len(HELP_SECTION_ORDER))
for row in rows:
self.assertTrue(row.startswith(' align="left">'), row)
self.assertEqual(row.count("<tg-button "), 1, row)
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
def test_section_navigates_to_every_other_section(self):
from help_message import HELP_SECTIONS, 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)
for section in HELP_SECTIONS:
html = help_section_html(section)
self.assertIn('data="help:menu"', html)
for other in HELP_SECTIONS:
if other == section:
continue
self.assertIn(f'data="help:{other}"', html, section)
last = help_section_html("commands")
self.assertIn('data="help:menu"', last)
self.assertNotIn("Следующий", last)
def test_current_section_is_disabled(self):
from help_message import HELP_SECTIONS, help_section_html
html = help_section_html("tone")
self.assertIn('<tg-button type="disabled">🎨 Tone of Voice</tg-button>', html)
self.assertNotIn('data="help:tone"', html)
for section in HELP_SECTIONS:
if section != "tone":
self.assertIn(f'data="help:{section}" style="primary"', html)
def test_unknown_section_falls_back_to_menu(self):
from help_message import help_view