feat: stream operation status as a live draft with activity phrases

Статус долгой операции больше не оставляет в чате отдельное сообщение:
в приватном чате он стримится живым драфтом (sendMessageDraft), пока
идет классификация, генерация, чтение скриншотов или распознавание речи.

- activity_phrases.py: пулы фраз по типу операции (generation, training,
  images, voice, ideas), случайный интервал 3-8 сек (ACTIVITY_MIN_SECONDS /
  ACTIVITY_MAX_SECONDS), фраза не повторяет предыдущую
- OperationStatus: драфт со стабильным draft_id, фоновая ротация фраз,
  finish() отправляет финальный статус реальным сообщением, retarget()
  меняет пул на лету (generate_ideas), предохранитель ACTIVITY_MAX_ROTATIONS
- в группах и топиках драфты недоступны: прежний answer + edit_text,
  ротация фраз отключена; при ошибке драфта автоматический откат
- Dockerfile копирует activity_phrases.py и support.py, иначе контейнер
  падает на импорте

Проверено: 172 теста OK,其中包括 14 новых в tests/test_activity_phrases.py.
Тесты нашли и закрыли баг: ротация запускалась и в группах, где каждый тик
был бы реальным редактированием сообщения.

VERSION: 0.1.0+48 -> 0.1.0+49
This commit is contained in:
admin committed 2026-10-11 14:51:07 +02:00
1 parent a95ae7ae5b
commit 87e421ae73
11 files changed
+936 -24

No files matched your search

+1 -1
View File
@@ -6,5 +6,5 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY bot.py apinex_client.py user_store.py help_message.py payments.py keyboards.py raw_updates.py VERSION ./
COPY bot.py apinex_client.py user_store.py help_message.py payments.py keyboards.py raw_updates.py support.py activity_phrases.py VERSION ./
CMD ["python", "bot.py"]
+3 -2
View File
@@ -70,7 +70,7 @@ 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+48`). Ротационный файл лога — `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+49`). Ротационный файл лога — `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 +103,8 @@ 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+48`
- Версия разработки: `0.1.0+49`
- [x] Статус долгой операции стримится живым драфтом `sendMessageDraft` в приватном чате и каждые 3–8 сек меняет случайную фразу из пула операции (`activity_phrases.py`); в группах и топиках работает прежний `edit_text`
- [x] Справка бота (`/help`) описывает `/balance` и `/subscribe`
- [x] Проект Gitea «Social Post Writer Bot Roadmap» с задачами (выполненные — в «Готово», будущие — в Backlog)
- [x] Ротационный файл лога `/app/data/bot.log` (5 MiB × 3 архива) на persistent volume
+16 -4
View File
@@ -13,7 +13,7 @@ Telegram-бот для генерации постов для соцсетей.
- [Деплой и конфигурация](#деплой-и-конфигурация)
- [Тесты](#тесты)
Версия разработки: `0.1.0+48`.
Версия разработки: `0.1.0+49`.
---
@@ -75,7 +75,10 @@ onboarding. В «Подписке» сразу отображаются тип
| Очистить данные | В режиме «Обучение» нажимает «Сбросить данные» или вводит `/clear`, затем подтверждает | Очищает стиль и историю, не меняя подписку или триал |
### Индикаторы и обратная связь
- Пока **идёт обработка** (классификация, расшифровка, чтение скриншота, генерация) — бот показывает «печатает» (`typing_status`, обновление каждые 4 секунды) и отдельное сообщение-статус, которое редактируется по этапам; перед результатом оно меняется на ✅, при ошибке — на ❌. После обучения в чате остаётся только краткий статус завершения, без вывода содержимого профиля.
- Пока **идёт обработка** (классификация, расшифровка, чтение скриншота, генерация) — бот показывает «печатает» (`typing_status`, обновление каждые 4 секунды).
- **В приватном чате** статус операции стримится живым драфтом (`sendMessageDraft`): он не тратит лимит отправки в чат, не оставляет после себя сообщения и анимируется при смене текста. Пока операция длится, драфт каждые **3–8 секунд** (случайно) получает случайную фразу из пула, привязанного к типу операции, — видно, что работа не встала. Финальный результат уходит обычным сообщением.
- **В группах и топиках** драфты недоступны: там работает прежний механизм — отдельное сообщение-статус, которое редактируется по этапам; перед результатом оно меняется на ✅, при ошибке — на ❌. Ротация фраз в группах отключена.
- Пулы фраз живут в `activity_phrases.py` (`generation`, `training`, `images`, `voice`, `ideas`), интервал задаётся переменными `ACTIVITY_MIN_SECONDS` / `ACTIVITY_MAX_SECONDS`.
- В режиме «Генерация» запрос сначала проверяет отдельный LLM-классификатор prompt injection, затем классификатор намерений. При обнаружении попытки изменить правила генератор не вызывается; ошибка проверки блокирует дальнейшую обработку. Модель проверки настраивается отдельно через `APINEX_INJECTION_CLASSIFIER_MODEL`.
- Благодарность и сообщения не по теме получают случайную заготовленную фразу и не запускают генерацию/идеи. Режим «Обучение» этим изменением не затронут.
- В режиме «Генерация» изображения и галереи используются для подготовки поста; подпись считается ТЗ. Без подписи бот сначала запрашивает ТЗ. Видео, видеокружки и GIF/анимации отклоняются во всех режимах.
@@ -165,6 +168,15 @@ update_tone_from_image → _handle_image_message
| `help_navigation` | Callback `help:<section>` → `edit_message_text(rich_message=...)` — переключение разделов |
| `edit_rich_message` | Редактирование текущего rich-сообщения на новый раздел |
| `typing_status` | Индикатор «печатает» во время обработки |
| `OperationStatus` | Статус операции: в приватном чате — живой драфт с ротацией фраз, иначе — сообщение с `edit_text` |
| `_draft_supported` | Драфты доступны только в приватных чатах |
### Ключевые функции (`activity_phrases.py`)
| Функция | Что делает |
|---|---|
| `next_phrase(kind, exclude)` | Случайная фраза из пула операции, не повторяет только что показанную |
| `random_interval()` | Случайная пауза между сменами фраз, `ACTIVITY_MIN_SECONDS..ACTIVITY_MAX_SECONDS` |
| `report_error` | Понятная ошибка пользователю, секреты вырезаны |
### Ключевые функции (`user_store.py`)
@@ -251,8 +263,8 @@ 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+48`). При `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`).
- **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"]`.
---
+1 -1
View File
@@ -1 +1 @@
0.1.0+48
0.1.0+49
+121
View File
@@ -0,0 +1,121 @@
"""Пулы фраз, которыми бот обозначает ход долгой операции.
Фразы показываются в живом драфте (``sendMessageDraft``) в приватном чате и
сменяют друг друга случайным образом через случайные промежутки времени, пока
операция не завершилась. Ни одна фраза не обещает конкретного результата — это
только обозначение процесса.
"""
from __future__ import annotations
import os
import random
GENERATION_PHRASES: tuple[str, ...] = (
"⏳ Разбираю запрос и подбираю формулировки…",
"⏳ Собираю фактуру из того, что ты прислал…",
"⏳ Придумываю три разных захода к теме…",
"⏳ Проверяю, что не придумываю лишних фактов…",
"⏳ Подбираю более точное начало поста…",
"⏳ Слежу за длиной и читаемостью каждого варианта…",
"⏳ Убираю канцелярит и лишние вводные…",
"⏳ Собираю три варианта и проверяю орфографию…",
)
TRAINING_PHRASES: tuple[str, ...] = (
"⏳ Читаю присланные материалы…",
"⏳ Выделяю особенности твоей подачи…",
"⏳ Сопоставляю новые примеры с прежним профилем…",
"⏳ Уточняю портрет аудитории…",
"⏳ Отмечаю типичные темы и повороты…",
"⏳ Собираю обновлённый профиль аккаунта…",
"⏳ Проверяю, что ничего не потерялось…",
"⏳ Упаковываю вывод в компактный профиль…",
)
IMAGES_PHRASES: tuple[str, ...] = (
"⏳ Скачиваю изображения…",
"⏳ Распознаю тексты на скриншотах…",
"⏳ Отделяю заголовки от основного текста…",
"⏳ Сверяю извлечённые посты между собой…",
"⏳ Ищу повторяющиеся приёмы в текстах…",
"⏳ Проверяю, что тексты прочитаны полностью…",
"⏳ Готовлю варианты поста по увиденному…",
)
VOICE_PHRASES: tuple[str, ...] = (
"⏳ Скачиваю аудиосообщение…",
"⏳ Перевожу речь в текст…",
"⏳ Расставляю знаки препинания в расшифровке…",
"⏳ Уточняю границы фраз…",
"⏳ Проверяю расшифровку на слух…",
"⏳ Разбираю, что именно ты просишь…",
)
IDEAS_PHRASES: tuple[str, ...] = (
"⏳ Ищу свежий угол для темы…",
"⏳ Проверяю, что идея не повторяет прошлые…",
"⏳ Подбираю формулировку заголовка…",
"⏳ Оцениваю пользу идеи для твоей аудитории…",
"⏳ Формулирую, зачем эта тема аккаунту…",
"⏳ Отбираю самый сильный вариант…",
)
DEFAULT_PHRASES: tuple[str, ...] = (
"⏳ Работаю над запросом…",
"⏳ Привожу результат к нужному виду…",
"⏳ Почти готова, проверяю детали…",
)
ACTIVITY_PHRASES: dict[str, tuple[str, ...]] = {
"generation": GENERATION_PHRASES,
"training": TRAINING_PHRASES,
"images": IMAGES_PHRASES,
"voice": VOICE_PHRASES,
"ideas": IDEAS_PHRASES,
}
def _positive_float(name: str, default: float) -> float:
raw = os.getenv(name, "").strip()
if not raw:
return default
try:
value = float(raw)
except ValueError:
return default
return value if value > 0 else default
def _positive_int(name: str, default: int) -> int:
raw = os.getenv(name, "").strip()
if not raw:
return default
try:
value = int(raw)
except ValueError:
return default
return value if value > 0 else default
ACTIVITY_MIN_SECONDS = _positive_float("ACTIVITY_MIN_SECONDS", 3.0)
ACTIVITY_MAX_SECONDS = _positive_float("ACTIVITY_MAX_SECONDS", 8.0)
if ACTIVITY_MIN_SECONDS > ACTIVITY_MAX_SECONDS:
ACTIVITY_MIN_SECONDS, ACTIVITY_MAX_SECONDS = ACTIVITY_MAX_SECONDS, ACTIVITY_MIN_SECONDS
# Предохранитель: если операция зависла и finish() не вызвался, ротация
# всё равно прекратится и не будет бесконечно долбить драфт.
ACTIVITY_MAX_ROTATIONS = _positive_int("ACTIVITY_MAX_ROTATIONS", 12)
def random_interval() -> float:
"""Случайная пауза между сменами фраз, в секундах."""
return random.uniform(ACTIVITY_MIN_SECONDS, ACTIVITY_MAX_SECONDS)
def next_phrase(kind: str | None = None, exclude: str | None = None) -> str:
"""Случайная фраза из пула операции, не повторяющая только что показанную."""
pool = ACTIVITY_PHRASES.get(kind or "", DEFAULT_PHRASES)
if exclude is None or len(pool) < 2:
return random.choice(pool)
variants = tuple(phrase for phrase in pool if phrase != exclude)
return random.choice(variants)
+184 -14
View File
@@ -30,12 +30,18 @@ from aiogram.types import (
import payments
import user_store
from activity_phrases import (
ACTIVITY_MAX_ROTATIONS,
next_phrase,
random_interval,
)
from raw_updates import RawUpdateMiddleware, init_raw_updates_db
from apinex_client import APINEX_STT_URL, POST_HISTORY_CHAR_LIMIT, POST_HISTORY_LIMIT, classify_intent, classify_prompt_injection, create_tone_profile, current_operation_id, diagnostic_operation, extract_posts_from_images, extract_transcript, generate_ideas, generate_posts, generate_posts_from_images, transcribe_audio
from help_message import WELCOME_RICH_HTML, help_plain_view, help_view, rich_to_plain
from keyboards import MAIN_MENU_VALUE, TRAINING_RESET_VALUE, main_menu_keyboard, mode_keyboard, resolve_button_text
from keyboards import MAIN_MENU_VALUE, SUPPORT_VALUE, TRAINING_RESET_VALUE, main_menu_keyboard, mode_keyboard, resolve_button_text
from payments import send_subscription_invoice
import support
from user_store import (
add_post_samples,
clear_user_data,
@@ -153,6 +159,16 @@ def keyboard_for_mode(mode: str) -> ReplyKeyboardMarkup:
async def enter_mode(message: Message, state: FSMContext, mode: str) -> None:
if mode != "help" and message.from_user is not None:
try:
if await asyncio.to_thread(support.is_support_active, message.from_user.id):
await message.answer(
"Идёт обращение в поддержку. Нажми «Главное меню», чтобы завершить его.",
reply_markup=mode_keyboard("help", support=True),
)
return
except Exception as exc:
logger.warning("Support active lookup failed (%s)", type(exc).__name__)
await state.clear()
await state.set_state(MODE_STATES[mode])
title, description = MODE_COPY[mode]
@@ -168,11 +184,29 @@ async def enter_mode(message: Message, state: FSMContext, mode: str) -> None:
async def show_main_menu(message: Message, state: FSMContext) -> None:
"""Выход в главное меню; активное обращение поддержки закрывается."""
if message.from_user is not None:
try:
await support.deactivate(message.from_user.id)
except Exception as exc:
logger.warning("Support deactivate failed (%s)", type(exc).__name__)
await state.clear()
await state.set_state(BotMode.lobby)
await message.answer("Выбери режим работы.", reply_markup=main_menu_keyboard())
async def open_support(message: Message, state: FSMContext) -> None:
"""Открывает обращение в поддержку из режима «Помощь»."""
if message.from_user is None:
await message.answer("Не удалось определить пользователя.")
return
await support.start_support(message)
await message.answer(
"Напиши сообщение — я передам его оператору.",
reply_markup=mode_keyboard("help", support=True),
)
async def require_mode(message: Message, state: FSMContext, mode: str) -> bool:
if await current_mode(state) == mode:
return True
@@ -562,7 +596,7 @@ async def process_training_text(message: Message, text: str) -> None:
await message.answer("Не удалось определить пользователя или бота.")
return
with diagnostic_operation():
status = await OperationStatus.start(message, "⏳ Обрабатываю сведения об аккаунте…")
status = await OperationStatus.start(message, "⏳ Обрабатываю сведения об аккаунте…", kind="training")
try:
async with typing_status(message.bot, message.chat.id):
await status.update("⏳ Обновляю профиль аккаунта…")
@@ -670,7 +704,7 @@ async def _generate_post_from_training_inner(message: Message) -> None:
)
return
status = await OperationStatus.start(message, "⏳ Выбираю тему по обученным данным…")
status = await OperationStatus.start(message, "⏳ Выбираю тему по обученным данным…", kind="generation")
try:
tone, samples, notes = await asyncio.gather(
asyncio.to_thread(get_tone_of_voice, user_id),
@@ -752,20 +786,104 @@ async def typing_status(bot: Bot, chat_id: int):
class OperationStatus:
"""A user-visible status message edited throughout a long operation."""
"""A user-visible status shown throughout a long operation.
def __init__(self, status_message: Message | None):
In private chats the status is streamed as an ephemeral live draft
(``sendMessageDraft``): it costs no per-chat send budget, leaves no
permanent message behind and animates as the text grows. Every
``ACTIVITY_MIN_SECONDS..ACTIVITY_MAX_SECONDS`` seconds the draft is
replaced with a random phrase from the pool of the operation kind, so the
user sees that work is still in progress.
Anywhere else (groups, topics, missing bot handle, API failure) the status
degrades to the legacy path: one real message edited in place.
"""
def __init__(
self,
message: Message | None = None,
status_message: Message | None = None,
*,
kind: str | None = None,
draft_id: int | None = None,
):
self.message = message
self.status_message = status_message
self.kind = kind
self.draft_id = draft_id
self._rotation_task: asyncio.Task | None = None
self._last_text = ""
@classmethod
async def start(cls, message: Message, text: str) -> "OperationStatus":
async def start(cls, message: Message, text: str, *, kind: str | None = None) -> "OperationStatus":
if _draft_supported(message):
status = cls(message=message, kind=kind, draft_id=random.randint(1, 2**31 - 1))
await status.update(text)
else:
try:
status = cls(message=message, status_message=await message.answer(text), kind=kind)
except Exception as exc:
logger.warning("Status message send failed (%s): %s", type(exc).__name__, str(exc)[:120])
status = cls(message=message, kind=kind, status_message=None)
status._last_text = text
if kind is not None:
status._start_rotation()
return status
@property
def uses_draft(self) -> bool:
return self.draft_id is not None and self.message is not None
def retarget(self, kind: str | None) -> None:
"""Switch the phrase pool mid-flight and restart the rotation."""
if self.kind == kind:
return
self.kind = kind
task, self._rotation_task = self._rotation_task, None
if task is not None:
task.cancel()
if kind is not None:
self._start_rotation()
def _start_rotation(self) -> None:
# Phrase rotation only makes sense for live drafts: in a group chat
# there is no draft and every rotation would be a real message edit.
if self._rotation_task is not None or not self.uses_draft:
return
self._rotation_task = asyncio.create_task(self._rotate_phrases())
async def _rotate_phrases(self) -> None:
try:
return cls(await message.answer(text))
for _ in range(ACTIVITY_MAX_ROTATIONS):
await asyncio.sleep(random_interval())
await self.update(next_phrase(self.kind, self._last_text))
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning("Status message send failed (%s): %s", type(exc).__name__, str(exc)[:120])
return cls(None)
logger.debug("Activity phrase rotation stopped: %s", type(exc).__name__)
async def update(self, text: str) -> None:
self._last_text = text
message = self.message
draft_id = self.draft_id
if message is not None and draft_id is not None and message.bot is not None:
try:
await message.bot.send_message_draft(
chat_id=message.chat.id,
draft_id=draft_id,
text=text,
)
return
except Exception as exc:
logger.warning("Draft update failed (%s): %s", type(exc).__name__, str(exc)[:120])
self.draft_id = None
try:
self.status_message = await message.answer(text)
except Exception as send_exc:
logger.warning(
"Status fallback send failed (%s): %s", type(send_exc).__name__, str(send_exc)[:120]
)
return
if self.status_message is None:
return
try:
@@ -774,8 +892,42 @@ class OperationStatus:
logger.warning("Status message edit failed (%s): %s", type(exc).__name__, str(exc)[:120])
async def finish(self, text: str) -> None:
await self.cancel()
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.
self.draft_id = None
try:
await 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 cancel(self) -> None:
"""Stop the phrase rotation without changing the visible status."""
task, self._rotation_task = self._rotation_task, None
if task is None:
return
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
def _draft_supported(message: Message | None) -> bool:
"""Live drafts only exist in private chats; groups get the legacy path."""
if message is None:
return False
if getattr(message, "bot", None) is None:
return False
chat = getattr(message, "chat", None)
if chat is None:
return False
return str(getattr(chat, "type", "") or "").lower() == "private"
async def _classify_and_respond(
message: Message,
@@ -810,11 +962,13 @@ async def _classify_and_respond_inner(
status = operation_status
try:
if show_typing and status is None:
status = await OperationStatus.start(message, "⏳ Разбираю запрос…")
status = await OperationStatus.start(message, "⏳ Разбираю запрос…", kind="generation")
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"] == "generate_ideas" and status is not None:
status.retarget("ideas")
if intent["intent"] == "help":
if status:
await status.finish("✅ Открываю справку")
@@ -1035,11 +1189,15 @@ async def _handle_image_message(message: Message, bot: Bot, state: FSMContext) -
}
)
return
status = await OperationStatus.start(message, "⏳ Изучаю изображения и готовлю посты…")
status = await OperationStatus.start(
message, "⏳ Изучаю изображения и готовлю посты…", kind="images"
)
async with typing_status(bot, message.chat.id):
await _generate_posts_from_images(message, images, caption, status)
return
status = await OperationStatus.start(message, "⏳ Читаю посты на изображениях…")
status = await OperationStatus.start(
message, "⏳ Читаю посты на изображениях…", kind="images"
)
async with typing_status(bot, message.chat.id):
await status.update("⏳ Анализирую стиль и обновляю профиль…")
await _process_image_group(messages, bot, status=status)
@@ -1119,7 +1277,7 @@ async def transcribe_voice(message: Message, bot: Bot, state: FSMContext) -> Non
if (await notify_development_deploy(bot, state, message.from_user.id if message.from_user else None)) is True:
return
with diagnostic_operation():
status = await OperationStatus.start(message, "⏳ Подготавливаю аудио…")
status = await OperationStatus.start(message, "⏳ Подготавливаю аудио…", kind="voice")
try:
async with typing_status(bot, message.chat.id):
await _process_voice(message, bot, state, status=status)
@@ -1166,6 +1324,8 @@ async def handle_command_text(message: Message, state: FSMContext) -> None:
await balance_command(message, state)
elif command == "/subscribe":
await subscribe_command(message, state)
elif command == "/support":
await open_support(message, state)
else:
await message.answer("Неизвестная команда. Выбери режим или открой /help.")
@@ -1183,6 +1343,9 @@ async def handle_text(message: Message, state: FSMContext) -> None:
if button == MAIN_MENU_VALUE:
await show_main_menu(message, state)
return
if button == SUPPORT_VALUE:
await open_support(message, state)
return
if button == TRAINING_RESET_VALUE:
await clear_command(message, state)
return
@@ -1194,7 +1357,9 @@ async def handle_text(message: Message, state: FSMContext) -> None:
pending_image = (await state.get_data()).get("pending_image_request")
if mode == "generation" and pending_image:
await state.update_data(pending_image_request=None)
status = await OperationStatus.start(message, "⏳ Изучаю изображения и готовлю посты…")
status = await OperationStatus.start(
message, "⏳ Изучаю изображения и готовлю посты…", kind="images"
)
async with typing_status(message.bot, message.chat.id):
await _generate_posts_from_images(message, pending_image["images"], text, status)
return
@@ -1250,6 +1415,7 @@ async def set_main_menu(bot: Bot) -> None:
BotCommand(command="idea", description="Идея (генерация)"),
BotCommand(command="balance", description="Статус подписки"),
BotCommand(command="subscribe", description="Оплатить подписку"),
BotCommand(command="support", description="Написать в поддержку"),
]
)
except Exception as exc:
@@ -1259,6 +1425,7 @@ async def set_main_menu(bot: Bot) -> None:
async def main() -> None:
init_db()
payments.init_payments_table()
await asyncio.to_thread(support.init_support_table)
if is_development_environment():
await asyncio.to_thread(init_raw_updates_db)
token = os.environ.get("BOT_TOKEN", "").strip()
@@ -1273,6 +1440,9 @@ async def main() -> None:
configure_raw_update_logging(dp)
dp.include_router(payments.router)
dp.include_router(router)
# Группа поддержки: роутер раньше основного, чтобы служебные чаты не шли в обычные режимы.
dp.include_router(support.router)
dp.message.outer_middleware(support.SupportRelayMiddleware())
# Ставим меню один раз при старте, чтобы оно было у всех, кто писал боту.
await set_main_menu(bot)
+6
View File
@@ -55,6 +55,12 @@ HELP_SECTIONS: dict[str, tuple[str, str]] = {
<p>Действия доступны только в своём режиме: <code>/style</code> и <code>/clear</code> — обучение; <code>/post</code> и <code>/idea</code> — генерация; <code>/balance</code> и <code>/subscribe</code> — подписка. <code>/clear</code> и кнопка «Сбросить данные» сначала запрашивают подтверждение; подписка и триал не меняются.</p>
<p>В режиме «Генерация» можно попросить идею обычными словами: «предложи идею для поста», «о чём написать?», «накидай тему».</p>""",
),
"support": (
"🆘 Поддержка",
"""<p>В режиме «Помощь» нажми «🆘 Поддержка» или отправь <code>/support</code>. Напиши текстом, чем помочь, — можно приложить фото, документ или голосовое.</p>
<p>Сообщение уходит оператору, он отвечает прямо в этом чате. Переписка продолжается в том же обращении: можно отвлечься на другие режимы, вернуться и дописать.</p>
<p>Кнопка «Главное меню» завершает обращение. Оно не удаляется: при следующем нажатии «Поддержка» оператор увидит всю историю.</p>""",
),
}
HELP_SECTION_ORDER: tuple[str, ...] = tuple(HELP_SECTIONS)
+16 -2
View File
@@ -19,6 +19,8 @@ MAIN_MENU_VALUE = "__main_menu__"
TRAINING_RESET_BUTTON = "🗑 Сбросить данные"
TRAINING_RESET_VALUE = "__training_reset__"
POST_GENERATE_BUTTON = "✍️ Придумать пост"
SUPPORT_BUTTON = "🆘 Поддержка"
SUPPORT_VALUE = "__support__"
BUTTON_TEXT_MAP: dict[str, str] = {
**MODE_BUTTONS,
@@ -26,6 +28,7 @@ BUTTON_TEXT_MAP: dict[str, str] = {
"Главное меню": MAIN_MENU_VALUE,
TRAINING_RESET_BUTTON: TRAINING_RESET_VALUE,
POST_GENERATE_BUTTON: "/post",
SUPPORT_BUTTON: SUPPORT_VALUE,
}
@@ -41,13 +44,24 @@ def main_menu_keyboard() -> ReplyKeyboardMarkup:
)
def mode_keyboard(mode: str) -> ReplyKeyboardMarkup:
"""Внутри режима показывает релевантные действия и возврат в меню."""
def mode_keyboard(mode: str, support: bool = False) -> ReplyKeyboardMarkup:
"""Внутри режима показывает релевантные действия и возврат в меню.
В активном обращении поддержки остаётся только кнопка «Главное меню».
"""
rows = [[KeyboardButton(text=MAIN_MENU_BUTTON)]]
if support:
return ReplyKeyboardMarkup(
keyboard=rows,
resize_keyboard=True,
input_field_placeholder="Опиши проблему или вернись в главное меню",
)
if mode == "training":
rows.append([KeyboardButton(text=TRAINING_RESET_BUTTON)])
elif mode == "generation":
rows.insert(0, [KeyboardButton(text=POST_GENERATE_BUTTON)])
elif mode == "help":
rows.insert(0, [KeyboardButton(text=SUPPORT_BUTTON)])
return ReplyKeyboardMarkup(
keyboard=rows,
resize_keyboard=True,
+407
View File
@@ -0,0 +1,407 @@
"""Поддержка: личный чат пользователя ↔ персональный топик в группе операторов.
Пользователь нажимает «🆘 Поддержка» в режиме «Помощь». Дальше все его сообщения
копируются в его персональный топик группы; любое сообщение оператора в этом
топике копируется обратно пользователю. Сообщение, начинающееся с
``SUPPORT_NOTE_PREFIX`` (по умолчанию ``//``), остаётся заметкой внутри топика.
"""
from __future__ import annotations
import asyncio
import html
import logging
import os
from contextlib import closing
from typing import Any, Awaitable, Callable
from aiogram import BaseMiddleware, Bot, F, Router
from aiogram.types import Message, TelegramObject, User
from keyboards import MAIN_MENU_VALUE, SUPPORT_BUTTON, SUPPORT_VALUE, TRAINING_RESET_VALUE, resolve_button_text
from user_store import connect
logger = logging.getLogger(__name__)
router = Router(name="support")
NOTE_PREFIX_DEFAULT = "//"
TOPIC_TITLE_LIMIT = 128
SUPPORT_START_TEXT = (
"🆘 <b>Поддержка</b>\n\n"
"Что случилось? Опиши проблему прямо здесь — я передам сообщение оператору, "
"он ответит в этом чате.\n\n"
"Можно писать текстом, присылать фото, документы и голосовые. "
"Кнопка «Главное меню» завершает обращение: бот снова работает как обычно, "
"а при следующем нажатии «Поддержка» переписка продолжится в том же топике."
)
SUPPORT_UNAVAILABLE_TEXT = (
"🆘 Поддержка временно недоступна. Попробуй позже или вернись в главное меню."
)
SUPPORT_RELAY_FAILED_TEXT = (
"⚠️ Не удалось передать сообщение оператору. Попробуй отправить его ещё раз."
)
# --------------------------------------------------------------------------- #
# Конфигурация
# --------------------------------------------------------------------------- #
def support_group_id() -> int | None:
raw = os.getenv("SUPPORT_GROUP_ID", "").strip()
if not raw:
return None
try:
return int(raw)
except ValueError:
logger.warning("SUPPORT_GROUP_ID is not an integer; support disabled")
return None
def note_prefix() -> str:
raw = os.getenv("SUPPORT_NOTE_PREFIX", "").strip()
return raw or NOTE_PREFIX_DEFAULT
def topic_icon_emoji_id() -> str | None:
raw = os.getenv("SUPPORT_TOPIC_EMOJI_ID", "").strip()
return raw or None
def is_enabled() -> bool:
return support_group_id() is not None
def _is_support_chat(chat: Any) -> bool:
group_id = support_group_id()
return group_id is not None and int(chat.id) == group_id
# --------------------------------------------------------------------------- #
# Хранилище: user_id ↔ message_thread_id
# --------------------------------------------------------------------------- #
def init_support_table(db_path: str | None = None) -> None:
with closing(connect(db_path)) as connection, connection:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS support_topics (
telegram_user_id INTEGER PRIMARY KEY,
thread_id INTEGER NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""
)
def get_topic(user_id: int, db_path: str | None = None) -> int | None:
with closing(connect(db_path)) as connection:
row = connection.execute(
"SELECT thread_id FROM support_topics WHERE telegram_user_id = ?",
(int(user_id),),
).fetchone()
return int(row["thread_id"]) if row else None
def set_topic(user_id: int, thread_id: int, db_path: str | None = None) -> None:
with closing(connect(db_path)) as connection, connection:
connection.execute(
"""
INSERT INTO support_topics (telegram_user_id, thread_id, is_active, updated_at)
VALUES (?, ?, 1, CURRENT_TIMESTAMP)
ON CONFLICT(telegram_user_id) DO UPDATE SET
thread_id = excluded.thread_id,
is_active = 1,
updated_at = CURRENT_TIMESTAMP
""",
(int(user_id), int(thread_id)),
)
def forget_topic(user_id: int, db_path: str | None = None) -> None:
with closing(connect(db_path)) as connection, connection:
connection.execute(
"DELETE FROM support_topics WHERE telegram_user_id = ?",
(int(user_id),),
)
def set_active(user_id: int, active: bool, db_path: str | None = None) -> None:
with closing(connect(db_path)) as connection, connection:
connection.execute(
"""
UPDATE support_topics
SET is_active = ?, updated_at = CURRENT_TIMESTAMP
WHERE telegram_user_id = ?
""",
(1 if active else 0, int(user_id)),
)
def is_support_active(user_id: int, db_path: str | None = None) -> bool:
if not is_enabled():
return False
with closing(connect(db_path)) as connection:
row = connection.execute(
"SELECT is_active FROM support_topics WHERE telegram_user_id = ?",
(int(user_id),),
).fetchone()
return bool(row and row["is_active"])
def get_user_by_thread(thread_id: int, db_path: str | None = None) -> int | None:
with closing(connect(db_path)) as connection:
row = connection.execute(
"SELECT telegram_user_id FROM support_topics WHERE thread_id = ?",
(int(thread_id),),
).fetchone()
return int(row["telegram_user_id"]) if row else None
# --------------------------------------------------------------------------- #
# Топик и карточка пользователя
# --------------------------------------------------------------------------- #
def topic_title(user: User) -> str:
full_name = " ".join(
part for part in (user.first_name, user.last_name) if part
).strip() or "Пользователь"
if user.username:
title = f"{full_name} · @{user.username}"
else:
title = f"{full_name} · {user.id}"
return title[:TOPIC_TITLE_LIMIT]
def profile_card(user: User) -> str:
full_name = " ".join(
part for part in (user.first_name, user.last_name) if part
).strip() or "Пользователь"
lines = [
"🆘 <b>Новое обращение</b>",
f"<b>Имя:</b> {html.escape(full_name)}",
]
if user.username:
lines.append(f"<b>Username:</b> @{html.escape(user.username)}")
lines.append(f"<b>ID:</b> <code>{user.id}</code>")
lines.append("")
lines.append("Ответь в этом топике — сообщение придёт пользователю в личку.")
lines.append(f"Ответ с префиксом <code>{html.escape(note_prefix())}</code> останется здесь.")
return "\n".join(lines)
async def send_profile_card(bot: Bot, user: User, thread_id: int) -> None:
group_id = support_group_id()
if group_id is None:
return
card = profile_card(user)
photo_file_id = None
try:
photos = await bot.get_user_profile_photos(user_id=user.id, limit=1)
if photos.total_count and photos.photos and photos.photos[0]:
photo_file_id = photos.photos[0][-1].file_id
except Exception as exc:
logger.info("Support profile photo unavailable (%s)", type(exc).__name__)
try:
if photo_file_id:
await bot.send_photo(
chat_id=group_id,
photo=photo_file_id,
caption=card,
message_thread_id=thread_id,
parse_mode="HTML",
)
else:
await bot.send_message(
chat_id=group_id,
text=card,
message_thread_id=thread_id,
parse_mode="HTML",
)
except Exception as exc:
logger.warning("Support profile card send failed (%s)", type(exc).__name__)
async def ensure_topic(bot: Bot, user: User) -> int | None:
"""Возвращает thread_id персонального топика, создавая его при необходимости."""
group_id = support_group_id()
if group_id is None:
return None
existing = await asyncio.to_thread(get_topic, user.id)
if existing is not None:
return existing
emoji_id = topic_icon_emoji_id()
try:
kwargs: dict[str, Any] = {"chat_id": group_id, "name": topic_title(user)}
if emoji_id:
kwargs["icon_custom_emoji_id"] = emoji_id
topic = await bot.create_forum_topic(**kwargs)
except Exception as exc:
logger.error("Support topic creation failed (%s)", type(exc).__name__)
return None
thread_id = int(topic.message_thread_id)
await asyncio.to_thread(set_topic, user.id, thread_id)
await send_profile_card(bot, user, thread_id)
return thread_id
async def deactivate(user_id: int) -> None:
await asyncio.to_thread(set_active, user_id, False)
# --------------------------------------------------------------------------- #
# Пользователь → топик
# --------------------------------------------------------------------------- #
async def relay_user_message(message: Message) -> bool:
"""Копирует сообщение пользователя в его топик. True — сообщение ушло."""
bot = message.bot
user = message.from_user
group_id = support_group_id()
if group_id is None or bot is None or user is None:
return False
thread_id = await ensure_topic(bot, user)
if thread_id is None:
try:
await message.answer(SUPPORT_UNAVAILABLE_TEXT)
except Exception:
logger.debug("Support unavailable notice failed")
return False
try:
await bot.copy_message(
chat_id=group_id,
from_chat_id=message.chat.id,
message_id=message.message_id,
message_thread_id=thread_id,
)
return True
except Exception as exc:
if _is_missing_thread(exc):
logger.warning("Support topic %s is gone; recreating", thread_id)
await asyncio.to_thread(forget_topic, user.id)
return await relay_user_message(message)
logger.error("Support relay to topic failed (%s)", type(exc).__name__)
try:
await message.answer(SUPPORT_RELAY_FAILED_TEXT)
except Exception:
logger.debug("Support relay failure notice failed")
return False
def _is_missing_thread(exc: Exception) -> bool:
detail = str(exc).lower()
return "thread not found" in detail or "message thread not found" in detail
async def start_support(message: Message) -> None:
"""Открывает обращение: создаёт топик и объясняет пользователю формат."""
bot = message.bot
user = message.from_user
if bot is None or user is None:
return
if not is_enabled():
await message.answer(SUPPORT_UNAVAILABLE_TEXT)
return
thread_id = await ensure_topic(bot, user)
if thread_id is None:
await message.answer(SUPPORT_UNAVAILABLE_TEXT)
return
await asyncio.to_thread(set_active, user.id, True)
await message.answer(SUPPORT_START_TEXT, parse_mode="HTML")
# --------------------------------------------------------------------------- #
# Топик → пользователь
# --------------------------------------------------------------------------- #
def _is_service_message(message: Message) -> bool:
text = (message.text or message.caption or "").strip()
prefix = note_prefix()
return bool(prefix) and text.startswith(prefix)
@router.message(F.chat.func(_is_support_chat), F.is_topic_message.as_("topic"))
async def handle_support_group_message(message: Message) -> None:
"""Все сообщения из топиков группы поддержки, кроме служебных, уходят пользователю."""
group_id = support_group_id()
thread_id = message.message_thread_id
if group_id is None or thread_id is None:
return
sender = message.from_user
if sender is None or sender.is_bot:
return
if _is_service_message(message):
return
user_id = await asyncio.to_thread(get_user_by_thread, thread_id)
if user_id is None:
logger.info("Support topic %s has no known user", thread_id)
return
bot = message.bot
if bot is None:
return
try:
await bot.copy_message(
chat_id=user_id,
from_chat_id=message.chat.id,
message_id=message.message_id,
)
except Exception as exc:
logger.error("Support reply delivery failed user=%s (%s)", user_id, type(exc).__name__)
try:
await message.reply(
"⚠️ Не удалось доставить сообщение пользователю. "
"Возможно, он остановил бота."
)
except Exception:
logger.debug("Support delivery notice failed")
# --------------------------------------------------------------------------- #
# Middleware: пользователь → топик
# --------------------------------------------------------------------------- #
def _is_support_button(message: Message) -> bool:
if not message.text:
return False
return resolve_button_text((message.text or "").strip()) == SUPPORT_VALUE
class SupportRelayMiddleware(BaseMiddleware):
"""Копирует входящие_private сообщения активного обращения в топик."""
async def __call__(
self,
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: dict[str, Any],
) -> Any:
if not isinstance(event, Message) or not is_enabled():
return await handler(event, data)
user = event.from_user
if user is None or user.is_bot:
return await handler(event, data)
if event.chat.type != "private":
return await handler(event, data)
if _is_support_chat(event.chat):
return await handler(event, data)
if _is_support_button(event) or _is_main_menu_button(event):
return await handler(event, data)
try:
active = await asyncio.to_thread(is_support_active, user.id)
except Exception as exc:
logger.warning("Support active lookup failed (%s)", type(exc).__name__)
return await handler(event, data)
if not active:
return await handler(event, data)
await relay_user_message(event)
# Сообщение уже доставлено оператору; обычные обработчики не запускаем.
return None
def _is_main_menu_button(message: Message) -> bool:
if not message.text:
return False
return resolve_button_text((message.text or "").strip()) == MAIN_MENU_VALUE
+160
View File
@@ -0,0 +1,160 @@
import asyncio
import unittest
from typing import Any, cast
from unittest.mock import AsyncMock, patch
import activity_phrases
import bot
def make_message(chat_type: str = "private", chat_id: int = 55):
"""Минимальный Message с нужными OperationStatus полями."""
fake_bot = AsyncMock()
fake_bot.send_message_draft = AsyncMock(return_value=True)
message = cast(
Any,
type(
"Message",
(),
{
"chat": type("Chat", (), {"id": chat_id, "type": chat_type})(),
"bot": fake_bot,
"answer": AsyncMock(return_value=AsyncMock()),
},
)(),
)
return message
class PhrasePoolTests(unittest.TestCase):
def test_known_kinds_have_non_empty_pools(self):
for kind, pool in activity_phrases.ACTIVITY_PHRASES.items():
self.assertTrue(pool, kind)
for phrase in pool:
self.assertIsInstance(phrase, str)
self.assertTrue(phrase.strip())
def test_next_phrase_respects_kind(self):
for kind, pool in activity_phrases.ACTIVITY_PHRASES.items():
self.assertIn(activity_phrases.next_phrase(kind), pool, kind)
def test_unknown_kind_falls_back_to_defaults(self):
self.assertIn(activity_phrases.next_phrase("no-such-kind"), activity_phrases.DEFAULT_PHRASES)
def test_next_phrase_never_repeats_excluded(self):
pool = activity_phrases.ACTIVITY_PHRASES["generation"]
excluded = pool[0]
for _ in range(200):
self.assertNotEqual(activity_phrases.next_phrase("generation", excluded), excluded)
def test_random_interval_within_bounds(self):
low, high = activity_phrases.ACTIVITY_MIN_SECONDS, activity_phrases.ACTIVITY_MAX_SECONDS
self.assertLessEqual(low, high)
for _ in range(500):
value = activity_phrases.random_interval()
self.assertGreaterEqual(value, low)
self.assertLessEqual(value, high)
class DraftStatusTests(unittest.IsolatedAsyncioTestCase):
async def test_private_chat_streams_draft_and_never_posts_a_status_message(self):
message = make_message("private")
status = await bot.OperationStatus.start(message, "⏳ Старт", kind="generation")
await status.cancel()
message.answer.assert_not_awaited()
message.bot.send_message_draft.assert_awaited_once()
call = message.bot.send_message_draft.await_args
self.assertEqual(call.kwargs["chat_id"], 55)
self.assertEqual(call.kwargs["text"], "⏳ Старт")
self.assertNotEqual(call.kwargs["draft_id"], 0)
async def test_draft_id_is_stable_across_updates(self):
message = make_message("private")
status = await bot.OperationStatus.start(message, "⏳ Старт", kind="generation")
await asyncio.sleep(0)
await status.update("⏳ Работаю")
await status.cancel()
ids = {c.kwargs["draft_id"] for c in message.bot.send_message_draft.await_args_list}
self.assertEqual(len(ids), 1)
self.assertEqual(
[c.kwargs["text"] for c in message.bot.send_message_draft.await_args_list][:2],
["⏳ Старт", "⏳ Работаю"],
)
async def test_finish_posts_real_message_and_stops_rotation(self):
message = make_message("private")
status = await bot.OperationStatus.start(message, "⏳ Старт", kind="generation")
await status.finish("✅ Готово")
message.answer.assert_awaited_once_with("✅ Готово")
self.assertIsNone(status._rotation_task)
# после finish ротация не должна больше дёргать драфт
calls_before = message.bot.send_message_draft.await_count
with patch.object(bot, "random_interval", return_value=0.01):
await asyncio.sleep(0.05)
self.assertEqual(message.bot.send_message_draft.await_count, calls_before)
async def test_rotation_emits_activity_phrases(self):
message = make_message("private")
with patch.object(bot, "random_interval", return_value=0.01):
status = await bot.OperationStatus.start(message, "⏳ Старт", kind="training")
await asyncio.sleep(0.09)
await status.cancel()
texts = [c.kwargs["text"] for c in message.bot.send_message_draft.await_args_list]
self.assertGreater(len(texts), 1)
self.assertTrue(
any(text in activity_phrases.TRAINING_PHRASES for text in texts[1:]),
texts,
)
async def test_no_kind_means_no_rotation(self):
message = make_message("private")
status = await bot.OperationStatus.start(message, "⏳ Старт")
with patch.object(bot, "random_interval", return_value=0.01):
await asyncio.sleep(0.05)
self.assertEqual(message.bot.send_message_draft.await_count, 1)
async def test_retarget_switches_pool(self):
message = make_message("private")
with patch.object(bot, "random_interval", return_value=0.01):
status = await bot.OperationStatus.start(message, "⏳ Старт", kind="generation")
status.retarget("images")
await asyncio.sleep(0.09)
await status.cancel()
texts = [c.kwargs["text"] for c in message.bot.send_message_draft.await_args_list][1:]
self.assertTrue(any(text in activity_phrases.IMAGES_PHRASES for text in texts), texts)
class GroupStatusTests(unittest.IsolatedAsyncioTestCase):
async def test_group_chat_uses_legacy_edit_path(self):
for chat_type in ("group", "supergroup", "channel"):
message = make_message(chat_type)
status = await bot.OperationStatus.start(message, "⏳ Старт", kind="generation")
await asyncio.sleep(0)
await status.update("⏳ Работаю")
await status.finish("✅ Готово")
message.bot.send_message_draft.assert_not_awaited()
message.answer.assert_awaited_once_with("⏳ Старт")
self.assertEqual(
[c.args[0] for c in status.status_message.edit_text.await_args_list],
["⏳ Работаю", "✅ Готово"],
)
async def test_no_rotation_task_in_groups(self):
message = make_message("group")
status = await bot.OperationStatus.start(message, "⏳ Старт", kind="generation")
self.assertIsNone(status._rotation_task)
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"))
status = await bot.OperationStatus.start(message, "⏳ Старт", kind="generation")
await status.cancel()
message.answer.assert_awaited_once_with("⏳ Старт")
self.assertIsNone(status.draft_id)
+21
View File
@@ -72,8 +72,29 @@ class KeyboardLayoutTests(unittest.TestCase):
expected.append(keyboards.TRAINING_RESET_BUTTON)
elif mode == "generation":
expected.insert(0, keyboards.POST_GENERATE_BUTTON)
elif mode == "help":
expected.insert(0, keyboards.SUPPORT_BUTTON)
self.assertEqual(labels, expected, mode)
def test_support_keyboard_keeps_only_main_menu(self):
for mode in bot.MODE_STATES:
labels = [
b.text
for row in keyboards.mode_keyboard(mode, support=True).keyboard
for b in row
]
self.assertEqual(labels, [keyboards.MAIN_MENU_BUTTON], mode)
def test_help_keyboard_offers_support(self):
labels = [b.text for row in keyboards.mode_keyboard("help").keyboard for b in row]
self.assertIn(keyboards.SUPPORT_BUTTON, labels)
def test_support_button_maps_to_internal_value(self):
self.assertEqual(
keyboards.resolve_button_text(keyboards.SUPPORT_BUTTON),
keyboards.SUPPORT_VALUE,
)
def test_mode_entry_copy_uses_short_paragraphs(self):
self.assertIn("\n", bot.MODE_COPY["training"][1])
self.assertIn("\n", bot.MODE_COPY["generation"][1])