diff --git a/IDEA.MD b/IDEA.MD index b0cd0db..1e6c373 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; не сохраняются в репозитории. -- Поддержка: `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` снимается при входе в **любой** diff --git a/README.md b/README.md index 9ae701c..528681c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/VERSION b/VERSION index 85b1ce8..66c3fe7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+56 \ No newline at end of file +0.1.0+57 \ No newline at end of file diff --git a/support.py b/support.py index 7771460..9903f16 100644 --- a/support.py +++ b/support.py @@ -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: diff --git a/tests/test_support.py b/tests/test_support.py index bb028c1..04d17f4 100644 --- a/tests/test_support.py +++ b/tests/test_support.py @@ -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 — возврат в лобби, поэтому обращение поддержки должно закрываться.