From 3dd2924d5eaf292d30c15ef60d267e1840eeb59f Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 11 Oct 2026 19:48:57 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=B2=D1=85=D0=BE=D0=B4=20=D0=B2=20?= =?UTF-8?q?=D0=BB=D1=8E=D0=B1=D0=BE=D0=B9=20=D1=80=D0=B5=D0=B6=D0=B8=D0=BC?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20?= =?UTF-8?q?=D0=BE=D0=B1=D1=80=D0=B0=D1=89=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B4=D0=B5=D1=80=D0=B6=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Деактивация была только в /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 --- IDEA.MD | 7 ++--- README.md | 9 ++++--- VERSION | 2 +- bot.py | 36 ++++++++++++++++---------- tests/test_support.py | 60 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 93 insertions(+), 21 deletions(-) diff --git a/IDEA.MD b/IDEA.MD index 35d4f6a..b0cd0db 100644 --- a/IDEA.MD +++ b/IDEA.MD @@ -150,9 +150,10 @@ Telegram-бот, который по запросу пользователя г Пул переопределяется переменной `SUPPORT_GREETINGS` (разделитель `|`) без деплоя. - **Заметки оператора**: сообщение с префиксом `//` (`SUPPORT_NOTE_PREFIX`) остаётся в топике и пользователю не отправляется. -- **Завершение**: «🏠 Главное меню» **или `/start`** снимают `is_active`, топик не удаляется. - Обе точки возврата в лобби должны деактивировать обращение: иначе FSM сброшен, а в БД - обращение открыто — следующее сообщение уходит оператору, а вход в режим блокируется. +- **Завершение**: уход из контекста поддержки. `is_active` снимается при входе в **любой** + режим, по «🏠 Главное меню» и `/start` — всё через общую `_close_support_case()`. + Пока флаг стоит, middleware шлёт любой текст из лички оператору, поэтому переключение + режима без снятия флага отправляло сообщение не туда. Топик не удаляется. - Если топик удалили вручную, при следующем сообщении бот создаёт его заново. **Требования к группе:** `is_forum = true`, бот — **администратор** с правом управления темами diff --git a/README.md b/README.md index 82182c0..9ae701c 100644 --- a/README.md +++ b/README.md @@ -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 отправляет любой текст из лички оператору, поэтому переключение в «Обучение» + без снятия флага приводило к тому, что сообщение уезжало не туда. + Топик при этом **не удаляется**: следующее «🆘 Поддержка» продолжает ту же переписку. Повторное «Поддержка» продолжает переписку в том же топике с сохранённой историей. - Пока обращение активно, клавиатура содержит только «Главное меню», а переключение в другие режимы отклоняется — чтобы режимы и генерация не смешивались с обращением. diff --git a/VERSION b/VERSION index 2016961..85b1ce8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+55 \ No newline at end of file +0.1.0+56 \ No newline at end of file diff --git a/bot.py b/bot.py index 632a74d..f12978d 100644 --- a/bot.py +++ b/bot.py @@ -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) diff --git a/tests/test_support.py b/tests/test_support.py index 515a3d9..bb028c1 100644 --- a/tests/test_support.py +++ b/tests/test_support.py @@ -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):