fix: вход в любой режим закрывает обращение поддержки
Деактивация была только в /start и «Главное меню». После перехода в
«Обучение» флаг is_active оставался, и текст уезжал оператору, а не в
обучение — сообщение уходило не туда.
Правило теперь: ушёл из контекста поддержки — обращение неактивно.
_close_support_case() вызывается из enter_mode() (любой режим),
show_main_menu() и start(). «🆘 Поддержка» — не режим, флаг не снимает.
Топик и история сохраняются, следующее обращение продолжает переписку.
Три точки вызова сведены в один helper вместо трёх копий try/except.
Тесты: 230 OK (3 новых). Регрессия проверена на старом коде: после входа
в «Обучение» is_active=True и relay=1 (текст ушёл оператору); с фиксом
is_active=False, relay=0 (текст обработал бот).
VERSION: 0.1.0+55 -> 0.1.0+56
This commit is contained in:
1 parent
12ef2c6b44
commit
3dd2924d5e
5 files changed
+93
-21
No files matched your search
@@ -150,9 +150,10 @@ Telegram-бот, который по запросу пользователя г
|
||||
Пул переопределяется переменной `SUPPORT_GREETINGS` (разделитель `|`) без деплоя.
|
||||
- **Заметки оператора**: сообщение с префиксом `//` (`SUPPORT_NOTE_PREFIX`) остаётся в топике и
|
||||
пользователю не отправляется.
|
||||
- **Завершение**: «🏠 Главное меню» **или `/start`** снимают `is_active`, топик не удаляется.
|
||||
Обе точки возврата в лобби должны деактивировать обращение: иначе FSM сброшен, а в БД
|
||||
обращение открыто — следующее сообщение уходит оператору, а вход в режим блокируется.
|
||||
- **Завершение**: уход из контекста поддержки. `is_active` снимается при входе в **любой**
|
||||
режим, по «🏠 Главное меню» и `/start` — всё через общую `_close_support_case()`.
|
||||
Пока флаг стоит, middleware шлёт любой текст из лички оператору, поэтому переключение
|
||||
режима без снятия флага отправляло сообщение не туда. Топик не удаляется.
|
||||
- Если топик удалили вручную, при следующем сообщении бот создаёт его заново.
|
||||
|
||||
**Требования к группе:** `is_forum = true`, бот — **администратор** с правом управления темами
|
||||
|
||||
@@ -103,9 +103,12 @@ onboarding. В «Подписке» сразу отображаются тип
|
||||
Пул можно переопределить без деплоя: `SUPPORT_GREETINGS` в Coolify, разделитель `|`.
|
||||
- Ответ оператора — любое сообщение в топике от живого участника. Сообщение с префиксом `//`
|
||||
(`SUPPORT_NOTE_PREFIX`) остаётся заметкой внутри топика и пользователю не уходит.
|
||||
- Завершение — кнопка «🏠 Главное меню» **или `/start`**: обращение деактивируется, топик
|
||||
**не удаляется**. Обе точки возврата в лобби обязаны снимать `is_active`, иначе FSM уже
|
||||
сброшен, а в БД обращение открыто — и следующее сообщение всё равно уйдёт оператору.
|
||||
- **Завершение — уход из контекста поддержки.** Флаг `is_active` снимается при входе в
|
||||
**любой** режим (`/training`, `/generate`, `/subscription`, `/help`), а также по
|
||||
«🏠 Главное меню» и `/start` — общая точка `_close_support_case()`. Пока флаг стоит,
|
||||
middleware отправляет любой текст из лички оператору, поэтому переключение в «Обучение»
|
||||
без снятия флага приводило к тому, что сообщение уезжало не туда.
|
||||
Топик при этом **не удаляется**: следующее «🆘 Поддержка» продолжает ту же переписку.
|
||||
Повторное «Поддержка» продолжает переписку в том же топике с сохранённой историей.
|
||||
- Пока обращение активно, клавиатура содержит только «Главное меню», а переключение в другие
|
||||
режимы отклоняется — чтобы режимы и генерация не смешивались с обращением.
|
||||
|
||||
@@ -165,11 +165,11 @@ def keyboard_for_mode(mode: str) -> ReplyKeyboardMarkup:
|
||||
|
||||
|
||||
async def enter_mode(message: Message, state: FSMContext, mode: str) -> None:
|
||||
# Проверки активного обращения здесь НАМЕРЕННО НЕТ.
|
||||
# /start и «Главное меню» закрывают обращение через support.deactivate(...),
|
||||
# выход в любой режим из открытого обращения разрешён — пользователь не должен
|
||||
# быть заперт. Раньше здесь была блокировка, и она срабатывала на /start,
|
||||
# потому что /start сбрасывал FSM, но не снимал is_active в БД.
|
||||
# Вход в ЛЮБОЙ режим означает уход из контекста поддержки, поэтому
|
||||
# обращение деактивируется. Пока is_active стоит, любой текст из лички
|
||||
# уезжает оператору — пользователь переключился в «Обучение», а сообщение
|
||||
# ушло не туда. «🆘 Поддержка» — не режим, поэтому флаг не снимает.
|
||||
await _close_support_case(message)
|
||||
await state.clear()
|
||||
await state.set_state(MODE_STATES[mode])
|
||||
title, description = MODE_COPY[mode]
|
||||
@@ -186,16 +186,27 @@ async def enter_mode(message: Message, state: FSMContext, mode: str) -> None:
|
||||
|
||||
async def show_main_menu(message: Message, state: FSMContext) -> None:
|
||||
"""Выход в главное меню; активное обращение поддержки закрывается."""
|
||||
if message.from_user is not None:
|
||||
try:
|
||||
await support.deactivate(message.from_user.id)
|
||||
except Exception as exc:
|
||||
logger.warning("Support deactivate failed (%s)", type(exc).__name__)
|
||||
await _close_support_case(message)
|
||||
await state.clear()
|
||||
await state.set_state(BotMode.lobby)
|
||||
await message.answer("Выбери режим работы.", reply_markup=main_menu_keyboard())
|
||||
|
||||
|
||||
async def _close_support_case(message: Message) -> None:
|
||||
"""Снимает флаг активного обращения, не удаляя топик и историю.
|
||||
|
||||
Вызывается из всех точек ухода из контекста поддержки: вход в любой режим,
|
||||
«Главное меню» и `/start`. Пока is_active стоит, middleware отправляет
|
||||
любой текст из лички оператору.
|
||||
"""
|
||||
if message.from_user is None:
|
||||
return
|
||||
try:
|
||||
await support.deactivate(message.from_user.id)
|
||||
except Exception as exc:
|
||||
logger.warning("Support deactivate failed (%s)", type(exc).__name__)
|
||||
|
||||
|
||||
async def open_support(message: Message, state: FSMContext) -> None:
|
||||
"""Открывает обращение в поддержку из режима «Помощь»."""
|
||||
if message.from_user is None:
|
||||
@@ -405,10 +416,7 @@ async def start(message: Message, state: FSMContext) -> None:
|
||||
# /start — возврат в лобби, поэтому обращение поддержки закрывается.
|
||||
# Иначе is_active остаётся в БД, а FSM уже сброшен: пользователь в лобби,
|
||||
# но следующее сообщение всё равно уходит оператору.
|
||||
try:
|
||||
await support.deactivate(message.from_user.id)
|
||||
except Exception as exc:
|
||||
logger.warning("Support deactivate failed (%s)", type(exc).__name__)
|
||||
await _close_support_case(message)
|
||||
await state.clear()
|
||||
await state.set_state(BotMode.lobby)
|
||||
await notify_development_deploy(message.bot)
|
||||
|
||||
@@ -399,6 +399,59 @@ class StartClosesSupportCaseTests(_TempDbCase):
|
||||
await bot.start(message, _StubState("lobby"))
|
||||
self.assertFalse(support.is_support_active(42, self.db_path))
|
||||
|
||||
async def test_enter_any_mode_closes_support_case(self):
|
||||
"""Вход в любой режим = уход из поддержки, флаг снимается.
|
||||
|
||||
Регрессия: деактивация была только в /start и «Главное меню», поэтому
|
||||
после перехода в «Обучение» текст уезжал оператору, а не в обучение.
|
||||
"""
|
||||
for mode in ("training", "generation", "subscription", "help"):
|
||||
with patch.dict("os.environ", {"SUPPORT_GROUP_ID": "-100123"}, clear=False):
|
||||
support.set_topic(42, 777, self.db_path)
|
||||
support.set_active(42, True, self.db_path)
|
||||
|
||||
state = _StubState("help")
|
||||
message = self._message("/training")
|
||||
with patch.object(bot, "send_rich_message", new=AsyncMock()):
|
||||
await bot.enter_mode(message, state, mode)
|
||||
|
||||
self.assertFalse(
|
||||
support.is_support_active(42, self.db_path),
|
||||
f"режим {mode} не закрыл обращение",
|
||||
)
|
||||
# Топик сохраняется: следующее обращение продолжает переписку.
|
||||
self.assertEqual(support.get_topic(42, self.db_path), 777, mode)
|
||||
|
||||
async def test_mode_switch_then_text_goes_to_bot_not_operator(self):
|
||||
"""После перехода в режим обычный текст не должен уходить оператору."""
|
||||
with patch.dict("os.environ", {"SUPPORT_GROUP_ID": "-100123"}, clear=False):
|
||||
support.set_topic(42, 777, self.db_path)
|
||||
support.set_active(42, True, self.db_path)
|
||||
|
||||
state = _StubState("help")
|
||||
message = self._message("/training")
|
||||
with patch.object(bot, "send_rich_message", new=AsyncMock()):
|
||||
await bot.enter_mode(message, state, "training")
|
||||
|
||||
# Middleware теперь должен пропустить текст в обычные обработчики.
|
||||
event = Message(
|
||||
message_id=2,
|
||||
date=datetime.datetime.now(),
|
||||
chat=Chat(id=555, type="private"),
|
||||
from_user=User(id=42, is_bot=False, first_name="Test"),
|
||||
text="кофейня в Калининграде для семей с детьми",
|
||||
)
|
||||
with patch.object(support, "relay_user_message", new=AsyncMock()) as relay:
|
||||
handled = await self._call_middleware(event)
|
||||
self.assertEqual(handled, "handled")
|
||||
relay.assert_not_awaited()
|
||||
|
||||
async def _call_middleware(self, event):
|
||||
async def handler(_event, _data):
|
||||
return "handled"
|
||||
|
||||
return await support.SupportRelayMiddleware()(handler, event, {})
|
||||
|
||||
async def test_enter_mode_no_longer_blocks_other_modes(self):
|
||||
"""Вход в режим из открытого обращения разрешён — пользователя нельзя запирать."""
|
||||
support.set_topic(42, 777, self.db_path)
|
||||
@@ -413,6 +466,13 @@ class StartClosesSupportCaseTests(_TempDbCase):
|
||||
texts = " ".join(text for text, _ in message.answers)
|
||||
self.assertNotIn("Идёт обращение в поддержку", texts)
|
||||
|
||||
async def test_main_menu_closes_support_case(self):
|
||||
with patch.dict("os.environ", {"SUPPORT_GROUP_ID": "-100123"}, clear=False):
|
||||
support.set_topic(42, 777, self.db_path)
|
||||
support.set_active(42, True, self.db_path)
|
||||
await bot.show_main_menu(self._message("🏠 Главное меню"), _StubState("help"))
|
||||
self.assertFalse(support.is_support_active(42, self.db_path))
|
||||
|
||||
|
||||
class SupportTopicTests(_TempDbCase):
|
||||
def _tg_user(self, **kwargs):
|
||||
|
||||
Reference in new issue
Block a user