From 971bf115da445bf547b37e733a23c936e84d2f7a Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 7 Oct 2026 11:30:39 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20reply-=D0=BA=D0=BB=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=B0=D1=82=D1=83=D1=80=D0=B0=20+=20=D1=84=D0=B8=D0=BA=D1=81?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D0=BF=D0=B0=D0=B2=D1=88=D0=B8=D0=B9=20=5F?= =?UTF-8?q?send=5Fidea?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - keyboards.py: MAIN_MENU (✍️ Написать пост / 💡 Идея / 🎨 Мой стиль / 📖 Помощь / 💳 Подписка / 👤 Баланс) и LOCKED_MENU — при блокировке первой кнопкой идёт «💳 Оплатить подписку» - кнопки не обходят роутинг: resolve_button_text превращает их в slash-команду (/help, /balance, /subscribe, /style) или готовый запрос («Напиши пост», «предложи идею…») - новая команда /style показывает сохранённый Tone of Voice и подсказывает, как его настроить - set_my_commands публикует slash-команды при старте, ошибка не роняет бот - fix: _send_idea вызывалась на bot.py, но была потеряна в коммите про подписку — /idea падал с NameError; функция восстановлена - 17 новых тестов клавиатуры; 92 теста OK; VERSION 0.1.0+26 --- Dockerfile | 2 +- IDEA.MD | 4 +- README.md | 22 ++++++- VERSION | 2 +- bot.py | 83 ++++++++++++++++++++++-- keyboards.py | 70 ++++++++++++++++++++ tests/test_keyboards.py | 140 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 312 insertions(+), 11 deletions(-) create mode 100644 keyboards.py create mode 100644 tests/test_keyboards.py diff --git a/Dockerfile b/Dockerfile index d49ece3..9a4fed1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,5 +6,5 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY bot.py apinex_client.py user_store.py help_message.py payments.py VERSION ./ +COPY bot.py apinex_client.py user_store.py help_message.py payments.py keyboards.py VERSION ./ CMD ["python", "bot.py"] diff --git a/IDEA.MD b/IDEA.MD index 305046b..6293ae7 100644 --- a/IDEA.MD +++ b/IDEA.MD @@ -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+25`). +- `DEPLOY_ENV=development`, `DEV_CHAT_ID` — для уведомления о новой версии; версия в файле `VERSION` (сейчас `0.1.0+26`). - Вики Gitea: страницы `Home`, `User-Guide`, `Developer-Guide`, `Subscription`, `Deployment`. ## Статус @@ -72,6 +72,8 @@ Telegram-бот, который по запросу пользователя г - [x] Development deployment/version notice and user-visible error details. - [x] Онбординг после `/start`: welcome Rich Message (`WELCOME_RICH_HTML`) → пауза → вызов `help_command`; правки справки автоматически меняют онбординг - [x] Подписка: триал 3 дня, `/subscribe` за 100 звёзд (30 дней), `/balance`, блокировка генерации без активной подписки +- [x] Reply-клавиатура (`keyboards.py`): основное меню при `/start`, отдельное меню с кнопкой оплаты при блокировке, кнопки мапятся на команды и запросы, slash-команды публикуются через `set_my_commands` +- [x] Исправлен баг: `_send_idea` была потеряна в коммите про подписку — `/idea` падал с `NameError` - [x] Реальная оплата Stars: `sendInvoice` (XTR) → `pre_checkout_query` → `successful_payment`; подписка активируется только после оплаты, `telegram_payment_charge_id` защищает от двойного начисления - [x] Справка бота (`/help`) описывает `/balance` и `/subscribe` - [x] Проект Gitea «Social Post Writer Bot Roadmap» с задачами (выполненные — в «Готово», будущие — в Backlog) diff --git a/README.md b/README.md index e22a3c6..453a2dd 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,23 @@ Telegram-бот для генерации постов для соцсетей. ## Как работает бот (UX) +### Reply-клавиатура + +Основное меню показывается в `/start`: + +``` +✍️ Написать пост 💡 Идея +🎨 Мой стиль 📖 Помощь +💳 Подписка 👤 Баланс +``` + +Если подписки нет, `_show_subscription_info` ставит `LOCKED_MENU` — с кнопкой «💳 Оплатить подписку» +на первом месте. Нажатие кнопки отправляет текст, который `resolve_button_text` превращает +либо в slash-команду (`/help`, `/balance`, `/subscribe`, `/style`), либо в готовый запрос +(«Напиши пост», «предложи идею для нового поста») — то есть кнопки не обходят роутинг, а идут через него. + +Slash-команды публикуются в Telegram через `set_my_commands` при старте (`set_main_menu`). + ### Онбординг (`/start`) Первое сообщение пользователя запускает онбординг — **два сообщения с паузой 0.8 сек** между ними: @@ -59,8 +76,9 @@ bot.py — роутинг, команды, обработка со payments.py — оплата Stars: sendInvoice, pre_checkout_query, successful_payment, защита от двойного списания apinex_client.py — клиент APInex (LLM, STT, vision), промпты, логирование вызовов user_store.py — SQLite: пользователи, ToV, посты, идеи, API-логи, подписки +keyboards.py — reply-клавиатура: `MAIN_MENU` (основная) и `LOCKED_MENU` (без подписки), маппинг кнопок на команды help_message.py — Rich Message: `WELCOME_RICH_HTML` (онбординг) + `HELP_SECTIONS`/`help_view()` (справка с кнопками), plain fallback -VERSION — версия релиза (сейчас 0.1.0+25) +VERSION — версия релиза (сейчас 0.1.0+26) ``` ### Основные потоки @@ -198,4 +216,4 @@ SUBSCRIPTION_PRICE_STARS = 100 python -m unittest discover -s tests -v ``` -Покрытие: STT, LLM, vision, ToV, reply-поля, изоляция пользователей, API-логи, **платежи** (инвойс, pre_checkout, successful_payment, защита от двойного списания), **справка с кнопками** (меню, разделы, callback-навигация, plain fallback), **онбординг** (welcome → вызов `help_command`), **подписки** (6 тестов: таблица, триал после `/start`, оплата, баланс, блокировка, обнуление через `/clear`). +Покрытие: STT, LLM, vision, ToV, reply-поля, изоляция пользователей, API-логи, **reply-клавиатура** (маппинг кнопок, роутинг, `/style`), **платежи** (инвойс, pre_checkout, successful_payment, защита от двойного списания), **справка с кнопками** (меню, разделы, callback-навигация, plain fallback), **онбординг** (welcome → вызов `help_command`), **подписки** (6 тестов: таблица, триал после `/start`, оплата, баланс, блокировка, обнуление через `/clear`). diff --git a/VERSION b/VERSION index f17aa32..3d72c9f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+25 +0.1.0+26 diff --git a/bot.py b/bot.py index c8ba44e..a4406d8 100644 --- a/bot.py +++ b/bot.py @@ -12,13 +12,15 @@ 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 CallbackQuery, InputRichMessage, Message +from aiogram.types import BotCommand, CallbackQuery, InputRichMessage, Message +import keyboards import payments 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 WELCOME_RICH_HTML, help_plain_view, help_view, rich_to_plain +from keyboards import LOCKED_MENU, MAIN_MENU, resolve_button_text from payments import send_subscription_invoice from user_store import ( add_post_samples, @@ -27,6 +29,7 @@ from user_store import ( get_deploy_notice_version, get_idea_history, get_post_history, + get_post_samples, get_recent_post_history_text, get_tone_of_voice, init_db, @@ -166,14 +169,19 @@ async def start(message: Message) -> None: return await notify_development_deploy(message.bot) await asyncio.to_thread(ensure_subscription, message.from_user.id) + await message.answer("Меню — внизу экрана. По нему можно написать пост, взять идею или открыть справку.") await send_rich_message(message, WELCOME_RICH_HTML, "приветствия") await asyncio.sleep(0.8) await help_command(message) + await message.answer("Меню обновлено.", reply_markup=MAIN_MENU) async def _show_subscription_info(message: Message, sub: sqlite3.Row | None) -> None: if not sub: - await message.answer("У тебя нет активной подписки или триала. Начни с команды /subscribe.") + await message.answer( + "У тебя нет активной подписки или триала. Начни с команды /subscribe.", + reply_markup=LOCKED_MENU, + ) return days_left = user_store._days_until(sub) if days_left >= 0: @@ -190,7 +198,8 @@ async def _show_subscription_info(message: Message, sub: sqlite3.Row | None) -> else: await message.answer( "Твой период (триал или подписка) истёк. " - "Оплати /subscribe, чтобы продолжить. Стоимость — 100 звёзд в месяц." + "Оплати /subscribe, чтобы продолжить. Стоимость — 100 звёзд в месяц.", + reply_markup=LOCKED_MENU, ) if sub["last_paid_at"] is None: await message.answer( @@ -230,6 +239,28 @@ async def subscribe_command(message: Message) -> None: ) +async def style_command(message: Message) -> None: + if message.from_user is None: + await message.answer("Не удалось определить пользователя.") + return + tone = await asyncio.to_thread(get_tone_of_voice, message.from_user.id) + samples = await asyncio.to_thread(get_post_samples, message.from_user.id) + if not tone and not samples: + await message.answer( + "Пока не настроен. Пришли скриншоты своих опубликованных постов — " + "я распознаю текст, сохраню образцы и построю правила твоего тона.", + reply_markup=MAIN_MENU, + ) + return + await message.answer( + f"Твой стиль\n\n{tone or 'Правила пока не сформированы.'}", + parse_mode="HTML", + reply_markup=MAIN_MENU, + ) + if samples: + await message.answer(f"Образцов сохранено: {len(samples)}.") + + async def clear_command(message: Message) -> None: if message.from_user is None: await message.answer("Не удалось определить пользователя.") @@ -262,6 +293,11 @@ async def _check_subscription(message: Message) -> bool: return True +async def _send_idea(message: Message, idea: dict[str, str], user_id: int) -> None: + await message.answer(f"{idea['title']}\n\n{idea['angle']}\n\nЗачем: {idea['why']}") + await asyncio.to_thread(save_idea, user_id, idea) + + async def _send_three_posts(message: Message, posts: list[str], user_id: int | None = None, origin: str = "generated") -> None: for post in posts: await message.answer(post) @@ -498,19 +534,27 @@ async def transcribe_voice(message: Message, bot: Bot) -> None: @router.message(F.text & F.text.startswith("/")) async def handle_command_text(message: Message) -> None: - command = (message.text or "").strip().split(maxsplit=1)[0].split("@", 1)[0].casefold() + raw = (message.text or "").strip() + # Кнопки reply-клавиатуры отправляют текст, который мапится на команду/запрос. + resolved = resolve_button_text(raw) or raw + command = resolved.split(maxsplit=1)[0].split("@", 1)[0].casefold() if command == "/help": await help_command(message) elif command == "/start": await start(message) elif command == "/clear": await clear_command(message) + elif command == "/style": + await style_command(message) elif command == "/idea": await _classify_and_respond(message, "предложи идею для нового поста") elif command == "/balance": await balance_command(message) elif command == "/subscribe": await subscribe_command(message) + elif resolved != raw: + # Это была кнопка меню, а не команда — отправляем её как обычный запрос. + await _classify_and_respond(message, resolved) else: await message.answer("Неизвестная команда. Используй /help.") @@ -518,8 +562,33 @@ async def handle_command_text(message: Message) -> None: @router.message(F.text & ~F.text.startswith("/")) async def handle_text(message: Message) -> None: await notify_development_deploy(message.bot) - if message.text: - await _classify_and_respond(message, message.text) + text = (message.text or "").strip() + if not text: + return + button = resolve_button_text(text) + if button: + if button.startswith("/"): + await handle_command_text(message) + return + text = button + await _classify_and_respond(message, text) + + +async def set_main_menu(bot: Bot) -> None: + """Публикует reply-клавиатуру для всех пользователей бота.""" + try: + await bot.set_my_commands( + commands=[ + BotCommand(command="help", description="Справка по боту"), + BotCommand(command="style", description="Мой Tone of Voice"), + BotCommand(command="idea", description="Идея для поста"), + BotCommand(command="balance", description="Статус подписки"), + BotCommand(command="subscribe", description="Оплатить подписку"), + BotCommand(command="clear", description="Сбросить мои данные"), + ] + ) + except Exception as exc: + logger.warning("Bot commands publish failed (%s): %s", type(exc).__name__, str(exc)[:120]) async def main() -> None: @@ -536,6 +605,8 @@ async def main() -> None: dp = Dispatcher() dp.include_router(payments.router) dp.include_router(router) + # Ставим меню один раз при старте, чтобы оно было у всех, кто писал боту. + await set_main_menu(bot) try: await dp.start_polling(bot) diff --git a/keyboards.py b/keyboards.py new file mode 100644 index 0000000..3fff55d --- /dev/null +++ b/keyboards.py @@ -0,0 +1,70 @@ +"""Reply-клавиатура бота: быстрый доступ к главным действиям. + +Кнопки отправляют те же тексты, что и slash-команды, поэтому роутинг в +`handle_command_text` обрабатывает их без изменений. +""" +from __future__ import annotations + +from aiogram.types import ( + KeyboardButton, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, +) + +from user_store import SUBSCRIPTION_PRICE_STARS + +# Основное меню. Текст кнопки = текст команды, который обрабатывает handle_command_text. +MAIN_MENU = ReplyKeyboardMarkup( + keyboard=[ + [ + KeyboardButton(text="✍️ Написать пост"), + KeyboardButton(text="💡 Идея"), + ], + [ + KeyboardButton(text="🎨 Мой стиль"), + KeyboardButton(text="📖 Помощь"), + ], + [ + KeyboardButton(text="💳 Подписка"), + KeyboardButton(text="👤 Баланс"), + ], + ], + resize_keyboard=True, + input_field_placeholder="Напиши тему поста или нажми кнопку", +) + +# Меню без подписки: главное — оплатить, а не генерация. +LOCKED_MENU = ReplyKeyboardMarkup( + keyboard=[ + [ + KeyboardButton(text="💳 Оплатить подписку"), + KeyboardButton(text="👤 Баланс"), + ], + [KeyboardButton(text="📖 Помощь")], + ], + resize_keyboard=True, + input_field_placeholder="Нужна подписка для генерации", +) + +# Соответствие подписей кнопок текстам, которые понимает бот. +BUTTON_TEXT_MAP: dict[str, str] = { + "✍️ Написать пост": "Напиши пост", + "💡 Идея": "предложи идею для нового поста", + "🎨 Мой стиль": "/style", + "📖 Помощь": "/help", + "💳 Подписка": "/subscribe", + "👤 Баланс": "/balance", + "💳 Оплатить подписку": "/subscribe", + "🗑 Сбросить всё": "/clear", +} + + +def resolve_button_text(text: str | None) -> str | None: + """Возвращает команду или текст запроса для нажатой кнопки, иначе None.""" + if not text: + return None + return BUTTON_TEXT_MAP.get(text.strip()) + + +def price_hint() -> str: + return f"Подписка {SUBSCRIPTION_PRICE_STARS} звёзд — кнопка «{LOCKED_MENU.keyboard[0][0].text}»." diff --git a/tests/test_keyboards.py b/tests/test_keyboards.py new file mode 100644 index 0000000..4036a0b --- /dev/null +++ b/tests/test_keyboards.py @@ -0,0 +1,140 @@ +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import bot +import keyboards + + +class ButtonTextTests(unittest.TestCase): + def test_buttons_map_to_commands_and_requests(self): + self.assertEqual(keyboards.resolve_button_text("📖 Помощь"), "/help") + self.assertEqual(keyboards.resolve_button_text("👤 Баланс"), "/balance") + self.assertEqual(keyboards.resolve_button_text("💳 Подписка"), "/subscribe") + self.assertEqual(keyboards.resolve_button_text("🎨 Мой стиль"), "/style") + + def test_idea_button_sends_request_not_command(self): + self.assertEqual( + keyboards.resolve_button_text("💡 Идея"), + "предложи идею для нового поста", + ) + + def test_regular_text_is_not_treated_as_button(self): + self.assertIsNone(keyboards.resolve_button_text("Напиши пост про запуск")) + self.assertIsNone(keyboards.resolve_button_text("привет")) + self.assertIsNone(keyboards.resolve_button_text(None)) + + def test_button_lookup_tolerates_whitespace(self): + self.assertEqual(keyboards.resolve_button_text(" 📖 Помощь "), "/help") + + +class KeyboardLayoutTests(unittest.TestCase): + def test_main_menu_has_core_actions(self): + labels = [b.text for row in keyboards.MAIN_MENU.keyboard for b in row] + for expected in ("✍️ Написать пост", "💡 Идея", "🎨 Мой стиль", "📖 Помощь"): + self.assertIn(expected, labels) + + def test_locked_menu_offers_payment_first(self): + self.assertEqual(keyboards.LOCKED_MENU.keyboard[0][0].text, "💳 Оплатить подписку") + + def test_every_button_is_resolvable(self): + for markup in (keyboards.MAIN_MENU, keyboards.LOCKED_MENU): + for row in markup.keyboard: + for button in row: + self.assertIsNotNone( + keyboards.resolve_button_text(button.text), button.text + ) + + def test_keyboards_are_resize_keyboard(self): + self.assertTrue(keyboards.MAIN_MENU.resize_keyboard) + self.assertTrue(keyboards.LOCKED_MENU.resize_keyboard) + + +class _StubMessage: + """Минимальный Message: нужны from_user, text и собираемые ответы.""" + + def __init__(self, text=None, user_id=42, chat_id=555): + self.text = text + self.from_user = SimpleNamespace(id=user_id) + self.chat = SimpleNamespace(id=chat_id) + self.message_thread_id = None + self.bot = AsyncMock() + self.answers: list[tuple[str, dict]] = [] + + async def answer(self, text, **kwargs): + self.answers.append((text, kwargs)) + + +class StyleCommandTests(unittest.IsolatedAsyncioTestCase): + async def test_style_without_tone_explains_how_to_set_it(self): + message = _StubMessage() + with patch.object(bot.asyncio, "to_thread", new=AsyncMock(side_effect=["", []])): + await bot.style_command(message) + self.assertIn("скриншот", message.answers[0][0]) + + async def test_style_shows_saved_tone(self): + message = _StubMessage() + with patch.object(bot.asyncio, "to_thread", new=AsyncMock(side_effect=["Короткие фразы", ["a", "b"]])): + await bot.style_command(message) + self.assertIn("Короткие фразы", message.answers[0][0]) + self.assertIn("Образцов сохранено: 2", message.answers[1][0]) + + +class ButtonRoutingTests(unittest.IsolatedAsyncioTestCase): + async def test_button_with_command_routes_to_command_handler(self): + message = _StubMessage("📖 Помощь") + with patch.object(bot, "help_command", new=AsyncMock()) as help_cmd: + await bot.handle_text(message) + help_cmd.assert_awaited_once_with(message) + + async def test_button_with_request_goes_to_classifier(self): + message = _StubMessage("💡 Идея") + with ( + patch.object(bot, "notify_development_deploy", new=AsyncMock()), + patch.object(bot, "_classify_and_respond", new=AsyncMock()) as classify, + ): + await bot.handle_text(message) + classify.assert_awaited_once_with(message, "предложи идею для нового поста") + + async def test_write_post_button_sends_topic_request(self): + message = _StubMessage("✍️ Написать пост") + with ( + patch.object(bot, "notify_development_deploy", new=AsyncMock()), + patch.object(bot, "_classify_and_respond", new=AsyncMock()) as classify, + ): + await bot.handle_text(message) + classify.assert_awaited_once_with(message, "Напиши пост") + + async def test_plain_text_is_not_rewritten(self): + message = _StubMessage("Напиши пост про запуск продукта") + with ( + patch.object(bot, "notify_development_deploy", new=AsyncMock()), + patch.object(bot, "_classify_and_respond", new=AsyncMock()) as classify, + ): + await bot.handle_text(message) + classify.assert_awaited_once_with(message, "Напиши пост про запуск продукта") + + async def test_style_command_is_routed(self): + message = _StubMessage("/style") + with patch.object(bot, "style_command", new=AsyncMock()) as style: + await bot.handle_command_text(message) + style.assert_awaited_once_with(message) + + +class MenuPublishTests(unittest.IsolatedAsyncioTestCase): + async def test_commands_are_published_to_telegram(self): + fake_bot = AsyncMock() + await bot.set_main_menu(fake_bot) + names = [c.command for c in fake_bot.set_my_commands.await_args.kwargs["commands"]] + self.assertIn("help", names) + self.assertIn("subscribe", names) + self.assertIn("style", names) + + async def test_publish_failure_does_not_crash_startup(self): + fake_bot = AsyncMock() + fake_bot.set_my_commands.side_effect = RuntimeError("boom") + await bot.set_main_menu(fake_bot) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file