feat: перевести навигацию справки на inline-клавиатуру
This commit is contained in:
1 parent
da0da91479
commit
a11b5aac64
7 files changed
+105
-90
No files matched your search
@@ -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`
|
||||
|
||||
@@ -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 `//` — префикс заметки, которая не уходит пользователю),
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user