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:
1 parent
175d618d62
commit
21f4adf0b1
5 files changed
+178
-7
No files matched your search
@@ -153,6 +153,9 @@ Telegram-бот, который по запросу пользователя г
|
||||
- **Подпись ответа**: пользователю сообщение приходит как «Оператор поддержки:\n<текст>».
|
||||
Переменная `SUPPORT_OPERATOR_SIGNATURE` (default «Оператор поддержки»). У медиа caption не
|
||||
подменяется, поэтому подпись уходит отдельным сообщением перед копией медиа.
|
||||
Если обращение закрыто, добавляется инлайн-кнопка «↩️ Ответить» (`support:reply:<user_id>`):
|
||||
она ставит `is_active = 1`, убирает кнопку с сообщения и показывает клавиатуру поддержки.
|
||||
Callback проверяет, что нажал тот же пользователь.
|
||||
- **Заметки оператора**: сообщение с префиксом `//` (`SUPPORT_NOTE_PREFIX`) остаётся в топике и
|
||||
пользователю не отправляется.
|
||||
- **Завершение**: уход из контекста поддержки. `is_active` снимается при входе в **любой**
|
||||
|
||||
@@ -108,6 +108,9 @@ onboarding. В «Подписке» сразу отображаются тип
|
||||
**с подписью**: «Оператор поддержки:\n<текст>». Подпись задаётся `SUPPORT_OPERATOR_SIGNATURE`
|
||||
(по умолчанию «Оператор поддержки»). У медиа `copy_message` не позволяет подменить caption,
|
||||
поэтому подпись уходит отдельным сообщением перед самим медиа.
|
||||
Если обращение закрыто, к сообщению добавляется инлайн-кнопка **«↩️ Ответить»**: она снова
|
||||
открывает обращение (`is_active = 1`) и показывает клавиатуру только с «Главное меню».
|
||||
При активном обращении кнопки нет — пользователь и так пишет оператору.
|
||||
Сообщение с префиксом `//` (`SUPPORT_NOTE_PREFIX`) остаётся заметкой внутри топика
|
||||
и пользователю не уходит.
|
||||
- **Завершение — уход из контекста поддержки.** Флаг `is_active` снимается при входе в
|
||||
|
||||
+89
-6
@@ -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"),
|
||||
|
||||
@@ -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):
|
||||
"""Ответ оператора приходит пользователю с подписью «Оператор поддержки:»."""
|
||||
|
||||
|
||||
Reference in new issue
Block a user