Files
social-post-writer-bot/keyboards.py
T
admin 87e421ae73 feat: stream operation status as a live draft with activity phrases
Статус долгой операции больше не оставляет в чате отдельное сообщение:
в приватном чате он стримится живым драфтом (sendMessageDraft), пока
идет классификация, генерация, чтение скриншотов или распознавание речи.

- activity_phrases.py: пулы фраз по типу операции (generation, training,
  images, voice, ideas), случайный интервал 3-8 сек (ACTIVITY_MIN_SECONDS /
  ACTIVITY_MAX_SECONDS), фраза не повторяет предыдущую
- OperationStatus: драфт со стабильным draft_id, фоновая ротация фраз,
  finish() отправляет финальный статус реальным сообщением, retarget()
  меняет пул на лету (generate_ideas), предохранитель ACTIVITY_MAX_ROTATIONS
- в группах и топиках драфты недоступны: прежний answer + edit_text,
  ротация фраз отключена; при ошибке драфта автоматический откат
- Dockerfile копирует activity_phrases.py и support.py, иначе контейнер
  падает на импорте

Проверено: 172 теста OK,其中包括 14 новых в tests/test_activity_phrases.py.
Тесты нашли и закрыли баг: ротация запускалась и в группах, где каждый тик
был бы реальным редактированием сообщения.

VERSION: 0.1.0+48 -> 0.1.0+49
2026-10-11 14:51:07 +02:00

89 lines
3.3 KiB
Python

"""Клавиатуры главного меню и пользовательских режимов бота."""
from __future__ import annotations
from aiogram.types import (
KeyboardButton,
ReplyKeyboardMarkup,
)
from user_store import SUBSCRIPTION_PRICE_STARS
MODE_BUTTONS: dict[str, str] = {
"🎓 Обучение": "/training",
"✍️ Генерация": "/generate",
"💳 Подписка": "/subscription",
"📖 Помощь": "/help",
}
MAIN_MENU_BUTTON = "🏠 Главное меню"
MAIN_MENU_VALUE = "__main_menu__"
TRAINING_RESET_BUTTON = "🗑 Сбросить данные"
TRAINING_RESET_VALUE = "__training_reset__"
POST_GENERATE_BUTTON = "✍️ Придумать пост"
SUPPORT_BUTTON = "🆘 Поддержка"
SUPPORT_VALUE = "__support__"
BUTTON_TEXT_MAP: dict[str, str] = {
**MODE_BUTTONS,
MAIN_MENU_BUTTON: MAIN_MENU_VALUE,
"Главное меню": MAIN_MENU_VALUE,
TRAINING_RESET_BUTTON: TRAINING_RESET_VALUE,
POST_GENERATE_BUTTON: "/post",
SUPPORT_BUTTON: SUPPORT_VALUE,
}
def main_menu_keyboard() -> ReplyKeyboardMarkup:
"""Показывает только четыре доступных пользователю режима."""
return ReplyKeyboardMarkup(
keyboard=[
[KeyboardButton(text="🎓 Обучение"), KeyboardButton(text="✍️ Генерация")],
[KeyboardButton(text="💳 Подписка"), KeyboardButton(text="📖 Помощь")],
],
resize_keyboard=True,
input_field_placeholder="Выбери режим",
)
def mode_keyboard(mode: str, support: bool = False) -> ReplyKeyboardMarkup:
"""Внутри режима показывает релевантные действия и возврат в меню.
В активном обращении поддержки остаётся только кнопка «Главное меню».
"""
rows = [[KeyboardButton(text=MAIN_MENU_BUTTON)]]
if support:
return ReplyKeyboardMarkup(
keyboard=rows,
resize_keyboard=True,
input_field_placeholder="Опиши проблему или вернись в главное меню",
)
if mode == "training":
rows.append([KeyboardButton(text=TRAINING_RESET_BUTTON)])
elif mode == "generation":
rows.insert(0, [KeyboardButton(text=POST_GENERATE_BUTTON)])
elif mode == "help":
rows.insert(0, [KeyboardButton(text=SUPPORT_BUTTON)])
return ReplyKeyboardMarkup(
keyboard=rows,
resize_keyboard=True,
input_field_placeholder=(
"Управляй обучением или вернись в меню"
if mode == "training"
else "Напиши сообщение или вернись в главное меню"
),
)
MAIN_MENU = main_menu_keyboard()
LOCKED_MENU = mode_keyboard("subscription")
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} звёзд."