feat: перевести навигацию справки на inline-клавиатуру

This commit is contained in:
hermes committed 2026-10-11 22:55:17 +02:00
1 parent da0da91479
commit a11b5aac64
7 files changed
+105 -90

No files matched your search

+4 -4
View File
@@ -24,7 +24,7 @@ Telegram-бот, который по запросу пользователя г
- **Подписка** — при входе показывает активный период и остаток дней; inline-кнопка продления сразу открывает Stars-инвойс (`/balance`, `/subscribe`).
- **Помощь** — справка и навигация по её разделам (`/help`).
`/start` показывает ровно одно welcome-сообщение без автоматического вызова помощи. При входе в любой режим показывается короткий onboarding. Главное меню содержит только четыре пользовательских режима; внутри каждого режима клавиатура состоит из кнопки «Главное меню». В справке `/help` кнопки разделов расположены последовательно, переносятся целиком по мере заполнения строки; отдельной кнопки «В меню» нет, из раздела доступны остальные разделы. Команды `/training`, `/generate`, `/subscription`, `/help` доступны из любого режима; профильные действия — только в своём режиме.
`/start` показывает ровно одно welcome-сообщение без автоматического вызова помощи. При входе в любой режим показывается короткий onboarding. Главное меню содержит только четыре пользовательских режима; внутри каждого режима клавиатура состоит из кнопки «Главное меню». В справке `/help` прикреплена обычная inline-клавиатура, callback `help:<section>` открывает выбранный раздел, а кнопки остаются доступны со всех страниц. Команды `/training`, `/generate`, `/subscription`, `/help` доступны из любого режима; профильные действия — только в своём режиме.
## Первая версия
- Принимать запрос обычным сообщением.
@@ -95,7 +95,7 @@ Telegram-бот, который по запросу пользователя г
- [x] `/idea` возвращает одну идею одним сообщением и сохраняет её для исключения повторов
- [x] Все отправленные варианты постов сохраняются по пользователю, включая исправленные; история используется для новых идей
- [x] Служебные ответы по-русски, язык текстов постов определяется запросом пользователя
- [x] Справка `/help` разбита на разделы с навигацией инлайн-кнопками во всю ширину сообщения: из любого раздела доступна кнопка на любой другой, текущий раздел — `type="disabled"` (aiogram 3.31.0: `send_rich_message` + `edit_message_text(rich_message=…)`)
- [x] Справка `/help` разбита на разделы: Rich Message прикреплён к обычной inline-клавиатуре; все пункты доступны на каждой странице, callback `help:<section>` прежний.
- [x] Help command uses new Rich Message, with a plain-text fallback.
- [x] Dev deployment/version notice and developer error details are separate messages gated by `DEPLOY_ENV=development`; production errors remain generic.
- [x] Онбординг `/start` отправляет одно welcome Rich Message и показывает главное меню без автопомощи
@@ -104,9 +104,9 @@ Telegram-бот, который по запросу пользователя г
- [x] Исправлен баг: `_send_idea` была потеряна в коммите про подписку — `/idea` падал с `NameError`
- [x] Реальная оплата Stars: `sendInvoice` (XTR) → `pre_checkout_query` → `successful_payment`; подписка активируется только после оплаты, `telegram_payment_charge_id` защищает от двойного начисления
- [x] В `DEPLOY_ENV=development` после обычного сообщения об инвойсе отдельным сообщением доступна кнопка «🧪 Оплатил (dev)»; callback ограничен пользователем инвойса, а все dev-ветви fail-closed выключены вне development
- Версия разработки: `0.1.0+60`
- Версия разработки: `0.1.0+61`
- [x] Статус долгой операции стримится живым драфтом `sendMessageDraft` в приватном чате и каждые 3–8 сек меняет случайную фразу из пула операции (`activity_phrases.py`); в группах и топиках работает прежний `edit_text`
- [x] Справка `/help`: кнопки разделов идут последовательно и переносятся на новую строку целиком; отдельной кнопки «В меню» нет, из раздела доступны остальные разделы
- [x] Справка `/help` отправляется как Rich Message с обычной inline-клавиатурой; каждый callback `help:<section>` открывает раздел, кнопки доступны на всех страницах.
- [x] Проект Gitea «Social Post Writer Bot Roadmap» с задачами (выполненные — в «Готово», будущие — в Backlog)
- [x] Ротационный файл лога `/app/data/bot.log` (5 MiB × 3 архива) на persistent volume
- [x] Кнопка «✍️ Придумать пост» и `/post`: самостоятельный пост по обученному профилю, гейт по `users.is_trained`
+6 -6
View File
@@ -14,7 +14,7 @@ Telegram-бот для генерации постов для соцсетей.
- [Деплой и конфигурация](#деплой-и-конфигурация)
- [Тесты](#тесты)
Версия разработки: `0.1.0+60`.
Версия разработки: `0.1.0+61`.
---
@@ -62,9 +62,9 @@ onboarding. В «Подписке» сразу отображаются тип
### Справка (`/help`)
Меню разделов и страницы справки используют последовательную раскладку inline-кнопок: кнопки
идут друг за другом, а на следующую строку переносятся целиком, когда не помещаются. Из раздела
можно перейти в любой другой; отдельной кнопки «В меню» нет, текущий раздел отключён.
Меню и страницы справки — Rich Message с обычной клавиатурой `InlineKeyboardMarkup` из `keyboards.py`.
По одной кнопке на строку; все разделы остаются доступны на каждой странице, включая текущую.
Выбор редактирует текст Rich Message без смены callback `help:<section>`.
### Поддержка (`support.py`)
@@ -252,7 +252,7 @@ update_tone_from_image → _handle_image_message
| `handle_pre_checkout` | Подтверждает только свой инвойс в XTR на 100 звёзд, всё остальное отклоняет |
| `handle_successful_payment` | Активирует подписку после реальной оплаты; `telegram_payment_charge_id` пишется в `payments` для защиты от повторного начисления |
| `clear_command` | `/clear` и кнопка «Сбросить данные» показывают inline-подтверждение; сброс только по confirm в режиме обучения |
| `help_command` | `/help` — меню справки (Rich Message с кнопками) + fallback на plain text |
| `help_command` | `/help` — меню справки (Rich Message с обычной inline-клавиатурой) + fallback на plain text |
| `help_navigation` | Callback `help:<section>` → `edit_message_text(rich_message=...)` — переключение разделов |
| `edit_rich_message` | Редактирование текущего rich-сообщения на новый раздел |
| `typing_status` | Индикатор «печатает» во время обработки |
@@ -353,7 +353,7 @@ SUBSCRIPTION_PRICE_STARS = 100
- **Coolify:** проект `Social Post Writer Bot`, окружение `development`, приложение `social-post-writer-bot-development` (`d32u5zcq2g4odmdpq9hw91yn`) на `spb-easy-dale`.
- **Деплой:** `bash /root/.hermes/scripts/gitea_deploy.sh dev` (POST `/api/v1/deploy?uuid=...&force=false`).
- **Окружение:** `DEPLOY_ENV` включает dev-функции только при точном значении `development`; production, пустое и неизвестное значение отключают их. `BOT_ENV` отдельно выбирает Telegram Bot API и не используется для включения dev-поведения.
- **VERSION:** файл `VERSION` (`0.1.0+60`). При `DEPLOY_ENV=development` и изменении версии бот шлёт уведомление о версии в `DEV_CHAT_ID`.
- **VERSION:** файл `VERSION` (`0.1.0+61`). При `DEPLOY_ENV=development` и изменении версии бот шлёт уведомление о версии в `DEV_CHAT_ID`.
- **Переменные Coolify:** `BOT_TOKEN`, `APINEX_API_KEY`, `APINEX_STT_MODEL`, `APINEX_INTENT_CLASSIFIER_MODEL`, `APINEX_INJECTION_CLASSIFIER_MODEL`, `APINEX_POST_WRITER_MODEL`, `APINEX_IMAGE_TONE_MODEL`, `APINEX_RESPONSE_REPAIR_MODEL` (optional; default uses the standard model), `BOT_DB_PATH`, `RAW_UPDATES_DB_PATH` (optional; default `/app/data/bot_updates.sqlite3`), `DEPLOY_ENV`, `DEV_CHAT_ID`, `BOT_LOG_PATH` (optional; default `/app/data/bot.log`), `ACTIVITY_MIN_SECONDS` / `ACTIVITY_MAX_SECONDS` (optional; default 3 / 8 — границы паузы между сменами фраз в драфте), `ACTIVITY_MAX_ROTATIONS` (optional; default 12 — предохранитель от бесконечной ротации),
`SUPPORT_GROUP_ID` (id супергруппы с темами для обращений; без неё поддержка выключена),
`SUPPORT_NOTE_PREFIX` (optional; default `//` — префикс заметки, которая не уходит пользователю),
+1 -1
View File
@@ -1 +1 @@
0.1.0+60
0.1.0+61
+26 -5
View File
@@ -39,7 +39,7 @@ from raw_updates import RawUpdateMiddleware, init_raw_updates_db
from apinex_client import APINEX_STT_URL, POST_HISTORY_CHAR_LIMIT, POST_HISTORY_LIMIT, classify_intent, classify_prompt_injection, create_tone_profile, current_operation_id, diagnostic_operation, extract_posts_from_images, extract_transcript, generate_ideas, generate_posts, generate_posts_from_images, transcribe_audio
from help_message import WELCOME_RICH_HTML, help_plain_view, help_view, rich_to_plain
from keyboards import MAIN_MENU_VALUE, SUPPORT_VALUE, TRAINING_RESET_VALUE, main_menu_keyboard, mode_keyboard, resolve_button_text
from keyboards import MAIN_MENU_VALUE, SUPPORT_VALUE, TRAINING_RESET_VALUE, help_inline_keyboard, main_menu_keyboard, mode_keyboard, resolve_button_text
from payments import send_subscription_invoice
import support
from user_store import (
@@ -174,7 +174,12 @@ async def enter_mode(message: Message, state: FSMContext, mode: str) -> None:
await state.set_state(MODE_STATES[mode])
title, description = MODE_COPY[mode]
if mode == "help":
await send_rich_message(message, help_view(None), "помощи")
await send_rich_message(
message,
help_view(None),
"помощи",
reply_markup=help_inline_keyboard(),
)
await message.answer(
f"Режим «{title}».\n{description}",
reply_markup=mode_keyboard(mode),
@@ -354,7 +359,11 @@ async def send_rich_message(
return None
async def edit_rich_message(message: Message, rich_html: str) -> bool:
async def edit_rich_message(
message: Message,
rich_html: str,
reply_markup: InlineKeyboardMarkup | None = None,
) -> bool:
bot = message.bot
if message.message_id is None:
return False
@@ -363,9 +372,12 @@ async def edit_rich_message(message: Message, rich_html: str) -> bool:
chat_id=message.chat.id,
message_id=message.message_id,
rich_message=InputRichMessage(html=rich_html),
reply_markup=reply_markup,
)
return True
except Exception as exc:
if "message is not modified" in str(exc).casefold():
return True
logger.warning("Rich help edit failed (%s): %s", type(exc).__name__, str(exc)[:180])
return False
@@ -379,7 +391,11 @@ async def help_navigation(callback: CallbackQuery, state: FSMContext) -> None:
await callback.answer("Сначала открой режим /help.", show_alert=True)
return
section = (callback.data or "help:menu").split(":", 1)[1]
if not await edit_rich_message(message, help_view(section)):
if not await edit_rich_message(
message,
help_view(section),
reply_markup=help_inline_keyboard(),
):
await callback.answer("Не удалось обновить справку, открой /help заново", show_alert=True)
return
await callback.answer()
@@ -686,7 +702,12 @@ async def help_command(message: Message, state: FSMContext | None = None) -> Non
if state is not None:
await enter_mode(message, state, "help")
return
await send_rich_message(message, help_view(None), "помощи")
await send_rich_message(
message,
help_view(None),
"помощи",
reply_markup=help_inline_keyboard(),
)
await message.answer(
"Режим «Помощь». Выбери раздел выше или другой режим в меню.",
reply_markup=mode_keyboard("help"),
+1 -34
View File
@@ -64,38 +64,6 @@ HELP_SECTIONS: dict[str, tuple[str, str]] = {
}
HELP_SECTION_ORDER: tuple[str, ...] = tuple(HELP_SECTIONS)
HELP_BUTTON_ROW_WIDTH = 24
def _menu_buttons(current: str | None = None) -> str:
buttons: list[tuple[str, str]] = []
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>"
)
buttons.append((title, button))
rows = []
row: list[str] = []
row_width = 0
for title, button in buttons:
button_width = len(title)
separator_width = 1 if row else 0
if row and row_width + separator_width + button_width > HELP_BUTTON_ROW_WIDTH:
rows.append(f'<tg-button-row align="left">{"".join(row)}</tg-button-row>')
row = []
row_width = 0
separator_width = 0
row.append(button)
row_width += separator_width + button_width
if row:
rows.append(f'<tg-button-row align="left">{"".join(row)}</tg-button-row>')
return "".join(rows)
def help_menu_html(current: str | None = None) -> str:
@@ -104,13 +72,12 @@ def help_menu_html(current: str | None = None) -> str:
f"<p>{HELP_MENU_INTRO}</p>"
f"<p>Я помогаю писать и редактировать посты для соцсетей. "
f"Генерирую посты только по явной просьбе.</p>"
f"{_menu_buttons(current)}"
)
def help_section_html(section: str) -> str:
title, body = HELP_SECTIONS[section]
return f"<h1>{title}</h1>{body}{_menu_buttons(section)}"
return f"<h1>{title}</h1>{body}"
def help_view(section: str | None = None) -> str:
+13
View File
@@ -2,10 +2,13 @@
from __future__ import annotations
from aiogram.types import (
InlineKeyboardButton,
InlineKeyboardMarkup,
KeyboardButton,
ReplyKeyboardMarkup,
)
from help_message import HELP_SECTIONS, HELP_SECTION_ORDER
from user_store import SUBSCRIPTION_PRICE_STARS
MODE_BUTTONS: dict[str, str] = {
@@ -73,6 +76,16 @@ def mode_keyboard(mode: str, support: bool = False) -> ReplyKeyboardMarkup:
)
def help_inline_keyboard() -> InlineKeyboardMarkup:
"""Стандартная inline-клавиатура для навигации по разделам справки."""
return InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text=HELP_SECTIONS[section][0], callback_data=f"help:{section}")]
for section in HELP_SECTION_ORDER
],
)
MAIN_MENU = main_menu_keyboard()
LOCKED_MENU = mode_keyboard("subscription")
+54 -40
View File
@@ -147,52 +147,28 @@ class HelpRoutingTests(unittest.TestCase):
class HelpNavigationTests(unittest.TestCase):
def test_menu_packs_buttons_without_truncating_labels(self):
from help_message import HELP_SECTIONS, HELP_SECTION_ORDER, help_menu_html
def test_menu_contains_no_embedded_buttons(self):
from help_message import help_menu_html
menu = help_menu_html()
rows = menu.split("<tg-button-row")[1:]
self.assertLess(len(rows), len(HELP_SECTION_ORDER))
for row in rows:
self.assertTrue(row.startswith(' align="left">'), row)
self.assertLessEqual(row.count("<tg-button "), 2, row)
labels = [
title
for title, _body in HELP_SECTIONS.values()
if f">{title}</tg-button>" in row
]
self.assertLessEqual(
sum(len(title) for title in labels) + max(0, len(labels) - 1),
24,
row,
)
for section, (title, _body) in HELP_SECTIONS.items():
self.assertIn(f'data="help:{section}"', menu)
self.assertIn(f">{title}</tg-button>", menu)
self.assertNotIn('data="help:menu"', menu)
self.assertNotIn("В меню", menu)
self.assertNotIn("<tg-button", help_menu_html())
def test_section_navigates_to_other_sections_without_menu_button(self):
def test_section_contains_no_embedded_buttons(self):
from help_message import HELP_SECTIONS, help_section_html
for section in HELP_SECTIONS:
html = help_section_html(section)
self.assertNotIn('data="help:menu"', html)
self.assertNotIn("В меню", html)
for other in HELP_SECTIONS:
if other == section:
continue
self.assertIn(f'data="help:{other}"', html, section)
with self.subTest(section=section):
self.assertNotIn("<tg-button", help_section_html(section))
def test_current_section_is_disabled(self):
from help_message import HELP_SECTIONS, help_section_html
def test_help_keyboard_has_one_button_per_section_with_stable_callback_data(self):
from help_message import HELP_SECTIONS, HELP_SECTION_ORDER
from keyboards import help_inline_keyboard
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)
rows = help_inline_keyboard().inline_keyboard
self.assertEqual(len(rows), len(HELP_SECTION_ORDER))
for section, row in zip(HELP_SECTION_ORDER, rows, strict=True):
self.assertEqual(len(row), 1)
self.assertEqual(row[0].text, HELP_SECTIONS[section][0])
self.assertEqual(row[0].callback_data, f"help:{section}")
def test_unknown_section_falls_back_to_menu(self):
from help_message import help_view
@@ -249,6 +225,40 @@ class OnboardingTests(unittest.IsolatedAsyncioTestCase):
callback.message = message
return callback
async def test_edit_rich_message_attaches_standard_keyboard(self):
message_bot = AsyncMock()
message = Message(
message_id=80,
date=datetime.now(),
chat=Chat(id=5, type="private"),
).as_(message_bot)
markup = bot.help_inline_keyboard()
result = await bot.edit_rich_message(
message,
bot.help_view("tone"),
reply_markup=markup,
)
self.assertTrue(result)
message_bot.edit_message_text.assert_awaited_once()
call = message_bot.edit_message_text.await_args.kwargs
self.assertEqual(call["chat_id"], 5)
self.assertEqual(call["message_id"], 80)
self.assertEqual(call["reply_markup"], markup)
self.assertEqual(call["rich_message"], bot.InputRichMessage(html=bot.help_view("tone")))
async def test_edit_rich_message_treats_unchanged_content_as_success(self):
message_bot = AsyncMock()
message_bot.edit_message_text.side_effect = RuntimeError("Bad Request: message is not modified")
message = Message(
message_id=81,
date=datetime.now(),
chat=Chat(id=5, type="private"),
).as_(message_bot)
self.assertTrue(await bot.edit_rich_message(message, bot.help_view("tone")))
async def test_help_callback_edits_rich_message(self):
message = Message(
message_id=77,
@@ -260,7 +270,11 @@ class OnboardingTests(unittest.IsolatedAsyncioTestCase):
with patch.object(bot, "edit_rich_message", new=AsyncMock(return_value=True)) as edit:
await bot.help_navigation(callback, _StubState("help"))
edit.assert_awaited_once_with(message, bot.help_view("tone"))
edit.assert_awaited_once_with(
message,
bot.help_view("tone"),
reply_markup=bot.help_inline_keyboard(),
)
callback.answer.assert_awaited_once_with()
async def test_help_callback_reports_error_when_edit_fails(self):