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:
hermes committed 2026-10-11 19:59:34 +02:00
1 parent 3dd2924d5e
commit 6938c1d0f3
5 files changed
+109 -10

No files matched your search

+4 -1
View File
@@ -70,7 +70,7 @@ Telegram-бот, который по запросу пользователя г
- После деплоя файл приложения `/app/data/bot.log` и его ротационные копии хранятся на persistent volume выше и переживают пересоздание контейнера. Пока logging-изменение не задеплоено, используй `docker logs` и API-журнал `api_call_logs` в SQLite. API-логи сохраняются в `/app/data/social_writer.sqlite3` на том же volume.
- Dev-only raw update log: отдельная SQLite `/app/data/bot_updates.sqlite3`, таблица `raw_updates`; поиск по Telegram `update_id` (`SELECT payload FROM raw_updates WHERE update_id = ?`). Middleware подключается только при `DEPLOY_ENV=development`; записи старше 30 дней удаляются при запуске и записи новых событий. Секретные поля/токены маскируются. JSON включает приватный текст и metadata апдейта; production его не пишет.
- Секреты хранятся в переменных окружения приложения Coolify; не сохраняются в репозитории.
- Поддержка: `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` снимается при входе в **любой**
+8 -3
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
0.1.0+56
0.1.0+57
+39 -5
View File
@@ -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:
+57
View File
@@ -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 — возврат в лобби, поэтому обращение поддержки должно закрываться.