feat: кнопка «Ответить» на сообщении оператора

Оператор мог ответить, когда пользователь уже вышел из режима поддержки.
Сообщение приходило, но ответить было нечем: снова идти в «Помощь →
Поддержка», а про прежний контекст уже ничего не напоминало.

- К сообщению оператора добавляется инлайн-кнопка «↩️ Ответить», только
  если обращение закрыто (при активном пользователь и так пишет оператору).
- Кнопка ставит is_active=1, убирает себя с сообщения и показывает
  клавиатуру поддержки (только «Главное меню»).
- Callback проверяет владельца: support:reply:<user_id> сработает только
  для того пользователя, кому пришло сообщение.
- Роутер support.router не затронут фильтром основного роутера
  (F.message.chat.type == "private") — он на него не распространяется.

Тесты: 251 OK (7 новых). Проверено, что aiogram вызывает bot(SendMessage(...)),
а не bot.send_message — asserts адаптированы, иначе тест проходил вхолостую.

VERSION: 0.1.0+58 -> 0.1.0+59
This commit is contained in:
hermes committed 2026-10-11 20:40:06 +02:00
1 parent 175d618d62
commit 21f4adf0b1
5 files changed
+178 -7

No files matched your search

+82
View File
@@ -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):
"""Ответ оператора приходит пользователю с подписью «Оператор поддержки:»."""