fix: always close the operation status so the draft stops hanging
Драфт исчезает только когда бот отправляет реальное сообщение. Ряд путей завершения операции не закрывал статус, из-за чего последняя показанная фраза висела в чате до конца 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
This commit is contained in:
1 parent
87e421ae73
commit
635b78cb86
5 files changed
+155
-17
No files matched your search
@@ -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** → **В работе** → **Готово**.
|
||||
|
||||
@@ -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 <container>`. После деплоя ротационный файл приложения будет доступен как `/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` может быть не установлен).
|
||||
@@ -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(
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Reference in new issue
Block a user