fix: push the final text into the draft before sending it as a message

Драфт с последней фразой висел в чате ещё несколько секунд после того, как
уже ушло сообщение «✅ Обучение завершено»: содержимое драфта не совпадало
с финальным сообщением, и клиент не мог их слить.

Финальный текст теперь сначала пушится в драфт тем же draft_id, и только
потом уходит реальным сообщением с тем же текстом — драфт заменяется без
задержки. finish() без текста гасит драфт пустым пушем.

Проверено: 198 тестов OK (2 новых на совпадение текста драфта и сообщения).

VERSION: 0.1.0+50 -> 0.1.0+51
This commit is contained in:
admin committed 2026-10-11 15:34:15 +02:00
1 parent 635b78cb86
commit 7a630a974f
5 files changed
+69 -13

No files matched your search

+15 -3
View File
@@ -71,7 +71,7 @@ Telegram-бот, который по запросу пользователя г
- 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; не сохраняются в репозитории.
- Поддержка: `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`).
- `DEPLOY_ENV` включает dev-поведение только при точном значении `development`; production, пустое и неизвестное значение отключают его. `BOT_ENV` отдельно выбирает Telegram Bot API. `DEV_CHAT_ID` используется для уведомления о версии; версия в файле `VERSION` (сейчас `0.1.0+51`). Ротационный файл лога — `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`.
## Статус
@@ -104,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+50`
- Версия разработки: `0.1.0+51`
- [x] Статус долгой операции стримится живым драфтом `sendMessageDraft` в приватном чате и каждые 3–8 сек меняет случайную фразу из пула операции (`activity_phrases.py`); в группах и топиках работает прежний `edit_text`
- [x] Справка бота (`/help`) описывает `/balance` и `/subscribe`
- [x] Проект Gitea «Social Post Writer Bot Roadmap» с задачами (выполненные — в «Готово», будущие — в Backlog)
@@ -129,8 +129,20 @@ Telegram-бот, который по запросу пользователя г
повторное обращение продолжает ту же переписку с сохранённой историей.
- **Название топика** — «Имя · @username» (без username — «Имя · id»).
- **Аватарка**: Telegram не даёт поставить на топик произвольную картинку, только custom emoji
(`SUPPORT_TOPIC_EMOJI_ID`). Аватарка профиля уходит первым сообщением-карточкой вместе с именем,
(`SUPPORT_TOPIC_EMOJI_ID`). Аватарка профиля уходит **первым сообщением-карточкой** вместе с именем,
username и `user_id`.
- **Топик создаётся сразу при первом нажатии «🆘 Поддержка»**, а не при первом сообщении. Карточка
со сводкой уходит **один раз** — при создании топика; повторные обращения пишут в тот же топик
и ничего не дублируют.
- **Данные профиля в карточке** (собирает `user_store.support_profile_summary`):
- ID пользователя;
- сколько дней пользуется ботом (по `users.created_at`, fallback — начало триала);
- сколько раз продлевалась подписка (`COUNT(*)` по `payments`);
- сколько дней осталось (`_days_until`; при истёкшем периоде — «нет активного периода»);
- обучен бот или нет (`users.is_trained`).
Сводка считается в отдельном потоке; при любой ошибке карточка уходит без этого блока,
топик всё равно создаётся.
- **Заметки оператора**: сообщение с префиксом `//` (`SUPPORT_NOTE_PREFIX`) остаётся в топике и
пользователю не отправляется.
- **Завершение**: «🏠 Главное меню» снимает `is_active`, топик не удаляется и не закрывается.
+9 -2
View File
@@ -14,7 +14,7 @@ Telegram-бот для генерации постов для соцсетей.
- [Деплой и конфигурация](#деплой-и-конфигурация)
- [Тесты](#тесты)
Версия разработки: `0.1.0+50`.
Версия разработки: `0.1.0+51`.
---
@@ -79,6 +79,13 @@ onboarding. В «Подписке» сразу отображаются тип
- Telegram не даёт поставить на топик произвольную картинку, только custom emoji
(`SUPPORT_TOPIC_EMOJI_ID`). Аватарка профиля уходит **первым сообщением-карточкой** в топик
(`get_user_profile_photos` → `send_photo` с подписью «Имя / @username / ID»).
- **Топик создаётся сразу при первом нажатии «🆘 Поддержка»**, а не при первом сообщении.
Карточка со сводкой профиля уходит **один раз** — при создании топика; повторные обращения
пишут в тот же топик и ничего не дублируют. Если топик удалили вручную, при следующем
сообщении он создаётся заново — и сводка уйдёт снова.
- **Сводка профиля в карточке** (`user_store.support_profile_summary`): ID пользователя;
сколько дней пользуется ботом; сколько раз продлевалась подписка; сколько дней осталось;
обучен бот или нет.
- Пересылаются **все типы контента** — текст, фото, документы, голосовые, альбомы (`copy_message`).
- Ответ оператора — любое сообщение в топике от живого участника. Сообщение с префиксом `//`
(`SUPPORT_NOTE_PREFIX`) остаётся заметкой внутри топика и пользователю не уходит.
@@ -302,7 +309,7 @@ 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+50`). При `DEPLOY_ENV=development` и изменении версии бот шлёт уведомление о версии в `DEV_CHAT_ID`.
- **VERSION:** файл `VERSION` (`0.1.0+51`). При `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 `//` — префикс заметки, которая не уходит пользователю),
+1 -1
View File
@@ -1 +1 @@
0.1.0+50
0.1.0+51
+29 -5
View File
@@ -896,18 +896,22 @@ class OperationStatus:
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.
A live draft only disappears once the bot sends a real message. Its
content must match that message, otherwise the client cannot merge
them: the draft keeps floating in the chat for a few more seconds
while already being stale. So the final text is pushed into the draft
first, then sent as a real message with the same text.
"""
await self.cancel()
self._closed = True
if text is None:
await self._clear_draft()
return
self._last_text = text
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.
# Bring the draft to the final text so the real message replaces it
# instead of racing a stale one.
await self._push_draft(self._finished_draft_id, text)
self.draft_id = None
try:
await self.message.answer(text)
@@ -916,6 +920,26 @@ class OperationStatus:
return
await self.update(text)
async def _push_draft(self, draft_id: int, text: str) -> None:
message = self.message
if message is None or message.bot is None:
return
try:
await message.bot.send_message_draft(
chat_id=message.chat.id,
draft_id=draft_id,
text=text,
)
except Exception as exc:
logger.debug("Draft push failed (%s): %s", type(exc).__name__, str(exc)[:120])
async def _clear_draft(self) -> None:
"""Drop the draft without leaving any text behind."""
draft_id = self._finished_draft_id
if draft_id is None:
return
await self._push_draft(draft_id, "")
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:
+15 -2
View File
@@ -150,13 +150,26 @@ 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):
async def test_finish_pushes_final_text_into_draft_before_sending(self):
"""Драфт приводится к финальному тексту, иначе он висит ещё несколько секунд."""
message = make_message("private")
status = await bot.OperationStatus.start(message, "⏳ Старт", kind="generation")
await status.finish("✅ Обучение завершено")
drafts = message.bot.send_message_draft.await_args_list
# Последний пуш в драфт — тем же текстом, что ушёл реальным сообщением.
self.assertEqual(drafts[-1].kwargs["text"], "✅ Обучение завершено")
self.assertEqual(drafts[-1].kwargs["draft_id"], status._finished_draft_id)
message.answer.assert_awaited_once_with("✅ Обучение завершено")
async def test_finish_without_text_clears_draft(self):
message = make_message("private")
status = await bot.OperationStatus.start(message, "⏳ Старт", kind="generation")
await status.finish()
# Никакого сообщения, но ротация остановлена и статус закрыт.
# Никакого сообщения, но драфт погашен и ротация остановлена.
message.answer.assert_not_awaited()
self.assertEqual(message.bot.send_message_draft.await_args_list[-1].kwargs["text"], "")
self.assertTrue(status._closed)
self.assertIsNone(status._rotation_task)