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

+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)