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:
1 parent
a95ae7ae5b
commit
87e421ae73
11 files changed
+936
-24
No files matched your search
+1
-1
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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"]`.
|
||||
|
||||
---
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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)
|
||||
@@ -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])
|
||||
|
||||
Reference in new issue
Block a user