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

+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"),