From 7a630a974f32ace8b901b8a2d7b18fdf86af07f2 Mon Sep 17 00:00:00 2001 From: admin Date: Sun, 11 Oct 2026 15:34:15 +0200 Subject: [PATCH] fix: push the final text into the draft before sending it as a message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Драфт с последней фразой висел в чате ещё несколько секунд после того, как уже ушло сообщение «✅ Обучение завершено»: содержимое драфта не совпадало с финальным сообщением, и клиент не мог их слить. Финальный текст теперь сначала пушится в драфт тем же draft_id, и только потом уходит реальным сообщением с тем же текстом — драфт заменяется без задержки. finish() без текста гасит драфт пустым пушем. Проверено: 198 тестов OK (2 новых на совпадение текста драфта и сообщения). VERSION: 0.1.0+50 -> 0.1.0+51 --- IDEA.MD | 18 +++++++++++++++--- README.md | 11 +++++++++-- VERSION | 2 +- bot.py | 34 +++++++++++++++++++++++++++++----- tests/test_activity_phrases.py | 17 +++++++++++++++-- 5 files changed, 69 insertions(+), 13 deletions(-) diff --git a/IDEA.MD b/IDEA.MD index e9729ec..c18ecbb 100644 --- a/IDEA.MD +++ b/IDEA.MD @@ -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`, топик не удаляется и не закрывается. diff --git a/README.md b/README.md index 4a82ef1..7cfd762 100644 --- a/README.md +++ b/README.md @@ -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 `//` — префикс заметки, которая не уходит пользователю), diff --git a/VERSION b/VERSION index ac25eaa..0447f3e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+50 +0.1.0+51 diff --git a/bot.py b/bot.py index 16dc35a..9599017 100644 --- a/bot.py +++ b/bot.py @@ -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: diff --git a/tests/test_activity_phrases.py b/tests/test_activity_phrases.py index 50958c8..e8575ba 100644 --- a/tests/test_activity_phrases.py +++ b/tests/test_activity_phrases.py @@ -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)