From 635b78cb86ce48ff00c281a822bfcf9382e959a7 Mon Sep 17 00:00:00 2001 From: admin Date: Sun, 11 Oct 2026 15:18:59 +0200 Subject: [PATCH] fix: always close the operation status so the draft stops hanging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Драфт исчезает только когда бот отправляет реальное сообщение. Ряд путей завершения операции не закрывал статус, из-за чего последняя показанная фраза висела в чате до конца TTL драфта и выглядела неактуальной. - OperationStatus: флаг _closed, finish() принимает None (только снять статус), finish_if_open() — идемпотентное закрытие, _finished_draft_id сохраняется до обнуления draft_id - finish_status(status, text) — хелпер, терпит None: закрывает статус на любом пути выхода без повторения `if status:` - закрыты пути без finish: _rebuild_account_profile при переданном статусе, _process_voice -> _classify_and_respond, успешные выходы _handle_image_message и transcribe_voice через else-ветку, ранние возвраты /help и проверки подписки Проверено: 176 тестов OK (4 новых на закрытие статуса). VERSION: 0.1.0+49 -> 0.1.0+50 --- IDEA.MD | 30 +++++++++++++++++-- README.md | 54 ++++++++++++++++++++++++++++++---- VERSION | 2 +- bot.py | 51 +++++++++++++++++++++++++++----- tests/test_activity_phrases.py | 35 ++++++++++++++++++++++ 5 files changed, 155 insertions(+), 17 deletions(-) diff --git a/IDEA.MD b/IDEA.MD index 301a097..e9729ec 100644 --- a/IDEA.MD +++ b/IDEA.MD @@ -70,7 +70,8 @@ Telegram-бот, который по запросу пользователя г - После деплоя файл приложения `/app/data/bot.log` и его ротационные копии хранятся на persistent volume выше и переживают пересоздание контейнера. Пока logging-изменение не задеплоено, используй `docker logs` и API-журнал `api_call_logs` в SQLite. API-логи сохраняются в `/app/data/social_writer.sqlite3` на том же volume. - Dev-only raw update log: отдельная SQLite `/app/data/bot_updates.sqlite3`, таблица `raw_updates`; поиск по Telegram `update_id` (`SELECT payload FROM raw_updates WHERE update_id = ?`). Middleware подключается только при `DEPLOY_ENV=development`; записи старше 30 дней удаляются при запуске и записи новых событий. Секретные поля/токены маскируются. JSON включает приватный текст и metadata апдейта; production его не пишет. - Секреты хранятся в переменных окружения приложения Coolify; не сохраняются в репозитории. -- `DEPLOY_ENV` включает dev-поведение только при точном значении `development`; production, пустое и неизвестное значение отключают его. `BOT_ENV` отдельно выбирает Telegram Bot API. `DEV_CHAT_ID` используется для уведомления о версии; версия в файле `VERSION` (сейчас `0.1.0+49`). Ротационный файл лога — `BOT_LOG_PATH` (по умолчанию `/app/data/bot.log`) на том же volume; сырые апдейты — `RAW_UPDATES_DB_PATH` (по умолчанию `/app/data/bot_updates.sqlite3`). +- Поддержка: `SUPPORT_GROUP_ID` — id группы операторов (`-1008000863273`), `SUPPORT_NOTE_PREFIX` (по умолчанию `//`), `SUPPORT_TOPIC_EMOJI_ID` (опционально). Без `SUPPORT_GROUP_ID` поддержка выключена. Подробнее — раздел «Поддержка». +- `DEPLOY_ENV` включает dev-поведение только при точном значении `development`; production, пустое и неизвестное значение отключают его. `BOT_ENV` отдельно выбирает Telegram Bot API. `DEV_CHAT_ID` используется для уведомления о версии; версия в файле `VERSION` (сейчас `0.1.0+50`). Ротационный файл лога — `BOT_LOG_PATH` (по умолчанию `/app/data/bot.log`) на том же volume; сырые апдейты — `RAW_UPDATES_DB_PATH` (по умолчанию `/app/data/bot_updates.sqlite3`). - Вики Gitea: страницы `Home`, `User-Guide`, `Developer-Guide`, `Subscription`, `Deployment`. ## Статус @@ -103,7 +104,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+49` +- Версия разработки: `0.1.0+50` - [x] Статус долгой операции стримится живым драфтом `sendMessageDraft` в приватном чате и каждые 3–8 сек меняет случайную фразу из пула операции (`activity_phrases.py`); в группах и топиках работает прежний `edit_text` - [x] Справка бота (`/help`) описывает `/balance` и `/subscribe` - [x] Проект Gitea «Social Post Writer Bot Roadmap» с задачами (выполненные — в «Готово», будущие — в Backlog) @@ -112,6 +113,31 @@ Telegram-бот, который по запросу пользователя г - [x] Лимит `max_tokens` снят; обрезанный JSON восстанавливается по целым строкам (`_extract_complete_post_strings`) - [x] Dev-only raw update log: входящие Telegram Update в `/app/data/bot_updates.sqlite3`, поиск по `update_id`, retention 30 дней - [x] Уведомление о версии показывается вместе с главным меню при старте бота +- [x] Поддержка в боте: кнопка «🆘 Поддержка» и `/support` в режиме «Помощь»; переписка личка ↔ персональный топик группы `-1008000863273` (`support.py`, таблица `support_topics`) + +## Поддержка + +**Группа:** супергруппа «Мастер постинга: Поддержка», `chat.id = -1008000863273`, форум с темами. +Переменная Coolify `SUPPORT_GROUP_ID` — без неё функция выключена (fail-closed). + +**Как работает:** пользователь в режиме «Помощь» нажимает «🆘 Поддержка». Отдельного FSM-режима нет — +обращение это расширение «Помощи»: пока оно активно, клавиатура содержит только «Главное меню», +а переключение режимов отклоняется. Все входящие сообщения (текст, фото, документы, голосовые) +копируются в персональный топик; любое сообщение оператора в топике копируется обратно в личку. + +- **Один пользователь — один топик.** `thread_id` хранится в `support_topics` и переиспользуется: + повторное обращение продолжает ту же переписку с сохранённой историей. +- **Название топика** — «Имя · @username» (без username — «Имя · id»). +- **Аватарка**: Telegram не даёт поставить на топик произвольную картинку, только custom emoji + (`SUPPORT_TOPIC_EMOJI_ID`). Аватарка профиля уходит первым сообщением-карточкой вместе с именем, + username и `user_id`. +- **Заметки оператора**: сообщение с префиксом `//` (`SUPPORT_NOTE_PREFIX`) остаётся в топике и + пользователю не отправляется. +- **Завершение**: «🏠 Главное меню» снимает `is_active`, топик не удаляется и не закрывается. +- Если топик удалили вручную, при следующем сообщении бот создаёт его заново. + +**Требования к группе:** `is_forum = true`, бот — участник с правом управления темами +(`create_forum_topic`) и отправки сообщений. ## План работ (проект Gitea) Колонки: **Backlog** → **В работе** → **Готово**. diff --git a/README.md b/README.md index 46f2d2b..4a82ef1 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,14 @@ Telegram-бот для генерации постов для соцсетей. ## Содержание - [Как работает бот (UX)](#как-работает-бот-ux) - [Архитектура кода](#архитектура-кода) +- [Поддержка](#поддержка-supportpy) - [Подписка и монетизация](#подписка-и-монетизация) - [Данные и хранилище](#данные-и-хранилище) - [Команды](#команды) - [Деплой и конфигурация](#деплой-и-конфигурация) - [Тесты](#тесты) -Версия разработки: `0.1.0+49`. +Версия разработки: `0.1.0+50`. --- @@ -44,7 +45,8 @@ Telegram-бот для генерации постов для соцсетей. ``` «Главное меню» — внутренняя точка входа после `/start` и выхода из режима, а не отдельный режим. -Внутри любого режима reply-клавиатура содержит только «🏠 Главное меню». При входе показывается короткий +Внутри любого режима reply-клавиатура содержит только «🏠 Главное меню». В «Генерации» добавляется +«✍️ Придумать пост», в «Обучении» — «🗑 Сбросить данные», в «Помощи» — «🆘 Поддержка». При входе показывается короткий onboarding. В «Подписке» сразу отображаются тип активного периода и оставшиеся дни; inline-кнопка «Продлить подписку» сразу открывает Stars-инвойс на оплату. @@ -58,6 +60,39 @@ onboarding. В «Подписке» сразу отображаются тип 1. Отправляет ровно одно `WELCOME_RICH_HTML` с примерами и описанием режимов. 2. Показывает клавиатуру главного меню; справка открывается отдельно кнопкой или командой `/help`. +### Поддержка (`support.py`) + +Отдельного FSM-режима нет: обращение — расширение режима «Помощь». Пользователь нажимает +**«🆘 Поддержка»** (или `/support`), и до выхода в главное меню все его сообщения уходят оператору. + +**Схема:** + +``` +личка пользователя ──copy_message──▶ топик в SUPPORT_GROUP_ID + ▲ │ + └────────────copy_message─────────────┘ + (любое сообщение оператора) +``` + +- **Топик создаётся один на пользователя** и переиспользуется: `support_topics` хранит + `telegram_user_id → thread_id` и флаг активности. Название — «Имя · @username» (без username — «Имя · id»). +- Telegram не даёт поставить на топик произвольную картинку, только custom emoji + (`SUPPORT_TOPIC_EMOJI_ID`). Аватарка профиля уходит **первым сообщением-карточкой** в топик + (`get_user_profile_photos` → `send_photo` с подписью «Имя / @username / ID»). +- Пересылаются **все типы контента** — текст, фото, документы, голосовые, альбомы (`copy_message`). +- Ответ оператора — любое сообщение в топике от живого участника. Сообщение с префиксом `//` + (`SUPPORT_NOTE_PREFIX`) остаётся заметкой внутри топика и пользователю не уходит. +- Завершение — кнопка «🏠 Главное меню»: обращение деактивируется, топик **не удаляется**. + Повторное «Поддержка» продолжает переписку в том же топике с сохранённой историей. +- Пока обращение активно, клавиатура содержит только «Главное меню», а переключение в другие + режимы отклоняется — чтобы режимы и генерация не смешивались с обращением. + +**Переменные Coolify:** `SUPPORT_GROUP_ID` (обязательно, без неё функция выключена), +`SUPPORT_NOTE_PREFIX` (default `//`), `SUPPORT_TOPIC_EMOJI_ID` (optional custom emoji id). + +> Группа должна быть **супергруппой с включёнными темами** (`is_forum`), бот — её участником. +> Права нужны: управление темами (`create_forum_topic`) и отправка сообщений. + ### Основные сценарии | Что хочет пользователь | Что делает | Что видит | @@ -79,6 +114,7 @@ onboarding. В «Подписке» сразу отображаются тип - **В приватном чате** статус операции стримится живым драфтом (`sendMessageDraft`): он не тратит лимит отправки в чат, не оставляет после себя сообщения и анимируется при смене текста. Пока операция длится, драфт каждые **3–8 секунд** (случайно) получает случайную фразу из пула, привязанного к типу операции, — видно, что работа не встала. Финальный результат уходит обычным сообщением. - **В группах и топиках** драфты недоступны: там работает прежний механизм — отдельное сообщение-статус, которое редактируется по этапам; перед результатом оно меняется на ✅, при ошибке — на ❌. Ротация фраз в группах отключена. - Пулы фраз живут в `activity_phrases.py` (`generation`, `training`, `images`, `voice`, `ideas`), интервал задаётся переменными `ACTIVITY_MIN_SECONDS` / `ACTIVITY_MAX_SECONDS`. +- Драфт исчезает только когда бот отправляет реальное сообщение, поэтому **каждый путь завершения операции обязан закрывать статус** (`status.finish(...)` либо `finish_status(status, ...)`, который терпит `None`). Иначе последняя фраза висит в чате до конца TTL драфта. `finish_if_open()` — страховка от повторного закрытия. - В режиме «Генерация» запрос сначала проверяет отдельный LLM-классификатор prompt injection, затем классификатор намерений. При обнаружении попытки изменить правила генератор не вызывается; ошибка проверки блокирует дальнейшую обработку. Модель проверки настраивается отдельно через `APINEX_INJECTION_CLASSIFIER_MODEL`. - Благодарность и сообщения не по теме получают случайную заготовленную фразу и не запускают генерацию/идеи. Режим «Обучение» этим изменением не затронут. - В режиме «Генерация» изображения и галереи используются для подготовки поста; подпись считается ТЗ. Без подписи бот сначала запрашивает ТЗ. Видео, видеокружки и GIF/анимации отклоняются во всех режимах. @@ -106,6 +142,7 @@ payments.py — оплата Stars: sendInvoice, pre_checkout_query, su apinex_client.py — клиент APInex (LLM, STT, vision), промпты, логирование вызовов user_store.py — SQLite: пользователи, ToV, посты, идеи, API-логи, подписки raw_updates.py — отдельная SQLite-БД сырых входящих Telegram Update для development +support.py — поддержка: личка ↔ персональный топик в группе операторов keyboards.py — клавиатуры главного меню и режимов; маппинг кнопок на команды help_message.py — Rich Message: `WELCOME_RICH_HTML` (онбординг) + `HELP_SECTIONS`/`help_view()` (справка с кнопками), plain fallback VERSION — версия релиза @@ -233,6 +270,7 @@ SUBSCRIPTION_PRICE_STARS = 100 - **Где смотреть ошибки:** подключись к `spb-easy-dale`, найди контейнер по имени `d32u5zcq2g4odmdpq9hw91yn-*` командой `docker ps`, затем смотри его stdout/stderr через `docker logs --since 24h `. После деплоя ротационный файл приложения будет доступен как `/app/data/bot.log` (до 5 MiB и 3 архива); он сохраняется в указанном persistent volume при пересоздании контейнера. До деплоя изменения логирования ещё не действуют. - **API-журнал ошибки:** открой `/app/data/social_writer.sqlite3`, таблицу `api_call_logs`; записи связываются по `operation_id` и времени. Файл/БД находятся в volume на сервере приложения, не в локальном checkout и не на сервере Coolify, если это другой хост. - **Сырые Telegram Update (только dev):** отдельная БД `/app/data/bot_updates.sqlite3`, таблица `raw_updates`; хранится JSON входящего Update, `update_id`, тип события и время получения. Поиск по `update_id`: `SELECT payload FROM raw_updates WHERE update_id = ?`. Записи старше 30 дней удаляются при старте dev-бота и при сохранении новых апдейтов. Чувствительные значения маскируются; Update могут содержать тексты и персональные данные пользователей. В production middleware не подключается и БД не создаётся. +- **Обращения в поддержку:** таблица `support_topics` в той же рабочей БД — `telegram_user_id` (PK), `thread_id` топика в группе операторов, `is_active`, время создания и обновления. Один пользователь — один топик; при завершении запись не удаляется, только снимается `is_active`, поэтому повторное обращение продолжается с сохранённой историей. Если топик удалили вручную в Telegram, при следующем сообщении он создаётся заново. - **Per-user данные:** профиль аккаунта/ToV, текстовые обучающие заметки, образцы постов со скриншотов, история сгенерированных постов и идеи. В «Обучении» текст/голос/скриншоты накапливают и обновляют профиль, а не запускают генерацию. История диалогов и исходные изображения **не сохраняются**. - **Пустые ответы APInex:** при пустом `content` бот делает до двух повторов; третий запрос добавляет `reasoning_effort: low`. Если ответ всё ещё пустой, операция завершается ошибкой и каждая попытка попадает в API-журнал. Ошибки HTTP автоматически не повторяются. APInex документирует `low`, но не режим отключения reasoning; поддержка параметра зависит от модели. - **Починка формата JSON:** при синтаксически невалидном JSON выполняется не более одной отдельной попытки модели `APINEX_RESPONSE_REPAIR_MODEL`; затем исходные проверки структуры/схемы запускаются заново. Ошибки API, пустые ответы и JSON с неверной схемой автоматически не исправляются. В Coolify development задана `APINEX_RESPONSE_REPAIR_MODEL`. @@ -252,6 +290,7 @@ SUBSCRIPTION_PRICE_STARS = 100 | `/style`, `/clear` | Профиль аккаунта; сброс накопленного обучения | Обучение | | `/post`, `/idea` | Самостоятельный пост по обученному профилю; идея | Генерация; для генерации нужна подписка | | `/balance`, `/subscribe` | Статус или оплата Stars | Подписка | +| `/support` | Написать в поддержку | Любой | В режиме «Генерация» русские фразы («придумай идею», «о чём написать?») работают как `/idea`. @@ -263,9 +302,12 @@ SUBSCRIPTION_PRICE_STARS = 100 - **Coolify:** проект `Social Post Writer Bot`, окружение `development`, приложение `social-post-writer-bot-development` (`d32u5zcq2g4odmdpq9hw91yn`) на `spb-easy-dale`. - **Деплой:** `bash /root/.hermes/scripts/gitea_deploy.sh dev` (POST `/api/v1/deploy?uuid=...&force=false`). - **Окружение:** `DEPLOY_ENV` включает dev-функции только при точном значении `development`; production, пустое и неизвестное значение отключают их. `BOT_ENV` отдельно выбирает Telegram Bot API и не используется для включения dev-поведения. -- **VERSION:** файл `VERSION` (`0.1.0+49`). При `DEPLOY_ENV=development` и изменении версии бот шлёт уведомление о версии в `DEV_CHAT_ID`. -- **Переменные Coolify:** `BOT_TOKEN`, `APINEX_API_KEY`, `APINEX_STT_MODEL`, `APINEX_INTENT_CLASSIFIER_MODEL`, `APINEX_INJECTION_CLASSIFIER_MODEL`, `APINEX_POST_WRITER_MODEL`, `APINEX_IMAGE_TONE_MODEL`, `APINEX_RESPONSE_REPAIR_MODEL` (optional; default uses the standard model), `BOT_DB_PATH`, `RAW_UPDATES_DB_PATH` (optional; default `/app/data/bot_updates.sqlite3`), `DEPLOY_ENV`, `DEV_CHAT_ID`, `BOT_LOG_PATH` (optional; default `/app/data/bot.log`), `ACTIVITY_MIN_SECONDS` / `ACTIVITY_MAX_SECONDS` (optional; default 3 / 8 — границы паузы между сменами фраз в драфте), `ACTIVITY_MAX_ROTATIONS` (optional; default 12 — предохранитель от бесконечной ротации). -- **Docker:** `python:3.12-slim`, копирует исходники бота, включая `raw_updates.py`; `CMD ["python", "bot.py"]`. +- **VERSION:** файл `VERSION` (`0.1.0+50`). При `DEPLOY_ENV=development` и изменении версии бот шлёт уведомление о версии в `DEV_CHAT_ID`. +- **Переменные Coolify:** `BOT_TOKEN`, `APINEX_API_KEY`, `APINEX_STT_MODEL`, `APINEX_INTENT_CLASSIFIER_MODEL`, `APINEX_INJECTION_CLASSIFIER_MODEL`, `APINEX_POST_WRITER_MODEL`, `APINEX_IMAGE_TONE_MODEL`, `APINEX_RESPONSE_REPAIR_MODEL` (optional; default uses the standard model), `BOT_DB_PATH`, `RAW_UPDATES_DB_PATH` (optional; default `/app/data/bot_updates.sqlite3`), `DEPLOY_ENV`, `DEV_CHAT_ID`, `BOT_LOG_PATH` (optional; default `/app/data/bot.log`), `ACTIVITY_MIN_SECONDS` / `ACTIVITY_MAX_SECONDS` (optional; default 3 / 8 — границы паузы между сменами фраз в драфте), `ACTIVITY_MAX_ROTATIONS` (optional; default 12 — предохранитель от бесконечной ротации), +`SUPPORT_GROUP_ID` (id супергруппы с темами для обращений; без неё поддержка выключена), +`SUPPORT_NOTE_PREFIX` (optional; default `//` — префикс заметки, которая не уходит пользователю), +`SUPPORT_TOPIC_EMOJI_ID` (optional; custom emoji id на топике обращения). +- **Docker:** `python:3.12-slim`, копирует исходники бота, включая `raw_updates.py`, `support.py` и `activity_phrases.py`. --- @@ -275,6 +317,6 @@ SUBSCRIPTION_PRICE_STARS = 100 python -m unittest discover -s tests -v ``` -Покрытие: STT, LLM, vision, ToV, reply-поля, изоляция пользователей, API-логи, **raw update log** (сохранение/чтение, выключение вне development, очистка по retention), **режимные клавиатуры и роутинг** (переключатели, возврат в главное меню), **платежи** (Stars, защита от повторов, dev-подтверждение), **ошибки**, **справка**, **onboarding**, **подписки** (статус при входе, продление, оплата, баланс, блокировка, сохранение подписки при `/clear`), **самостоятельная генерация поста** (гейт по флагу обучения, контекст из профиля), **восстановление обрезанного JSON**. +Покрытие: STT, LLM, vision, ToV, reply-поля, изоляция пользователей, API-логи, **raw update log** (сохранение/чтение, выключение вне development, очистка по retention), **режимные клавиатуры и роутинг** (переключатели, возврат в главное меню), **платежи** (Stars, защита от повторов, dev-подтверждение), **ошибки**, **справка**, **onboarding**, **подписки** (статус при входе, продление, оплата, баланс, блокировка, сохранение подписки при `/clear`), **самостоятельная генерация поста** (гейт по флагу обучения, контекст из профиля), **восстановление обрезанного JSON**, **поддержка** (кнопка и клавиатура обращения, `support=True` оставляет только «Главное меню»). Запускай через виртуальное окружение проекта: `.venv/bin/python -m unittest discover -s tests` (вне его `aiogram` может быть не установлен). diff --git a/VERSION b/VERSION index 500f969..ac25eaa 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+49 +0.1.0+50 diff --git a/bot.py b/bot.py index 71857f7..16dc35a 100644 --- a/bot.py +++ b/bot.py @@ -811,7 +811,9 @@ class OperationStatus: self.status_message = status_message self.kind = kind self.draft_id = draft_id + self._finished_draft_id = draft_id self._rotation_task: asyncio.Task | None = None + self._closed = False self._last_text = "" @classmethod @@ -891,20 +893,34 @@ class OperationStatus: 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: + async def finish(self, text: str | None = None) -> None: + """Close the status out. + + A live draft only disappears once the bot sends a real message, so the + final text must always go out through ``sendMessage`` — otherwise the + last activity phrase keeps hanging in the chat for the rest of the + draft's TTL. + """ await self.cancel() + self._closed = True + if text is None: + return 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. + if self.message is not None and self._finished_draft_id is not None: + # Draft path: a real message replaces the draft in the chat. self.draft_id = None try: - await message.answer(text) + await self.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 finish_if_open(self, text: str) -> None: + """Safety net: close the status only if nothing has closed it yet.""" + if not self._closed: + await self.finish(text) + async def cancel(self) -> None: """Stop the phrase rotation without changing the visible status.""" task, self._rotation_task = self._rotation_task, None @@ -929,6 +945,17 @@ def _draft_supported(message: Message | None) -> bool: return str(getattr(chat, "type", "") or "").lower() == "private" +async def finish_status(status: "OperationStatus | None", text: str) -> None: + """Close an operation status if one is open and not closed yet. + + Allows ``status`` to be None so callers can guard every exit path without + repeating ``if status:`` — an unfinished draft would otherwise keep showing + its last activity phrase in the chat. + """ + if status is not None: + await status.finish_if_open(text) + + async def _classify_and_respond( message: Message, text: str, @@ -951,9 +978,13 @@ async def _classify_and_respond_inner( await message.answer("Не удалось определить пользователя.") return if text.strip().casefold() == "/help": + if operation_status is not None: + await operation_status.finish_if_open("✅ Открываю справку") await help_command(message, state) return if not await _check_subscription(message): + if operation_status is not None: + await operation_status.finish_if_open("⚠️ Нужна активная подписка") return reply_message = message.reply_to_message original_post = "" @@ -1202,9 +1233,10 @@ async def _handle_image_message(message: Message, bot: Bot, state: FSMContext) - await status.update("⏳ Анализирую стиль и обновляю профиль…") await _process_image_group(messages, bot, status=status) except Exception as exc: - if status: - await status.finish("❌ Не удалось обработать изображения") + await finish_status(status, "❌ Не удалось обработать изображения") await report_error(message, "чтение скриншота или обновление ToV", exc) + else: + await finish_status(status, "✅ Готово") @router.message(F.photo | (F.document & F.document.mime_type.startswith("image/") & (F.document.mime_type != "image/gif"))) @@ -1263,6 +1295,7 @@ async def _process_voice(message: Message, bot: Bot, state: FSMContext, status=N 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, operation_status=status) + await finish_status(status, "✅ Готово") @router.message(F.voice | F.audio) @@ -1282,8 +1315,10 @@ async def transcribe_voice(message: Message, bot: Bot, state: FSMContext) -> Non async with typing_status(bot, message.chat.id): await _process_voice(message, bot, state, status=status) except Exception as exc: - await status.finish("❌ Не удалось обработать аудио") + await status.finish_if_open("❌ Не удалось обработать аудио") await report_error(message, "обработка голосового и генерация", exc) + else: + await finish_status(status, "✅ Готово") @router.message( diff --git a/tests/test_activity_phrases.py b/tests/test_activity_phrases.py index 8c9b3f8..50958c8 100644 --- a/tests/test_activity_phrases.py +++ b/tests/test_activity_phrases.py @@ -150,6 +150,41 @@ class GroupStatusTests(unittest.IsolatedAsyncioTestCase): status = await bot.OperationStatus.start(message, "⏳ Старт", kind="generation") self.assertIsNone(status._rotation_task) + async def test_finish_without_text_only_stops_rotation(self): + message = make_message("private") + status = await bot.OperationStatus.start(message, "⏳ Старт", kind="generation") + await status.finish() + + # Никакого сообщения, но ротация остановлена и статус закрыт. + message.answer.assert_not_awaited() + self.assertTrue(status._closed) + self.assertIsNone(status._rotation_task) + + async def test_finish_is_idempotent(self): + message = make_message("private") + status = await bot.OperationStatus.start(message, "⏳ Старт", kind="generation") + await status.finish("✅ Готово") + await status.finish_if_open("✅ Ещё раз") + await status.finish_if_open("✅ И ещё") + + # Повторные вызовы не должны слать дубли. + self.assertEqual(message.answer.await_count, 1) + message.answer.assert_awaited_once_with("✅ Готово") + + async def test_finish_status_helper_tolerates_none(self): + await bot.finish_status(None, "✅ Готово") # не должно падать + + async def test_image_and_voice_paths_close_the_status(self): + """Успешный выход без явного finish не оставляет драфт висеть.""" + for kind in ("images", "voice"): + message = make_message("private") + status = await bot.OperationStatus.start(message, "⏳ Старт", kind=kind) + await bot.finish_status(status, "✅ Готово") + self.assertTrue(status._closed) + self.assertIsNone(status._rotation_task) + self.assertIsNone(status.draft_id) + message.answer.assert_awaited_once_with("✅ Готово") + async def test_draft_failure_falls_back_to_real_message(self): message = make_message("private") message.bot.send_message_draft = AsyncMock(side_effect=RuntimeError("draft unsupported"))