feat: reply-клавиатура + фикс пропавший _send_idea

- 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
This commit is contained in:
admin committed 2026-10-07 11:30:39 +02:00
1 parent 3c8917c20a
commit 971bf115da
7 files changed
+312 -11

No files matched your search

+1 -1
View File
@@ -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"]
+3 -1
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+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)
+20 -2
View File
@@ -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`).
+1 -1
View File
@@ -1 +1 @@
0.1.0+25
0.1.0+26
+77 -6
View File
@@ -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"<b>Твой стиль</b>\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)
+70
View File
@@ -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}»."
+140
View File
@@ -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()