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

+3
View File
@@ -153,6 +153,9 @@ Telegram-бот, который по запросу пользователя г
- **Подпись ответа**: пользователю сообщение приходит как «Оператор поддержки:\n<текст>».
Переменная `SUPPORT_OPERATOR_SIGNATURE` (default «Оператор поддержки»). У медиа caption не
подменяется, поэтому подпись уходит отдельным сообщением перед копией медиа.
Если обращение закрыто, добавляется инлайн-кнопка «↩️ Ответить» (`support:reply:<user_id>`):
она ставит `is_active = 1`, убирает кнопку с сообщения и показывает клавиатуру поддержки.
Callback проверяет, что нажал тот же пользователь.
- **Заметки оператора**: сообщение с префиксом `//` (`SUPPORT_NOTE_PREFIX`) остаётся в топике и
пользователю не отправляется.
- **Завершение**: уход из контекста поддержки. `is_active` снимается при входе в **любой**
+3
View File
@@ -108,6 +108,9 @@ onboarding. В «Подписке» сразу отображаются тип
**с подписью**: «Оператор поддержки:\n<текст>». Подпись задаётся `SUPPORT_OPERATOR_SIGNATURE`
(по умолчанию «Оператор поддержки»). У медиа `copy_message` не позволяет подменить caption,
поэтому подпись уходит отдельным сообщением перед самим медиа.
Если обращение закрыто, к сообщению добавляется инлайн-кнопка **«↩️ Ответить»**: она снова
открывает обращение (`is_active = 1`) и показывает клавиатуру только с «Главное меню».
При активном обращении кнопки нет — пользователь и так пишет оператору.
Сообщение с префиксом `//` (`SUPPORT_NOTE_PREFIX`) остаётся заметкой внутри топика
и пользователю не уходит.
- **Завершение — уход из контекста поддержки.** Флаг `is_active` снимается при входе в
+1 -1
View File
@@ -1 +1 @@
0.1.0+58
0.1.0+59
+89 -6
View File
@@ -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"),
+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):
"""Ответ оператора приходит пользователю с подписью «Оператор поддержки:»."""