diff --git a/IDEA.MD b/IDEA.MD index 1404377..c4365bf 100644 --- a/IDEA.MD +++ b/IDEA.MD @@ -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) diff --git a/README.md b/README.md index e388e9a..92fc218 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ Telegram-бот для генерации постов для соцсетей. - [Деплой и конфигурация](#деплой-и-конфигурация) - [Тесты](#тесты) +Версия разработки: `0.1.0+39`. + --- ## Синхронизация документации @@ -69,7 +71,7 @@ onboarding. В «Подписке» сразу отображаются тип | Очистить данные | В режиме «Обучение» нажимает «Сбросить данные» или вводит `/clear`, затем подтверждает | Очищает стиль и историю, не меняя подписку или триал | ### Индикаторы и обратная связь -- Пока **идёт обработка** (классификация, расшифровка, чтение скриншота, генерация) — бот показывает «печатает» (`typing_status`), обновляя каждые 4 секунды. +- Пока **идёт обработка** (классификация, расшифровка, чтение скриншота, генерация) — бот показывает «печатает» (`typing_status`, обновление каждые 4 секунды) и отдельное сообщение-статус, которое редактируется по этапам; перед результатом оно меняется на ✅, при ошибке — на ❌. - При **ошибке** пользователь получает общий безопасный ответ. Только в development отдельным сообщением дополнительно отправляется диагностика; production не раскрывает текст исключения. - Служебные тексты — **всегда на русском**. Язык самого поста определяется языком запроса. diff --git a/VERSION b/VERSION index 5a4b937..445aad9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+38 +0.1.0+39 diff --git a/bot.py b/bot.py index 9bec5f0..caaea38 100644 --- a/bot.py +++ b/bot.py @@ -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 = ( "
Накоплено постов: {len(samples)}. Дополнений профиля: {len(notes)}.
" @@ -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) diff --git a/tests/test_keyboards.py b/tests/test_keyboards.py index 17992b5..609d902 100644 --- a/tests/test_keyboards.py +++ b/tests/test_keyboards.py @@ -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() diff --git a/tests/test_typing.py b/tests/test_typing.py index dd8e07f..56783c0 100644 --- a/tests/test_typing.py +++ b/tests/test_typing.py @@ -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):