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:
1 parent
a95ae7ae5b
commit
87e421ae73
11 files changed
+936
-24
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user