feat: show editable operation status
This commit is contained in:
1 parent
189f6bc1f0
commit
3e33a9deb5
6 files changed
+129
-15
No files matched your search
@@ -43,6 +43,7 @@ Telegram-бот, который по запросу пользователя г
|
||||
- Служебные ответы пользователю — всегда на русском; тексты постов — на языке явного запроса. В production исключения не показываются; техническая диагностика приходит отдельным сообщением только в development.
|
||||
- Настройки пользователей и распознанные тексты публикаций для накопления Tone of Voice хранятся отдельно по Telegram user id; история диалогов и исходные изображения не сохраняются.
|
||||
- На явную просьбу поста и на реплай-правку бот возвращает 3 отдельных варианта.
|
||||
- Во время обучения, чтения изображений, распознавания голосовых и генерации бот редактирует одно сообщение-статус по этапам; перед результатом показывает ✅, при ошибке — ❌.
|
||||
|
||||
## Подписка и монетизация
|
||||
- Модель: триал 3 дня, затем подписка 30 дней за 100 звёзд (Telegram Stars).
|
||||
@@ -96,7 +97,7 @@ Telegram-бот, который по запросу пользователя г
|
||||
- [x] Исправлен баг: `_send_idea` была потеряна в коммите про подписку — `/idea` падал с `NameError`
|
||||
- [x] Реальная оплата Stars: `sendInvoice` (XTR) → `pre_checkout_query` → `successful_payment`; подписка активируется только после оплаты, `telegram_payment_charge_id` защищает от двойного начисления
|
||||
- [x] В `DEPLOY_ENV=development` после обычного сообщения об инвойсе отдельным сообщением доступна кнопка «🧪 Оплатил (dev)»; callback ограничен пользователем инвойса, а все dev-ветви fail-closed выключены вне development
|
||||
- Версия разработки: `0.1.0+30`
|
||||
- Версия разработки: `0.1.0+39`
|
||||
- [x] Справка бота (`/help`) описывает `/balance` и `/subscribe`
|
||||
- [x] Проект Gitea «Social Post Writer Bot Roadmap» с задачами (выполненные — в «Готово», будущие — в Backlog)
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ Telegram-бот для генерации постов для соцсетей.
|
||||
- [Деплой и конфигурация](#деплой-и-конфигурация)
|
||||
- [Тесты](#тесты)
|
||||
|
||||
Версия разработки: `0.1.0+39`.
|
||||
|
||||
---
|
||||
|
||||
## Синхронизация документации
|
||||
@@ -69,7 +71,7 @@ onboarding. В «Подписке» сразу отображаются тип
|
||||
| Очистить данные | В режиме «Обучение» нажимает «Сбросить данные» или вводит `/clear`, затем подтверждает | Очищает стиль и историю, не меняя подписку или триал |
|
||||
|
||||
### Индикаторы и обратная связь
|
||||
- Пока **идёт обработка** (классификация, расшифровка, чтение скриншота, генерация) — бот показывает «печатает» (`typing_status`), обновляя каждые 4 секунды.
|
||||
- Пока **идёт обработка** (классификация, расшифровка, чтение скриншота, генерация) — бот показывает «печатает» (`typing_status`, обновление каждые 4 секунды) и отдельное сообщение-статус, которое редактируется по этапам; перед результатом оно меняется на ✅, при ошибке — на ❌.
|
||||
- При **ошибке** пользователь получает общий безопасный ответ. Только в development отдельным сообщением дополнительно отправляется диагностика; production не раскрывает текст исключения.
|
||||
- Служебные тексты — **всегда на русском**. Язык самого поста определяется языком запроса.
|
||||
|
||||
|
||||
@@ -473,7 +473,7 @@ async def style_command(message: Message, state: FSMContext) -> None:
|
||||
await message.answer(f"Накоплено постов: {len(samples)}. Дополнений профиля: {len(notes)}.")
|
||||
|
||||
|
||||
async def _rebuild_account_profile(user_id: int, message: Message, *, new_note: str = "", new_posts: list[str] | None = None) -> None:
|
||||
async def _rebuild_account_profile(user_id: int, message: Message, *, new_note: str = "", new_posts: list[str] | None = None, status=None) -> None:
|
||||
previous_profile = await asyncio.to_thread(get_tone_of_voice, user_id)
|
||||
if new_note.strip():
|
||||
notes = await asyncio.to_thread(add_training_note, user_id, new_note)
|
||||
@@ -483,13 +483,19 @@ async def _rebuild_account_profile(user_id: int, message: Message, *, new_note:
|
||||
samples = await asyncio.to_thread(add_post_samples, user_id, new_posts)
|
||||
else:
|
||||
samples = await asyncio.to_thread(get_post_samples, user_id)
|
||||
if status:
|
||||
await status.update("⏳ Формирую профиль аккаунта…")
|
||||
profile = await create_tone_profile(
|
||||
samples, previous_profile, user_id=user_id, account_notes=notes
|
||||
)
|
||||
if not profile:
|
||||
if status:
|
||||
await status.finish("⚠️ Недостаточно материалов для обновления профиля")
|
||||
await message.answer("Не нашла достаточно материалов для обновления профиля.")
|
||||
return
|
||||
await asyncio.to_thread(save_tone_of_voice, user_id, profile)
|
||||
if status:
|
||||
await status.finish("✅ Обучение завершено")
|
||||
rich_html = (
|
||||
"<h2>✅ Профиль аккаунта обновлён</h2>"
|
||||
f"<p>Накоплено постов: {len(samples)}. Дополнений профиля: {len(notes)}.</p>"
|
||||
@@ -504,10 +510,13 @@ async def process_training_text(message: Message, text: str) -> None:
|
||||
await message.answer("Не удалось определить пользователя или бота.")
|
||||
return
|
||||
with diagnostic_operation():
|
||||
status = await OperationStatus.start(message, "⏳ Обрабатываю сведения об аккаунте…")
|
||||
try:
|
||||
async with typing_status(message.bot, message.chat.id):
|
||||
await _rebuild_account_profile(message.from_user.id, message, new_note=text)
|
||||
await status.update("⏳ Обновляю профиль аккаунта…")
|
||||
await _rebuild_account_profile(message.from_user.id, message, new_note=text, status=status)
|
||||
except Exception as exc:
|
||||
await status.finish("❌ Не удалось завершить обучение")
|
||||
await report_error(message, "обучение профиля аккаунта", exc)
|
||||
|
||||
|
||||
@@ -637,14 +646,41 @@ async def typing_status(bot: Bot, chat_id: int):
|
||||
pass
|
||||
|
||||
|
||||
class OperationStatus:
|
||||
"""A user-visible status message edited throughout a long operation."""
|
||||
|
||||
def __init__(self, status_message: Message | None):
|
||||
self.status_message = status_message
|
||||
|
||||
@classmethod
|
||||
async def start(cls, message: Message, text: str) -> "OperationStatus":
|
||||
try:
|
||||
return cls(await message.answer(text))
|
||||
except Exception as exc:
|
||||
logger.warning("Status message send failed (%s): %s", type(exc).__name__, str(exc)[:120])
|
||||
return cls(None)
|
||||
|
||||
async def update(self, text: str) -> None:
|
||||
if self.status_message is None:
|
||||
return
|
||||
try:
|
||||
await self.status_message.edit_text(text)
|
||||
except Exception as exc:
|
||||
logger.warning("Status message edit failed (%s): %s", type(exc).__name__, str(exc)[:120])
|
||||
|
||||
async def finish(self, text: str) -> None:
|
||||
await self.update(text)
|
||||
|
||||
|
||||
async def _classify_and_respond(
|
||||
message: Message,
|
||||
text: str,
|
||||
show_typing: bool = True,
|
||||
state: FSMContext | None = None,
|
||||
operation_status=None,
|
||||
) -> None:
|
||||
with diagnostic_operation():
|
||||
await _classify_and_respond_inner(message, text, show_typing, state)
|
||||
await _classify_and_respond_inner(message, text, show_typing, state, operation_status)
|
||||
|
||||
|
||||
async def _classify_and_respond_inner(
|
||||
@@ -652,6 +688,7 @@ async def _classify_and_respond_inner(
|
||||
text: str,
|
||||
show_typing: bool = True,
|
||||
state: FSMContext | None = None,
|
||||
operation_status=None,
|
||||
) -> None:
|
||||
if message.from_user is None:
|
||||
await message.answer("Не удалось определить пользователя.")
|
||||
@@ -665,13 +702,22 @@ async def _classify_and_respond_inner(
|
||||
original_post = ""
|
||||
if reply_message and reply_message.from_user and reply_message.from_user.is_bot:
|
||||
original_post = (reply_message.text or reply_message.caption or "").strip()
|
||||
status = operation_status
|
||||
try:
|
||||
if show_typing and status is None:
|
||||
status = await OperationStatus.start(message, "⏳ Разбираю запрос…")
|
||||
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"] == "help":
|
||||
if status:
|
||||
await status.finish("✅ Открываю справку")
|
||||
await help_command(message, state)
|
||||
return
|
||||
if intent["intent"] == "generate_ideas":
|
||||
if status:
|
||||
await status.update("⏳ Подбираю идею…")
|
||||
tone = await asyncio.to_thread(get_tone_of_voice, message.from_user.id)
|
||||
history = await asyncio.to_thread(
|
||||
get_recent_post_history_text, message.from_user.id,
|
||||
@@ -679,13 +725,19 @@ async def _classify_and_respond_inner(
|
||||
)
|
||||
idea_history = await asyncio.to_thread(get_idea_history, message.from_user.id, 30)
|
||||
idea = await generate_ideas(text, tone, history, idea_history, user_id=message.from_user.id)
|
||||
if status:
|
||||
await status.finish("✅ Идея готова")
|
||||
await _send_idea(message, idea, message.from_user.id)
|
||||
return
|
||||
if intent["intent"] == "revise_post":
|
||||
if not original_post:
|
||||
if status:
|
||||
await status.finish("⚠️ Не найден исходный вариант")
|
||||
await message.answer("Чтобы исправить пост, ответь реплаем на сообщение с его вариантом.")
|
||||
return
|
||||
tone = await asyncio.to_thread(get_tone_of_voice, message.from_user.id)
|
||||
if status:
|
||||
await status.update("⏳ Редактирую вариант…")
|
||||
posts = await generate_posts(
|
||||
intent, tone, original_post=original_post, edit_request=text,
|
||||
user_id=message.from_user.id,
|
||||
@@ -693,10 +745,16 @@ async def _classify_and_respond_inner(
|
||||
origin = "revised"
|
||||
else:
|
||||
tone = await asyncio.to_thread(get_tone_of_voice, message.from_user.id)
|
||||
if status:
|
||||
await status.update("⏳ Генерирую варианты поста…")
|
||||
posts = await generate_posts(intent, tone, user_id=message.from_user.id)
|
||||
origin = "generated"
|
||||
if status:
|
||||
await status.finish("✅ Посты готовы")
|
||||
await _send_three_posts(message, posts, user_id=message.from_user.id, origin=origin)
|
||||
except Exception as exc:
|
||||
if status:
|
||||
await status.finish("❌ Не удалось выполнить запрос")
|
||||
await report_error(message, "классификация или генерация поста", exc)
|
||||
|
||||
|
||||
@@ -727,12 +785,14 @@ async def _download_message_image(message: Message, bot: Bot) -> tuple[bytes, st
|
||||
return image, mime_type
|
||||
|
||||
|
||||
async def _process_image_group(messages: list[Message], bot: Bot) -> None:
|
||||
async def _process_image_group(messages: list[Message], bot: Bot, status=None) -> None:
|
||||
first = messages[0]
|
||||
if first.from_user is None:
|
||||
await first.answer("Не удалось определить пользователя.")
|
||||
return
|
||||
if len(messages) > 10:
|
||||
if status:
|
||||
await status.finish("⚠️ В галерее больше 10 изображений")
|
||||
await first.answer("В одной галерее можно обработать не более 10 изображений.")
|
||||
return
|
||||
images: list[tuple[bytes, str]] = []
|
||||
@@ -741,12 +801,18 @@ async def _process_image_group(messages: list[Message], bot: Bot) -> None:
|
||||
if image_payload:
|
||||
images.append(image_payload)
|
||||
if not images:
|
||||
if status:
|
||||
await status.finish("⚠️ Не удалось получить изображения")
|
||||
return
|
||||
if status:
|
||||
await status.update("⏳ Извлекаю тексты со скриншотов…")
|
||||
new_samples = await extract_posts_from_images(images, user_id=first.from_user.id)
|
||||
if not new_samples:
|
||||
if status:
|
||||
await status.finish("⚠️ Не нашла читаемых текстов")
|
||||
await first.answer("Не нашла читаемых текстов на скриншотах. Обучение не меняла.")
|
||||
return
|
||||
await _rebuild_account_profile(first.from_user.id, first, new_posts=new_samples)
|
||||
await _rebuild_account_profile(first.from_user.id, first, new_posts=new_samples, status=status)
|
||||
|
||||
|
||||
async def _collect_media_group(message: Message, bot: Bot) -> list[Message]:
|
||||
@@ -789,6 +855,7 @@ async def _finish_media_group(key: tuple[int, str]) -> None:
|
||||
async def _handle_image_message(message: Message, bot: Bot) -> None:
|
||||
await notify_development_deploy(bot)
|
||||
with diagnostic_operation():
|
||||
status = None
|
||||
try:
|
||||
if message.media_group_id:
|
||||
messages = await _collect_media_group(message, bot)
|
||||
@@ -796,9 +863,13 @@ async def _handle_image_message(message: Message, bot: Bot) -> None:
|
||||
return
|
||||
else:
|
||||
messages = [message]
|
||||
status = await OperationStatus.start(message, "⏳ Читаю посты на изображениях…")
|
||||
async with typing_status(bot, message.chat.id):
|
||||
await _process_image_group(messages, bot)
|
||||
await status.update("⏳ Анализирую стиль и обновляю профиль…")
|
||||
await _process_image_group(messages, bot, status=status)
|
||||
except Exception as exc:
|
||||
if status:
|
||||
await status.finish("❌ Не удалось обработать изображения")
|
||||
await report_error(message, "чтение скриншота или обновление ToV", exc)
|
||||
|
||||
|
||||
@@ -809,37 +880,53 @@ async def update_tone_from_image(message: Message, bot: Bot, state: FSMContext)
|
||||
await _handle_image_message(message, bot)
|
||||
|
||||
|
||||
async def _process_voice(message: Message, bot: Bot, state: FSMContext) -> None:
|
||||
async def _process_voice(message: Message, bot: Bot, state: FSMContext, status=None) -> None:
|
||||
if not os.environ.get("APINEX_API_KEY", "").strip():
|
||||
if status:
|
||||
await status.finish("❌ Расшифровка аудио не настроена")
|
||||
await message.answer("Расшифровка голосовых пока не настроена.")
|
||||
return
|
||||
audio_file = message.voice or message.audio
|
||||
if audio_file is None:
|
||||
if status:
|
||||
await status.finish("⚠️ Аудиофайл не найден")
|
||||
return
|
||||
if audio_file.file_size and audio_file.file_size > MAX_AUDIO_BYTES:
|
||||
if status:
|
||||
await status.finish("⚠️ Аудиофайл превышает лимит 20 МБ")
|
||||
await message.answer("Аудиофайл слишком большой. Максимальный размер — 20 МБ.")
|
||||
return
|
||||
audio_buffer = io.BytesIO()
|
||||
await bot.download(audio_file, destination=audio_buffer)
|
||||
audio = audio_buffer.getvalue()
|
||||
if not audio:
|
||||
if status:
|
||||
await status.finish("❌ Не удалось получить аудиофайл")
|
||||
await message.answer("Не удалось получить аудиофайл.")
|
||||
return
|
||||
if len(audio) > MAX_AUDIO_BYTES:
|
||||
if status:
|
||||
await status.finish("⚠️ Аудиофайл превышает лимит 20 МБ")
|
||||
await message.answer("Аудиофайл слишком большой. Максимальный размер — 20 МБ.")
|
||||
return
|
||||
content_type = "audio/ogg" if message.voice else (message.audio.mime_type or "audio/mpeg")
|
||||
if status:
|
||||
await status.update("⏳ Распознаю речь…")
|
||||
transcript = await transcribe_audio(audio, content_type, user_id=message.from_user.id if message.from_user else None)
|
||||
if not transcript:
|
||||
if status:
|
||||
await status.finish("⚠️ Не удалось распознать речь")
|
||||
await message.answer("Не удалось распознать речь в аудио.")
|
||||
return
|
||||
if await current_mode(state) == "training":
|
||||
if message.from_user is None:
|
||||
if status:
|
||||
await status.finish("❌ Не удалось определить пользователя")
|
||||
await message.answer("Не удалось определить пользователя.")
|
||||
return
|
||||
await _rebuild_account_profile(message.from_user.id, message, new_note=transcript)
|
||||
await _rebuild_account_profile(message.from_user.id, message, new_note=transcript, status=status)
|
||||
return
|
||||
await _classify_and_respond(message, transcript, show_typing=False, state=state)
|
||||
await _classify_and_respond(message, transcript, show_typing=False, state=state, operation_status=status)
|
||||
|
||||
|
||||
@router.message(F.voice | F.audio)
|
||||
@@ -853,10 +940,12 @@ async def transcribe_voice(message: Message, bot: Bot, state: FSMContext) -> Non
|
||||
return
|
||||
await notify_development_deploy(bot)
|
||||
with diagnostic_operation():
|
||||
status = await OperationStatus.start(message, "⏳ Подготавливаю аудио…")
|
||||
try:
|
||||
async with typing_status(bot, message.chat.id):
|
||||
await _process_voice(message, bot, state)
|
||||
await _process_voice(message, bot, state, status=status)
|
||||
except Exception as exc:
|
||||
await status.finish("❌ Не удалось обработать аудио")
|
||||
await report_error(message, "обработка голосового и генерация", exc)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from aiogram.types import Chat, Message
|
||||
@@ -232,7 +233,10 @@ class ModeRoutingTests(unittest.IsolatedAsyncioTestCase):
|
||||
message = _StubMessage("Кофейня в Калининграде для семей с детьми")
|
||||
with patch.object(bot, "_rebuild_account_profile", new=AsyncMock()) as rebuild:
|
||||
await bot.handle_text(message, _StubState("training"))
|
||||
rebuild.assert_awaited_once_with(42, message, new_note="Кофейня в Калининграде для семей с детьми")
|
||||
call = cast(Any, rebuild.await_args)
|
||||
self.assertEqual(call.args, (42, message))
|
||||
self.assertEqual(call.kwargs["new_note"], "Кофейня в Калининграде для семей с детьми")
|
||||
self.assertIsInstance(call.kwargs["status"], bot.OperationStatus)
|
||||
|
||||
async def test_plain_text_is_only_sent_to_generator_in_generation_mode(self):
|
||||
message = _StubMessage("Напиши пост про запуск продукта")
|
||||
@@ -316,7 +320,10 @@ class ModeRoutingTests(unittest.IsolatedAsyncioTestCase):
|
||||
):
|
||||
await bot._process_voice(message, bot_client, state)
|
||||
stt.assert_awaited_once()
|
||||
rebuild.assert_awaited_once_with(42, message, new_note="Мы — кофейня в Калининграде")
|
||||
call = cast(Any, rebuild.await_args)
|
||||
self.assertEqual(call.args, (42, message))
|
||||
self.assertEqual(call.kwargs["new_note"], "Мы — кофейня в Калининграде")
|
||||
self.assertIsNone(call.kwargs["status"])
|
||||
classify.assert_not_awaited()
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import unittest
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import bot
|
||||
@@ -45,6 +46,20 @@ class MediaGroupTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
|
||||
class TypingStatusTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_operation_status_is_edited_and_finished_in_place(self):
|
||||
status_message = cast(Any, type("StatusMessage", (), {"edit_text": AsyncMock()})())
|
||||
message = cast(Any, type("Message", (), {"answer": AsyncMock(return_value=status_message)})())
|
||||
|
||||
status = await bot.OperationStatus.start(message, "⏳ Старт")
|
||||
await status.update("⏳ Работаю")
|
||||
await status.finish("✅ Готово")
|
||||
|
||||
message.answer.assert_awaited_once_with("⏳ Старт")
|
||||
self.assertEqual(
|
||||
[call.args[0] for call in status_message.edit_text.await_args_list],
|
||||
["⏳ Работаю", "✅ Готово"],
|
||||
)
|
||||
|
||||
async def test_sends_immediately_refreshes_and_cancels(self):
|
||||
fake_bot = AsyncMock()
|
||||
with patch.object(bot, "TYPING_REFRESH_SECONDS", 0.01):
|
||||
|
||||
Reference in new issue
Block a user