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
This commit is contained in:
admin committed 2026-10-11 14:51:07 +02:00
1 parent a95ae7ae5b
commit 87e421ae73
11 files changed
+936 -24

No files matched your search

+184 -14
View File
@@ -30,12 +30,18 @@ from aiogram.types import (
import payments
import user_store
from activity_phrases import (
ACTIVITY_MAX_ROTATIONS,
next_phrase,
random_interval,
)
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, TRAINING_RESET_VALUE, main_menu_keyboard, mode_keyboard, resolve_button_text
from keyboards import MAIN_MENU_VALUE, SUPPORT_VALUE, TRAINING_RESET_VALUE, main_menu_keyboard, mode_keyboard, resolve_button_text
from payments import send_subscription_invoice
import support
from user_store import (
add_post_samples,
clear_user_data,
@@ -153,6 +159,16 @@ def keyboard_for_mode(mode: str) -> ReplyKeyboardMarkup:
async def enter_mode(message: Message, state: FSMContext, mode: str) -> None:
if mode != "help" and message.from_user is not None:
try:
if await asyncio.to_thread(support.is_support_active, message.from_user.id):
await message.answer(
"Идёт обращение в поддержку. Нажми «Главное меню», чтобы завершить его.",
reply_markup=mode_keyboard("help", support=True),
)
return
except Exception as exc:
logger.warning("Support active lookup failed (%s)", type(exc).__name__)
await state.clear()
await state.set_state(MODE_STATES[mode])
title, description = MODE_COPY[mode]
@@ -168,11 +184,29 @@ async def enter_mode(message: Message, state: FSMContext, mode: str) -> None:
async def show_main_menu(message: Message, state: FSMContext) -> None:
"""Выход в главное меню; активное обращение поддержки закрывается."""
if message.from_user is not None:
try:
await support.deactivate(message.from_user.id)
except Exception as exc:
logger.warning("Support deactivate failed (%s)", type(exc).__name__)
await state.clear()
await state.set_state(BotMode.lobby)
await message.answer("Выбери режим работы.", reply_markup=main_menu_keyboard())
async def open_support(message: Message, state: FSMContext) -> None:
"""Открывает обращение в поддержку из режима «Помощь»."""
if message.from_user is None:
await message.answer("Не удалось определить пользователя.")
return
await support.start_support(message)
await message.answer(
"Напиши сообщение — я передам его оператору.",
reply_markup=mode_keyboard("help", support=True),
)
async def require_mode(message: Message, state: FSMContext, mode: str) -> bool:
if await current_mode(state) == mode:
return True
@@ -562,7 +596,7 @@ async def process_training_text(message: Message, text: str) -> None:
await message.answer("Не удалось определить пользователя или бота.")
return
with diagnostic_operation():
status = await OperationStatus.start(message, "⏳ Обрабатываю сведения об аккаунте…")
status = await OperationStatus.start(message, "⏳ Обрабатываю сведения об аккаунте…", kind="training")
try:
async with typing_status(message.bot, message.chat.id):
await status.update("⏳ Обновляю профиль аккаунта…")
@@ -670,7 +704,7 @@ async def _generate_post_from_training_inner(message: Message) -> None:
)
return
status = await OperationStatus.start(message, "⏳ Выбираю тему по обученным данным…")
status = await OperationStatus.start(message, "⏳ Выбираю тему по обученным данным…", kind="generation")
try:
tone, samples, notes = await asyncio.gather(
asyncio.to_thread(get_tone_of_voice, user_id),
@@ -752,20 +786,104 @@ async def typing_status(bot: Bot, chat_id: int):
class OperationStatus:
"""A user-visible status message edited throughout a long operation."""
"""A user-visible status shown throughout a long operation.
def __init__(self, status_message: Message | None):
In private chats the status is streamed as an ephemeral live draft
(``sendMessageDraft``): it costs no per-chat send budget, leaves no
permanent message behind and animates as the text grows. Every
``ACTIVITY_MIN_SECONDS..ACTIVITY_MAX_SECONDS`` seconds the draft is
replaced with a random phrase from the pool of the operation kind, so the
user sees that work is still in progress.
Anywhere else (groups, topics, missing bot handle, API failure) the status
degrades to the legacy path: one real message edited in place.
"""
def __init__(
self,
message: Message | None = None,
status_message: Message | None = None,
*,
kind: str | None = None,
draft_id: int | None = None,
):
self.message = message
self.status_message = status_message
self.kind = kind
self.draft_id = draft_id
self._rotation_task: asyncio.Task | None = None
self._last_text = ""
@classmethod
async def start(cls, message: Message, text: str) -> "OperationStatus":
async def start(cls, message: Message, text: str, *, kind: str | None = None) -> "OperationStatus":
if _draft_supported(message):
status = cls(message=message, kind=kind, draft_id=random.randint(1, 2**31 - 1))
await status.update(text)
else:
try:
status = cls(message=message, status_message=await message.answer(text), kind=kind)
except Exception as exc:
logger.warning("Status message send failed (%s): %s", type(exc).__name__, str(exc)[:120])
status = cls(message=message, kind=kind, status_message=None)
status._last_text = text
if kind is not None:
status._start_rotation()
return status
@property
def uses_draft(self) -> bool:
return self.draft_id is not None and self.message is not None
def retarget(self, kind: str | None) -> None:
"""Switch the phrase pool mid-flight and restart the rotation."""
if self.kind == kind:
return
self.kind = kind
task, self._rotation_task = self._rotation_task, None
if task is not None:
task.cancel()
if kind is not None:
self._start_rotation()
def _start_rotation(self) -> None:
# Phrase rotation only makes sense for live drafts: in a group chat
# there is no draft and every rotation would be a real message edit.
if self._rotation_task is not None or not self.uses_draft:
return
self._rotation_task = asyncio.create_task(self._rotate_phrases())
async def _rotate_phrases(self) -> None:
try:
return cls(await message.answer(text))
for _ in range(ACTIVITY_MAX_ROTATIONS):
await asyncio.sleep(random_interval())
await self.update(next_phrase(self.kind, self._last_text))
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning("Status message send failed (%s): %s", type(exc).__name__, str(exc)[:120])
return cls(None)
logger.debug("Activity phrase rotation stopped: %s", type(exc).__name__)
async def update(self, text: str) -> None:
self._last_text = text
message = self.message
draft_id = self.draft_id
if message is not None and draft_id is not None and message.bot is not None:
try:
await message.bot.send_message_draft(
chat_id=message.chat.id,
draft_id=draft_id,
text=text,
)
return
except Exception as exc:
logger.warning("Draft update failed (%s): %s", type(exc).__name__, str(exc)[:120])
self.draft_id = None
try:
self.status_message = await message.answer(text)
except Exception as send_exc:
logger.warning(
"Status fallback send failed (%s): %s", type(send_exc).__name__, str(send_exc)[:120]
)
return
if self.status_message is None:
return
try:
@@ -774,8 +892,42 @@ class OperationStatus:
logger.warning("Status message edit failed (%s): %s", type(exc).__name__, str(exc)[:120])
async def finish(self, text: str) -> None:
await self.cancel()
self._last_text = text
message = self.message
if message is not None and self.draft_id is not None:
# The draft is ephemeral: the result goes out as a real message.
self.draft_id = None
try:
await message.answer(text)
except Exception as exc:
logger.warning("Status finish send failed (%s): %s", type(exc).__name__, str(exc)[:120])
return
await self.update(text)
async def cancel(self) -> None:
"""Stop the phrase rotation without changing the visible status."""
task, self._rotation_task = self._rotation_task, None
if task is None:
return
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
def _draft_supported(message: Message | None) -> bool:
"""Live drafts only exist in private chats; groups get the legacy path."""
if message is None:
return False
if getattr(message, "bot", None) is None:
return False
chat = getattr(message, "chat", None)
if chat is None:
return False
return str(getattr(chat, "type", "") or "").lower() == "private"
async def _classify_and_respond(
message: Message,
@@ -810,11 +962,13 @@ async def _classify_and_respond_inner(
status = operation_status
try:
if show_typing and status is None:
status = await OperationStatus.start(message, "⏳ Разбираю запрос…")
status = await OperationStatus.start(message, "⏳ Разбираю запрос…", kind="generation")
elif status:
await status.update("⏳ Разбираю запрос…")
async with (typing_status(message.bot, message.chat.id) if show_typing else nullcontext()):
intent = await classify_intent(text, original_post or None, user_id=message.from_user.id)
if intent["intent"] == "generate_ideas" and status is not None:
status.retarget("ideas")
if intent["intent"] == "help":
if status:
await status.finish("✅ Открываю справку")
@@ -1035,11 +1189,15 @@ async def _handle_image_message(message: Message, bot: Bot, state: FSMContext) -
}
)
return
status = await OperationStatus.start(message, "⏳ Изучаю изображения и готовлю посты…")
status = await OperationStatus.start(
message, "⏳ Изучаю изображения и готовлю посты…", kind="images"
)
async with typing_status(bot, message.chat.id):
await _generate_posts_from_images(message, images, caption, status)
return
status = await OperationStatus.start(message, "⏳ Читаю посты на изображениях…")
status = await OperationStatus.start(
message, "⏳ Читаю посты на изображениях…", kind="images"
)
async with typing_status(bot, message.chat.id):
await status.update("⏳ Анализирую стиль и обновляю профиль…")
await _process_image_group(messages, bot, status=status)
@@ -1119,7 +1277,7 @@ async def transcribe_voice(message: Message, bot: Bot, state: FSMContext) -> Non
if (await notify_development_deploy(bot, state, message.from_user.id if message.from_user else None)) is True:
return
with diagnostic_operation():
status = await OperationStatus.start(message, "⏳ Подготавливаю аудио…")
status = await OperationStatus.start(message, "⏳ Подготавливаю аудио…", kind="voice")
try:
async with typing_status(bot, message.chat.id):
await _process_voice(message, bot, state, status=status)
@@ -1166,6 +1324,8 @@ async def handle_command_text(message: Message, state: FSMContext) -> None:
await balance_command(message, state)
elif command == "/subscribe":
await subscribe_command(message, state)
elif command == "/support":
await open_support(message, state)
else:
await message.answer("Неизвестная команда. Выбери режим или открой /help.")
@@ -1183,6 +1343,9 @@ async def handle_text(message: Message, state: FSMContext) -> None:
if button == MAIN_MENU_VALUE:
await show_main_menu(message, state)
return
if button == SUPPORT_VALUE:
await open_support(message, state)
return
if button == TRAINING_RESET_VALUE:
await clear_command(message, state)
return
@@ -1194,7 +1357,9 @@ async def handle_text(message: Message, state: FSMContext) -> None:
pending_image = (await state.get_data()).get("pending_image_request")
if mode == "generation" and pending_image:
await state.update_data(pending_image_request=None)
status = await OperationStatus.start(message, "⏳ Изучаю изображения и готовлю посты…")
status = await OperationStatus.start(
message, "⏳ Изучаю изображения и готовлю посты…", kind="images"
)
async with typing_status(message.bot, message.chat.id):
await _generate_posts_from_images(message, pending_image["images"], text, status)
return
@@ -1250,6 +1415,7 @@ async def set_main_menu(bot: Bot) -> None:
BotCommand(command="idea", description="Идея (генерация)"),
BotCommand(command="balance", description="Статус подписки"),
BotCommand(command="subscribe", description="Оплатить подписку"),
BotCommand(command="support", description="Написать в поддержку"),
]
)
except Exception as exc:
@@ -1259,6 +1425,7 @@ async def set_main_menu(bot: Bot) -> None:
async def main() -> None:
init_db()
payments.init_payments_table()
await asyncio.to_thread(support.init_support_table)
if is_development_environment():
await asyncio.to_thread(init_raw_updates_db)
token = os.environ.get("BOT_TOKEN", "").strip()
@@ -1273,6 +1440,9 @@ async def main() -> None:
configure_raw_update_logging(dp)
dp.include_router(payments.router)
dp.include_router(router)
# Группа поддержки: роутер раньше основного, чтобы служебные чаты не шли в обычные режимы.
dp.include_router(support.router)
dp.message.outer_middleware(support.SupportRelayMiddleware())
# Ставим меню один раз при старте, чтобы оно было у всех, кто писал боту.
await set_main_menu(bot)