Files
social-post-writer-bot/activity_phrases.py
T
hermes 21db563f28 feat: поддержка в боте — топик, сводка профиля, приветствие оператора
Обращение открывается кнопкой «🆘 Поддержка» или /support в режиме «Помощь».
Отдельного FSM-режима нет: пока обращение активно, внизу остаётся только
«Главное меню», а переключение режимов отклоняется.

- support.py: личка пользователя ↔ персональный топик группы SUPPORT_GROUP_ID.
  Топик создаётся один раз на пользователя и переиспользуется, поэтому
  повторные обращения продолжают ту же переписку. Пересылаются все типы
  контента через copy_message; ответ оператора из топика уходит в личку;
  сообщение с префиксом // остаётся внутренней заметкой. Функция выключена
  без SUPPORT_GROUP_ID.
- Карточка профиля уходит первым сообщением топика: аватарка, имя, username,
  id и сводка — сколько дней пользуется ботом, сколько продлений подписки,
  сколько дней осталось, обучен ли бот (user_store.support_profile_summary).
- Бот не объясняет пользователю, что «передаёт оператору»: в ответ приходит
  случайная фраза из пула SUPPORT_GREETINGS (activity_phrases.support_greeting).
  Формулировки безличные, без «я/бот/оператор»; пул переопределяется через
  SUPPORT_GREETINGS без деплоя.

Проверено: 207 тестов OK (30 новых в tests/test_support.py).
VERSION: 0.1.0+51 -> 0.1.0+52
2026-10-11 16:48:04 +02:00

157 lines
8.5 KiB
Python

"""Пулы фраз, которыми бот обозначает ход долгой операции.
Фразы показываются в живом драфте (``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, ...] = (
"⏳ Работаю над запросом…",
"⏳ Привожу результат к нужному виду…",
"⏳ Почти готова, проверяю детали…",
)
# Фразы, с которых живой оператор начинает диалог в поддержке. Бот не должен
# рассказывать пользователю про «передачу оператору» — он просто здоровается
# и приглашает описать проблему: для пользователя это начало разговора с человеком.
# Формулировки безличные или без глаголов в первом лице, чтобы подходить
# любому оператору и не спорить с голосом бота.
SUPPORT_GREETINGS: tuple[str, ...] = (
"Здравствуйте! На связи поддержка. Расскажите, что случилось — разберёмся.",
"Добрый день! Слушаю вас: опишите проблему, и постараемся помочь.",
"Приветствую! Чем могу помочь? Опишите ситуацию подробнее.",
"Здравствуйте! На связи. Что пошло не так — напишите, посмотрим.",
"Добрый день! На связи поддержка. Опишите, с чем возникла сложность.",
"Здравствуйте! Слушаю. Расскажите, что случилось, — постараемся решить.",
"Приветствую! Готовы помочь. Опишите проблему, и разберёмся вместе.",
"Добрый день! На связи. Напишите, что не работает, — посмотрим.",
"Здравствуйте! Поддержка на связи. Опишите вопрос — поможем разобраться.",
"Приветствую! На связи. С чего начнём — опишите, что случилось.",
)
SUPPORT_GREETING_FALLBACK = "Здравствуйте! На связи поддержка. Расскажите, что случилось."
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)
def support_greeting() -> str:
"""Случайная фраза начала диалога поддержки.
Пул задаётся переменной окружения SUPPORT_GREETINGS (разделитель — `|`),
чтобы оператор мог поменять формулировки без деплоя. Пустые строки
отбрасываются; если пул пуст, возвращается SUPPORT_GREETING_FALLBACK.
"""
raw = os.getenv("SUPPORT_GREETINGS", "").strip()
if raw:
pool = tuple(item.strip() for item in raw.split("|") if item.strip())
if pool:
return random.choice(pool)
return random.choice(SUPPORT_GREETINGS) if SUPPORT_GREETINGS else SUPPORT_GREETING_FALLBACK