feat: подпись «Оператор поддержки» на ответе оператора
Ответ из топика приходил пользователю без подписи, и было непонятно, кто пишет — бот или человек. - Текст уходит одним сообщением: «Оператор поддержки:\n<текст>». - У медиа copy_message не позволяет подменить caption, поэтому подпись отправляется отдельным сообщением перед копией; caption медиа, если был, попадает в то же сообщение. - Подпись задаётся SUPPORT_OPERATOR_SIGNATURE (default «Оператор поддержки»). - Заметки с префиксом // по-прежнему не уходят пользователю. Тесты: 236 OK (6 новых — текст, медиа с caption, медиа без caption, переопределение подписи через env). VERSION: 0.1.0+56 -> 0.1.0+57
This commit is contained in:
1 parent
3dd2924d5e
commit
6938c1d0f3
5 files changed
+109
-10
No files matched your search
@@ -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; не сохраняются в репозитории.
|
||||
- Поддержка: `SUPPORT_GROUP_ID` — id группы операторов (`-1008000863273`), `SUPPORT_NOTE_PREFIX` (по умолчанию `//`), `SUPPORT_GREETINGS` (пул приветствий, разделитель `|`), `SUPPORT_TOPIC_EMOJI_ID` (опционально). Без `SUPPORT_GROUP_ID` поддержка выключена. Подробнее — раздел «Поддержка».
|
||||
- Поддержка: `SUPPORT_GROUP_ID` — id группы операторов (`-1008000863273`), `SUPPORT_NOTE_PREFIX` (по умолчанию `//`), `SUPPORT_GREETINGS` (пул приветствий, разделитель `|`), `SUPPORT_OPERATOR_SIGNATURE` (по умолчанию `Оператор поддержки`), `SUPPORT_TOPIC_EMOJI_ID` (опционально). Без `SUPPORT_GROUP_ID` поддержка выключена. Подробнее — раздел «Поддержка».
|
||||
- `DEPLOY_ENV` включает dev-поведение только при точном значении `development`; production, пустое и неизвестное значение отключают его. `BOT_ENV` отдельно выбирает Telegram Bot API. `DEV_CHAT_ID` используется для уведомления о версии; версия в файле `VERSION` (сейчас `0.1.0+51`). Ротационный файл лога — `BOT_LOG_PATH` (по умолчанию `/app/data/bot.log`) на том же volume; сырые апдейты — `RAW_UPDATES_DB_PATH` (по умолчанию `/app/data/bot_updates.sqlite3`).
|
||||
- Вики Gitea: страницы `Home`, `User-Guide`, `Developer-Guide`, `Subscription`, `Deployment`.
|
||||
|
||||
@@ -148,6 +148,9 @@ Telegram-бот, который по запросу пользователя г
|
||||
(`activity_phrases.support_greeting()`): «Здравствуйте! На связи поддержка. Расскажите,
|
||||
что случилось — разберёмся.» Формулировки безличные, без «я / бот / оператор».
|
||||
Пул переопределяется переменной `SUPPORT_GREETINGS` (разделитель `|`) без деплоя.
|
||||
- **Подпись ответа**: пользователю сообщение приходит как «Оператор поддержки:\n<текст>».
|
||||
Переменная `SUPPORT_OPERATOR_SIGNATURE` (default «Оператор поддержки»). У медиа caption не
|
||||
подменяется, поэтому подпись уходит отдельным сообщением перед копией медиа.
|
||||
- **Заметки оператора**: сообщение с префиксом `//` (`SUPPORT_NOTE_PREFIX`) остаётся в топике и
|
||||
пользователю не отправляется.
|
||||
- **Завершение**: уход из контекста поддержки. `is_active` снимается при входе в **любой**
|
||||
|
||||
@@ -101,8 +101,12 @@ onboarding. В «Подписке» сразу отображаются тип
|
||||
«Здравствуйте! На связи поддержка. Расскажите, что случилось — разберёмся.»
|
||||
Формулировки безличные и без «я/бот/оператор», чтобы подходить любому оператору.
|
||||
Пул можно переопределить без деплоя: `SUPPORT_GREETINGS` в Coolify, разделитель `|`.
|
||||
- Ответ оператора — любое сообщение в топике от живого участника. Сообщение с префиксом `//`
|
||||
(`SUPPORT_NOTE_PREFIX`) остаётся заметкой внутри топика и пользователю не уходит.
|
||||
- Ответ оператора — любое сообщение в топике от живого участника. Пользователю оно приходит
|
||||
**с подписью**: «Оператор поддержки:\n<текст>». Подпись задаётся `SUPPORT_OPERATOR_SIGNATURE`
|
||||
(по умолчанию «Оператор поддержки»). У медиа `copy_message` не позволяет подменить caption,
|
||||
поэтому подпись уходит отдельным сообщением перед самим медиа.
|
||||
Сообщение с префиксом `//` (`SUPPORT_NOTE_PREFIX`) остаётся заметкой внутри топика
|
||||
и пользователю не уходит.
|
||||
- **Завершение — уход из контекста поддержки.** Флаг `is_active` снимается при входе в
|
||||
**любой** режим (`/training`, `/generate`, `/subscription`, `/help`), а также по
|
||||
«🏠 Главное меню» и `/start` — общая точка `_close_support_case()`. Пока флаг стоит,
|
||||
@@ -119,7 +123,8 @@ onboarding. В «Подписке» сразу отображаются тип
|
||||
|
||||
**Переменные Coolify:** `SUPPORT_GROUP_ID` (обязательно, без неё функция выключена),
|
||||
`SUPPORT_NOTE_PREFIX` (default `//`), `SUPPORT_TOPIC_EMOJI_ID` (optional custom emoji id),
|
||||
`SUPPORT_GREETINGS` (optional; пул приветствий оператора, разделитель `|`).
|
||||
`SUPPORT_GREETINGS` (optional; пул приветствий оператора, разделитель `|`),
|
||||
`SUPPORT_OPERATOR_SIGNATURE` (optional; подпись ответа оператора, default `Оператор поддержки`).
|
||||
|
||||
> Группа должна быть **супергруппой с включёнными темами** (`is_forum`), бот — её **администратором**
|
||||
> с правом «Управление темами» (`create_forum_topic`). Обычного участника недостаточно: Telegram
|
||||
|
||||
+39
-5
@@ -64,6 +64,19 @@ def note_prefix() -> str:
|
||||
return raw or NOTE_PREFIX_DEFAULT
|
||||
|
||||
|
||||
DEFAULT_OPERATOR_SIGNATURE = "Оператор поддержки"
|
||||
|
||||
|
||||
def operator_signature() -> str:
|
||||
"""Подпись, с которой ответ оператора приходит пользователю в личку.
|
||||
|
||||
Задаётся переменной `SUPPORT_OPERATOR_SIGNATURE`; по умолчанию —
|
||||
«Оператор поддержки». Формат сообщения: «<подпись>:\n<текст>».
|
||||
"""
|
||||
raw = os.getenv("SUPPORT_OPERATOR_SIGNATURE", "").strip()
|
||||
return raw or DEFAULT_OPERATOR_SIGNATURE
|
||||
|
||||
|
||||
def topic_icon_emoji_id() -> str | None:
|
||||
raw = os.getenv("SUPPORT_TOPIC_EMOJI_ID", "").strip()
|
||||
return raw or None
|
||||
@@ -383,6 +396,31 @@ async def start_support(message: Message) -> None:
|
||||
# Топик → пользователь
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
async def _deliver_operator_reply(bot: Bot, message: Message, user_id: int) -> None:
|
||||
"""Доставляет ответ оператора пользователю, добавляя подпись.
|
||||
|
||||
Текст уходит одним сообщением с подписью в начале. Медиа копируется
|
||||
`copy_message` (caption у копии задать нельзя), поэтому подпись отправляется
|
||||
отдельным сообщением перед ним.
|
||||
"""
|
||||
signature = operator_signature()
|
||||
text = (message.text or "").strip()
|
||||
if text:
|
||||
await bot.send_message(chat_id=user_id, text=f"{signature}:\n{text}")
|
||||
return
|
||||
caption = (message.caption or "").strip()
|
||||
if caption:
|
||||
# У медиа есть подпись — пересылаем её текстом, само медиа копируем ниже.
|
||||
await bot.send_message(chat_id=user_id, text=f"{signature}:\n{caption}")
|
||||
else:
|
||||
await bot.send_message(chat_id=user_id, text=f"{signature}:")
|
||||
await bot.copy_message(
|
||||
chat_id=user_id,
|
||||
from_chat_id=message.chat.id,
|
||||
message_id=message.message_id,
|
||||
)
|
||||
|
||||
|
||||
def _is_service_message(message: Message) -> bool:
|
||||
text = (message.text or message.caption or "").strip()
|
||||
prefix = note_prefix()
|
||||
@@ -413,11 +451,7 @@ async def handle_support_group_message(message: Message) -> None:
|
||||
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,
|
||||
)
|
||||
await _deliver_operator_reply(bot, message, user_id)
|
||||
except Exception as exc:
|
||||
logger.error("Support reply delivery failed user=%s (%s)", user_id, type(exc).__name__)
|
||||
try:
|
||||
|
||||
@@ -345,6 +345,63 @@ class SupportStartTextTests(unittest.TestCase):
|
||||
self.assertNotIn("оператор", rendered.lower())
|
||||
|
||||
|
||||
class OperatorSignatureTests(unittest.IsolatedAsyncioTestCase):
|
||||
"""Ответ оператора приходит пользователю с подписью «Оператор поддержки:»."""
|
||||
|
||||
def test_default_signature(self):
|
||||
with patch.dict("os.environ", {"SUPPORT_OPERATOR_SIGNATURE": ""}, clear=False):
|
||||
self.assertEqual(support.operator_signature(), "Оператор поддержки")
|
||||
|
||||
def test_signature_from_env(self):
|
||||
with patch.dict("os.environ", {"SUPPORT_OPERATOR_SIGNATURE": "Саша"}, clear=False):
|
||||
self.assertEqual(support.operator_signature(), "Саша")
|
||||
|
||||
async def test_text_reply_gets_signature_prefix(self):
|
||||
bot = AsyncMock()
|
||||
message = SimpleNamespace(text="Проверяю платёж", caption=None,
|
||||
chat=SimpleNamespace(id=-100123))
|
||||
await support._deliver_operator_reply(bot, message, 42)
|
||||
|
||||
bot.send_message.assert_awaited_once()
|
||||
kwargs = bot.send_message.await_args.kwargs
|
||||
self.assertEqual(kwargs["chat_id"], 42)
|
||||
self.assertEqual(kwargs["text"], "Оператор поддержки:\nПроверяю платёж")
|
||||
bot.copy_message.assert_not_awaited()
|
||||
|
||||
async def test_media_with_caption_sends_signature_then_copies(self):
|
||||
bot = AsyncMock()
|
||||
message = SimpleNamespace(text=None, caption="Скриншот платежа",
|
||||
chat=SimpleNamespace(id=-100123), message_id=77)
|
||||
await support._deliver_operator_reply(bot, message, 42)
|
||||
|
||||
self.assertEqual(
|
||||
bot.send_message.await_args.kwargs["text"],
|
||||
"Оператор поддержки:\nСкриншот платежа",
|
||||
)
|
||||
bot.copy_message.assert_awaited_once_with(
|
||||
chat_id=42, from_chat_id=-100123, message_id=77
|
||||
)
|
||||
|
||||
async def test_media_without_caption_sends_bare_signature_then_copies(self):
|
||||
bot = AsyncMock()
|
||||
message = SimpleNamespace(text=None, caption=None,
|
||||
chat=SimpleNamespace(id=-100123), message_id=78)
|
||||
await support._deliver_operator_reply(bot, message, 42)
|
||||
|
||||
self.assertEqual(bot.send_message.await_args.kwargs["text"], "Оператор поддержки:")
|
||||
bot.copy_message.assert_awaited_once_with(
|
||||
chat_id=42, from_chat_id=-100123, message_id=78
|
||||
)
|
||||
|
||||
async def test_signature_env_override_is_used(self):
|
||||
bot = AsyncMock()
|
||||
message = SimpleNamespace(text="Готово", caption=None,
|
||||
chat=SimpleNamespace(id=-100123))
|
||||
with patch.dict("os.environ", {"SUPPORT_OPERATOR_SIGNATURE": "Саша"}, clear=False):
|
||||
await support._deliver_operator_reply(bot, message, 42)
|
||||
self.assertEqual(bot.send_message.await_args.kwargs["text"], "Саша:\nГотово")
|
||||
|
||||
|
||||
class StartClosesSupportCaseTests(_TempDbCase):
|
||||
"""/start — возврат в лобби, поэтому обращение поддержки должно закрываться.
|
||||
|
||||
|
||||
Reference in new issue
Block a user