diff --git a/IDEA.MD b/IDEA.MD index 5fc5a81..455395a 100644 --- a/IDEA.MD +++ b/IDEA.MD @@ -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:
` открывает выбранный раздел, а кнопки остаются доступны со всех страниц. Команды `/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:
` прежний. - [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:
` открывает раздел, кнопки доступны на всех страницах. - [x] Проект Gitea «Social Post Writer Bot Roadmap» с задачами (выполненные — в «Готово», будущие — в Backlog) - [x] Ротационный файл лога `/app/data/bot.log` (5 MiB × 3 архива) на persistent volume - [x] Кнопка «✍️ Придумать пост» и `/post`: самостоятельный пост по обученному профилю, гейт по `users.is_trained` diff --git a/README.md b/README.md index 37696ba..a4c0947 100644 --- a/README.md +++ b/README.md @@ -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:
`. ### Поддержка (`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:
` → `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 `//` — префикс заметки, которая не уходит пользователю), diff --git a/VERSION b/VERSION index 2d5d1a2..e7f1a84 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+60 \ No newline at end of file +0.1.0+61 \ No newline at end of file diff --git a/bot.py b/bot.py index f12978d..9fac1c6 100644 --- a/bot.py +++ b/bot.py @@ -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"), diff --git a/help_message.py b/help_message.py index c36aeeb..cb111fa 100644 --- a/help_message.py +++ b/help_message.py @@ -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'{title}' - else: - button = ( - f'' - f"{title}" - ) - 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'{"".join(row)}') - row = [] - row_width = 0 - separator_width = 0 - row.append(button) - row_width += separator_width + button_width - if row: - rows.append(f'{"".join(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"

{HELP_MENU_INTRO}

" f"

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

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

{title}

{body}{_menu_buttons(section)}" + return f"

{title}

{body}" def help_view(section: str | None = None) -> str: diff --git a/keyboards.py b/keyboards.py index a5fcee1..8900627 100644 --- a/keyboards.py +++ b/keyboards.py @@ -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") diff --git a/tests/test_release_help.py b/tests/test_release_help.py index ba5fbe8..a96ac87 100644 --- a/tests/test_release_help.py +++ b/tests/test_release_help.py @@ -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("'), row) - self.assertLessEqual(row.count("{title}" 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}", menu) - self.assertNotIn('data="help:menu"', menu) - self.assertNotIn("В меню", menu) + self.assertNotIn("🎨 Tone of Voice', 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):