diff --git a/IDEA.MD b/IDEA.MD index bbaa5d8..7e24319 100644 --- a/IDEA.MD +++ b/IDEA.MD @@ -153,6 +153,9 @@ Telegram-бот, который по запросу пользователя г - **Подпись ответа**: пользователю сообщение приходит как «Оператор поддержки:\n<текст>». Переменная `SUPPORT_OPERATOR_SIGNATURE` (default «Оператор поддержки»). У медиа caption не подменяется, поэтому подпись уходит отдельным сообщением перед копией медиа. + Если обращение закрыто, добавляется инлайн-кнопка «↩️ Ответить» (`support:reply:`): + она ставит `is_active = 1`, убирает кнопку с сообщения и показывает клавиатуру поддержки. + Callback проверяет, что нажал тот же пользователь. - **Заметки оператора**: сообщение с префиксом `//` (`SUPPORT_NOTE_PREFIX`) остаётся в топике и пользователю не отправляется. - **Завершение**: уход из контекста поддержки. `is_active` снимается при входе в **любой** diff --git a/README.md b/README.md index 29082b1..594bfae 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,9 @@ onboarding. В «Подписке» сразу отображаются тип **с подписью**: «Оператор поддержки:\n<текст>». Подпись задаётся `SUPPORT_OPERATOR_SIGNATURE` (по умолчанию «Оператор поддержки»). У медиа `copy_message` не позволяет подменить caption, поэтому подпись уходит отдельным сообщением перед самим медиа. + Если обращение закрыто, к сообщению добавляется инлайн-кнопка **«↩️ Ответить»**: она снова + открывает обращение (`is_active = 1`) и показывает клавиатуру только с «Главное меню». + При активном обращении кнопки нет — пользователь и так пишет оператору. Сообщение с префиксом `//` (`SUPPORT_NOTE_PREFIX`) остаётся заметкой внутри топика и пользователю не уходит. - **Завершение — уход из контекста поддержки.** Флаг `is_active` снимается при входе в diff --git a/VERSION b/VERSION index 0e2775f..a2bea9e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+58 \ No newline at end of file +0.1.0+59 \ No newline at end of file diff --git a/support.py b/support.py index a33be1c..cf4afd6 100644 --- a/support.py +++ b/support.py @@ -16,10 +16,24 @@ from contextlib import closing from typing import Any, Awaitable, Callable from aiogram import BaseMiddleware, Bot, F, Router -from aiogram.types import Message, TelegramObject, User +from aiogram.types import ( + CallbackQuery, + InlineKeyboardButton, + InlineKeyboardMarkup, + Message, + TelegramObject, + User, +) from activity_phrases import support_greeting -from keyboards import MAIN_MENU_VALUE, SUPPORT_BUTTON, SUPPORT_VALUE, TRAINING_RESET_VALUE, resolve_button_text +from keyboards import ( + MAIN_MENU_VALUE, + SUPPORT_BUTTON, + SUPPORT_VALUE, + TRAINING_RESET_VALUE, + mode_keyboard, + resolve_button_text, +) from user_store import connect, support_profile_summary logger = logging.getLogger(__name__) @@ -67,6 +81,27 @@ def note_prefix() -> str: DEFAULT_OPERATOR_SIGNATURE = "Оператор поддержки" +REPLY_TO_OPERATOR_BUTTON = "↩️ Ответить" +SUPPORT_REPLY_CALLBACK_PREFIX = "support:reply:" +SUPPORT_REPLY_PROMPT = "Жду ваше сообщение для оператора." + + +def support_reply_callback_data(user_id: int) -> str: + return f"{SUPPORT_REPLY_CALLBACK_PREFIX}{user_id}" + + +def support_reply_keyboard(user_id: int) -> InlineKeyboardMarkup: + """Кнопка «Ответить» под сообщением оператора, если обращение закрыто.""" + return InlineKeyboardMarkup( + inline_keyboard=[[ + InlineKeyboardButton( + text=REPLY_TO_OPERATOR_BUTTON, + callback_data=support_reply_callback_data(user_id), + ) + ]] + ) + + def operator_signature() -> str: """Подпись, с которой ответ оператора приходит пользователю в личку. @@ -435,19 +470,33 @@ async def _deliver_operator_reply(bot: Bot, message: Message, user_id: int) -> N Текст уходит одним сообщением с подписью в начале. Медиа копируется `copy_message` (caption у копии задать нельзя), поэтому подпись отправляется - отдельным сообщением перед ним. + отдельным сообщением перед ним. Если обращение закрыто, к сообщению + добавляется кнопка «Ответить» — она возвращает в режим общения с поддержкой. """ signature = operator_signature() + # Кнопка «Ответить» нужна только если пользователь вне режима поддержки: + # иначе он и так пишет оператору, и кнопка была бы лишней. + markup = None + try: + if not await asyncio.to_thread(is_support_active, user_id): + markup = support_reply_keyboard(user_id) + except Exception as exc: + logger.warning("Support active lookup failed (%s)", type(exc).__name__) + text = (message.text or "").strip() if text: - await bot.send_message(chat_id=user_id, text=f"{signature}:\n{text}") + await bot.send_message( + chat_id=user_id, text=f"{signature}:\n{text}", reply_markup=markup + ) return caption = (message.caption or "").strip() if caption: # У медиа есть подпись — пересылаем её текстом, само медиа копируем ниже. - await bot.send_message(chat_id=user_id, text=f"{signature}:\n{caption}") + await bot.send_message( + chat_id=user_id, text=f"{signature}:\n{caption}", reply_markup=markup + ) else: - await bot.send_message(chat_id=user_id, text=f"{signature}:") + await bot.send_message(chat_id=user_id, text=f"{signature}:", reply_markup=markup) await bot.copy_message( chat_id=user_id, from_chat_id=message.chat.id, @@ -461,6 +510,40 @@ def _is_service_message(message: Message) -> bool: return bool(prefix) and text.startswith(prefix) +@router.callback_query(F.data.startswith(SUPPORT_REPLY_CALLBACK_PREFIX)) +async def handle_support_reply(callback: CallbackQuery) -> None: + """Кнопка «Ответить»: возвращает пользователя в режим общения с поддержкой.""" + raw = (callback.data or "").split(":", 2)[-1] + try: + owner_id = int(raw) + except ValueError: + await callback.answer("Некорректная кнопка.", show_alert=True) + return + if callback.from_user is None or callback.from_user.id != owner_id: + await callback.answer("Эта кнопка предназначена другому пользователю.", show_alert=True) + return + if not is_enabled(): + await callback.answer(SUPPORT_UNAVAILABLE_TEXT, show_alert=True) + return + try: + await asyncio.to_thread(set_active, owner_id, True) + except Exception as exc: + logger.error("Support reactivate failed user=%s (%s)", owner_id, type(exc).__name__) + await callback.answer("Не удалось открыть обращение. Попробуй ещё раз.", show_alert=True) + return + await callback.answer() + message = callback.message + if isinstance(message, Message): + try: + await message.edit_reply_markup(reply_markup=None) + except Exception: + logger.debug("Support reply button removal failed") + await message.answer( + SUPPORT_REPLY_PROMPT, + reply_markup=mode_keyboard("help", support=True), + ) + + @router.message( F.chat.func(_is_support_chat), F.is_topic_message.as_("topic"), diff --git a/tests/test_support.py b/tests/test_support.py index 4ff5eb1..89ed3ce 100644 --- a/tests/test_support.py +++ b/tests/test_support.py @@ -429,6 +429,88 @@ class UserMessageToTopicTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(bot.send_message.await_args.kwargs["text"], "99:\nпривет") +class SupportReplyButtonTests(unittest.IsolatedAsyncioTestCase): + """Кнопка «Ответить» под сообщением оператора, если обращение закрыто.""" + + def test_callback_data_carries_user_id(self): + self.assertEqual(support.support_reply_callback_data(42), "support:reply:42") + + def test_keyboard_has_single_reply_button(self): + markup = support.support_reply_keyboard(42) + button = markup.inline_keyboard[0][0] + self.assertEqual(button.text, support.REPLY_TO_OPERATOR_BUTTON) + self.assertEqual(button.callback_data, "support:reply:42") + + async def test_reply_button_added_when_case_is_closed(self): + bot = AsyncMock() + message = SimpleNamespace(text="Проверяю платёж", caption=None, + chat=SimpleNamespace(id=-100123)) + with patch.object(support, "is_support_active", new=lambda uid, db_path=None: False): + await support._deliver_operator_reply(bot, message, 42) + + self.assertIsNotNone(bot.send_message.await_args.kwargs["reply_markup"]) + + async def test_reply_button_omitted_when_case_is_open(self): + bot = AsyncMock() + message = SimpleNamespace(text="Проверяю платёж", caption=None, + chat=SimpleNamespace(id=-100123)) + with patch.object(support, "is_support_active", new=lambda uid, db_path=None: True): + await support._deliver_operator_reply(bot, message, 42) + + self.assertIsNone(bot.send_message.await_args.kwargs["reply_markup"]) + + async def test_button_reactivates_support_case(self): + bot = AsyncMock() + real_message = Message( + message_id=1, + date=datetime.datetime.now(), + chat=Chat(id=555, type="private"), + text="Оператор поддержки:\nПроверяю платёж", + ).as_(bot) + callback = SimpleNamespace( + data="support:reply:42", + from_user=SimpleNamespace(id=42), + message=real_message, + answer=AsyncMock(), + ) + # Без SUPPORT_GROUP_ID обработчик отказывает — поддержка выключена. + with patch.dict("os.environ", {"SUPPORT_GROUP_ID": "-100123"}, clear=False): + with patch.object(support, "set_active") as set_active: + await support.handle_support_reply(callback) + + set_active.assert_called_once_with(42, True) + callback.answer.assert_awaited_once_with() + # aiogram вызывает bot(SendMessage(...)), а не bot.send_message напрямую. + sent = [c.args[0] for c in bot.mock_calls if c.args and type(c.args[0]).__name__ == "SendMessage"] + self.assertEqual(len(sent), 1) + self.assertEqual(sent[0].text, support.SUPPORT_REPLY_PROMPT) + self.assertIsNotNone(sent[0].reply_markup) + + async def test_button_is_rejected_for_another_user(self): + callback = SimpleNamespace( + data="support:reply:42", + from_user=SimpleNamespace(id=999), + message=SimpleNamespace(edit_reply_markup=AsyncMock(), answer=AsyncMock()), + answer=AsyncMock(), + ) + with patch.object(support, "set_active") as set_active: + await support.handle_support_reply(callback) + + set_active.assert_not_called() + callback.message.answer.assert_not_awaited() + + async def test_button_is_rejected_on_broken_callback_data(self): + callback = SimpleNamespace( + data="support:reply:abc", + from_user=SimpleNamespace(id=42), + message=SimpleNamespace(edit_reply_markup=AsyncMock(), answer=AsyncMock()), + answer=AsyncMock(), + ) + with patch.object(support, "set_active") as set_active: + await support.handle_support_reply(callback) + set_active.assert_not_called() + + class OperatorSignatureTests(unittest.IsolatedAsyncioTestCase): """Ответ оператора приходит пользователю с подписью «Оператор поддержки:»."""