diff --git a/IDEA.MD b/IDEA.MD index 1e6c373..bbaa5d8 100644 --- a/IDEA.MD +++ b/IDEA.MD @@ -148,6 +148,8 @@ Telegram-бот, который по запросу пользователя г (`activity_phrases.support_greeting()`): «Здравствуйте! На связи поддержка. Расскажите, что случилось — разберёмся.» Формулировки безличные, без «я / бот / оператор». Пул переопределяется переменной `SUPPORT_GREETINGS` (разделитель `|`) без деплоя. +- **Подпись пользователя**: в топик сообщение уходит как «<Имя>:\n<текст>»; если имени нет — + «:\n<текст>». Caption медиа уходит текстом с подписью, само медиа — как есть. - **Подпись ответа**: пользователю сообщение приходит как «Оператор поддержки:\n<текст>». Переменная `SUPPORT_OPERATOR_SIGNATURE` (default «Оператор поддержки»). У медиа caption не подменяется, поэтому подпись уходит отдельным сообщением перед копией медиа. diff --git a/README.md b/README.md index 528681c..29082b1 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,9 @@ onboarding. В «Подписке» сразу отображаются тип Порядок включения роутеров критичен — у aiogram побеждает первый подходящий. Планируемые роли: **работник** (личка), **помощник поддержки** (группа), **аналитик** (сбор аналитики), **почтальон** (рассылки). +- **Сообщение пользователя в топике подписывается**: «<Имя>:\n<текст>», а если имени нет — + «:\n<текст>» (`user_signature()`). Caption медиа тоже уходит текстом с подписью, + само медиа копируется как есть. - Первое сообщение — приветствие оператора. Бот не объясняет, что «передаёт» сообщение: это разрушает ощущение живого диалога. Пользователь видит случайную фразу из пула `SUPPORT_GREETINGS` (`activity_phrases.support_greeting()`), например diff --git a/VERSION b/VERSION index 66c3fe7..0e2775f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+57 \ No newline at end of file +0.1.0+58 \ No newline at end of file diff --git a/support.py b/support.py index 9903f16..a33be1c 100644 --- a/support.py +++ b/support.py @@ -331,6 +331,45 @@ def _unavailable_text(reason: str | None = None) -> str: ) +def user_signature(user: User) -> str: + """Подпись сообщения пользователя в топике: имя или id, если имени нет.""" + full_name = " ".join( + part for part in (user.first_name, user.last_name) if part + ).strip() + return full_name or str(user.id) + + +async def _send_user_message_to_topic( + bot: Bot, message: Message, group_id: int, thread_id: int, user: User +) -> None: + """Отправляет сообщение пользователя в топик, добавляя подпись к тексту. + + Текст уходит одним сообщением «<Имя>:\n<текст>». Всё остальное (фото, + документы, голосовые) копируется как есть, без подписи. + """ + text = (message.text or "").strip() + if text: + await bot.send_message( + chat_id=group_id, + text=f"{user_signature(user)}:\n{text}", + message_thread_id=thread_id, + ) + return + caption = (message.caption or "").strip() + if caption: + await bot.send_message( + chat_id=group_id, + text=f"{user_signature(user)}:\n{caption}", + message_thread_id=thread_id, + ) + await bot.copy_message( + chat_id=group_id, + from_chat_id=message.chat.id, + message_id=message.message_id, + message_thread_id=thread_id, + ) + + async def relay_user_message(message: Message) -> bool: """Копирует сообщение пользователя в его топик. True — сообщение ушло.""" bot = message.bot @@ -346,12 +385,7 @@ async def relay_user_message(message: Message) -> bool: 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, - ) + await _send_user_message_to_topic(bot, message, group_id, thread_id, user) return True except Exception as exc: if _is_missing_thread(exc): diff --git a/tests/test_support.py b/tests/test_support.py index 04d17f4..4ff5eb1 100644 --- a/tests/test_support.py +++ b/tests/test_support.py @@ -345,6 +345,90 @@ class SupportStartTextTests(unittest.TestCase): self.assertNotIn("оператор", rendered.lower()) +class UserSignatureTests(unittest.IsolatedAsyncioTestCase): + """Сообщение пользователя в топике подписывается его именем или id.""" + + def _user(self, first_name="Иван", last_name=None, user_id=42): + return SimpleNamespace( + id=user_id, first_name=first_name, last_name=last_name, is_bot=False + ) + + def test_signature_uses_first_and_last_name(self): + self.assertEqual( + support.user_signature(self._user("Иван", "Петров")), "Иван Петров" + ) + + def test_signature_uses_first_name_only(self): + self.assertEqual(support.user_signature(self._user("Иван")), "Иван") + + def test_signature_falls_back_to_id(self): + self.assertEqual(support.user_signature(self._user(None, None, 42)), "42") + + def test_signature_ignores_blank_name(self): + self.assertEqual(support.user_signature(self._user(" ", None, 42)), "42") + + +class UserMessageToTopicTests(unittest.IsolatedAsyncioTestCase): + """Сообщение пользователя уходит в топик с подписью; медиа — как есть.""" + + def _user(self, first_name="Иван", last_name=None, user_id=42): + return SimpleNamespace( + id=user_id, first_name=first_name, last_name=last_name, is_bot=False + ) + + async def test_text_is_sent_with_signature(self): + bot = AsyncMock() + message = SimpleNamespace( + text="не работает оплата", caption=None, + chat=SimpleNamespace(id=555), message_id=10, + ) + await support._send_user_message_to_topic(bot, message, -100123, 14, self._user()) + + bot.send_message.assert_awaited_once() + kwargs = bot.send_message.await_args.kwargs + self.assertEqual(kwargs["chat_id"], -100123) + self.assertEqual(kwargs["message_thread_id"], 14) + self.assertEqual(kwargs["text"], "Иван:\nне работает оплата") + bot.copy_message.assert_not_awaited() + + async def test_media_without_text_is_copied_as_is(self): + bot = AsyncMock() + message = SimpleNamespace( + text=None, caption=None, + chat=SimpleNamespace(id=555), message_id=11, + ) + await support._send_user_message_to_topic(bot, message, -100123, 14, self._user()) + + bot.send_message.assert_not_awaited() + bot.copy_message.assert_awaited_once_with( + chat_id=-100123, from_chat_id=555, message_id=11, message_thread_id=14 + ) + + async def test_caption_is_sent_as_text_then_media_copied(self): + bot = AsyncMock() + message = SimpleNamespace( + text=None, caption="вот скриншот", + chat=SimpleNamespace(id=555), message_id=12, + ) + await support._send_user_message_to_topic(bot, message, -100123, 14, self._user()) + + self.assertEqual(bot.send_message.await_args.kwargs["text"], "Иван:\nвот скриншот") + bot.copy_message.assert_awaited_once_with( + chat_id=-100123, from_chat_id=555, message_id=12, message_thread_id=14 + ) + + async def test_signature_falls_back_to_user_id(self): + bot = AsyncMock() + message = SimpleNamespace( + text="привет", caption=None, + chat=SimpleNamespace(id=555), message_id=13, + ) + await support._send_user_message_to_topic( + bot, message, -100123, 14, self._user(None, None, 99) + ) + self.assertEqual(bot.send_message.await_args.kwargs["text"], "99:\nпривет") + + class OperatorSignatureTests(unittest.IsolatedAsyncioTestCase): """Ответ оператора приходит пользователю с подписью «Оператор поддержки:»."""