diff --git a/Dockerfile b/Dockerfile index b2c9136..c0ef563 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/IDEA.MD b/IDEA.MD index eaa4c5a..301a097 100644 --- a/IDEA.MD +++ b/IDEA.MD @@ -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 diff --git a/README.md b/README.md index 53a9583..46f2d2b 100644 --- a/README.md +++ b/README.md @@ -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:
` → `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"]`. --- diff --git a/VERSION b/VERSION index c0e6c69..500f969 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+48 +0.1.0+49 diff --git a/activity_phrases.py b/activity_phrases.py new file mode 100644 index 0000000..e6448f4 --- /dev/null +++ b/activity_phrases.py @@ -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) diff --git a/bot.py b/bot.py index 3ba95db..71857f7 100644 --- a/bot.py +++ b/bot.py @@ -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) diff --git a/help_message.py b/help_message.py index 7e9bcd2..712fc3a 100644 --- a/help_message.py +++ b/help_message.py @@ -55,6 +55,12 @@ HELP_SECTIONS: dict[str, tuple[str, str]] = {

Действия доступны только в своём режиме: /style и /clear — обучение; /post и /idea — генерация; /balance и /subscribe — подписка. /clear и кнопка «Сбросить данные» сначала запрашивают подтверждение; подписка и триал не меняются.

В режиме «Генерация» можно попросить идею обычными словами: «предложи идею для поста», «о чём написать?», «накидай тему».

""", ), + "support": ( + "🆘 Поддержка", + """

В режиме «Помощь» нажми «🆘 Поддержка» или отправь /support. Напиши текстом, чем помочь, — можно приложить фото, документ или голосовое.

+

Сообщение уходит оператору, он отвечает прямо в этом чате. Переписка продолжается в том же обращении: можно отвлечься на другие режимы, вернуться и дописать.

+

Кнопка «Главное меню» завершает обращение. Оно не удаляется: при следующем нажатии «Поддержка» оператор увидит всю историю.

""", + ), } HELP_SECTION_ORDER: tuple[str, ...] = tuple(HELP_SECTIONS) diff --git a/keyboards.py b/keyboards.py index c401c89..a5fcee1 100644 --- a/keyboards.py +++ b/keyboards.py @@ -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, diff --git a/support.py b/support.py new file mode 100644 index 0000000..d2320be --- /dev/null +++ b/support.py @@ -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 = ( + "🆘 Поддержка\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 = [ + "🆘 Новое обращение", + f"Имя: {html.escape(full_name)}", + ] + if user.username: + lines.append(f"Username: @{html.escape(user.username)}") + lines.append(f"ID: {user.id}") + lines.append("") + lines.append("Ответь в этом топике — сообщение придёт пользователю в личку.") + lines.append(f"Ответ с префиксом {html.escape(note_prefix())} останется здесь.") + 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 diff --git a/tests/test_activity_phrases.py b/tests/test_activity_phrases.py new file mode 100644 index 0000000..8c9b3f8 --- /dev/null +++ b/tests/test_activity_phrases.py @@ -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) diff --git a/tests/test_keyboards.py b/tests/test_keyboards.py index 2553d0d..7e89190 100644 --- a/tests/test_keyboards.py +++ b/tests/test_keyboards.py @@ -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])