diff --git a/IDEA.MD b/IDEA.MD index 02615b9..35d4f6a 100644 --- a/IDEA.MD +++ b/IDEA.MD @@ -150,7 +150,9 @@ Telegram-бот, который по запросу пользователя г Пул переопределяется переменной `SUPPORT_GREETINGS` (разделитель `|`) без деплоя. - **Заметки оператора**: сообщение с префиксом `//` (`SUPPORT_NOTE_PREFIX`) остаётся в топике и пользователю не отправляется. -- **Завершение**: «🏠 Главное меню» снимает `is_active`, топик не удаляется и не закрывается. +- **Завершение**: «🏠 Главное меню» **или `/start`** снимают `is_active`, топик не удаляется. + Обе точки возврата в лобби должны деактивировать обращение: иначе FSM сброшен, а в БД + обращение открыто — следующее сообщение уходит оператору, а вход в режим блокируется. - Если топик удалили вручную, при следующем сообщении бот создаёт его заново. **Требования к группе:** `is_forum = true`, бот — **администратор** с правом управления темами diff --git a/README.md b/README.md index 1dc7858..82182c0 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,9 @@ onboarding. В «Подписке» сразу отображаются тип Пул можно переопределить без деплоя: `SUPPORT_GREETINGS` в Coolify, разделитель `|`. - Ответ оператора — любое сообщение в топике от живого участника. Сообщение с префиксом `//` (`SUPPORT_NOTE_PREFIX`) остаётся заметкой внутри топика и пользователю не уходит. -- Завершение — кнопка «🏠 Главное меню»: обращение деактивируется, топик **не удаляется**. +- Завершение — кнопка «🏠 Главное меню» **или `/start`**: обращение деактивируется, топик + **не удаляется**. Обе точки возврата в лобби обязаны снимать `is_active`, иначе FSM уже + сброшен, а в БД обращение открыто — и следующее сообщение всё равно уйдёт оператору. Повторное «Поддержка» продолжает переписку в том же топике с сохранённой историей. - Пока обращение активно, клавиатура содержит только «Главное меню», а переключение в другие режимы отклоняется — чтобы режимы и генерация не смешивались с обращением. diff --git a/VERSION b/VERSION index cf58a4c..2016961 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+54 \ No newline at end of file +0.1.0+55 \ No newline at end of file diff --git a/bot.py b/bot.py index 586bf09..632a74d 100644 --- a/bot.py +++ b/bot.py @@ -165,16 +165,11 @@ def keyboard_for_mode(mode: str) -> ReplyKeyboardMarkup: async def enter_mode(message: Message, state: FSMContext, mode: str) -> None: - if mode != "help" and message.from_user is not None: - try: - if await asyncio.to_thread(support.is_support_active, message.from_user.id): - await message.answer( - "Идёт обращение в поддержку. Нажми «Главное меню», чтобы завершить его.", - reply_markup=mode_keyboard("help", support=True), - ) - return - except Exception as exc: - logger.warning("Support active lookup failed (%s)", type(exc).__name__) + # Проверки активного обращения здесь НАМЕРЕННО НЕТ. + # /start и «Главное меню» закрывают обращение через support.deactivate(...), + # выход в любой режим из открытого обращения разрешён — пользователь не должен + # быть заперт. Раньше здесь была блокировка, и она срабатывала на /start, + # потому что /start сбрасывал FSM, но не снимал is_active в БД. await state.clear() await state.set_state(MODE_STATES[mode]) title, description = MODE_COPY[mode] @@ -407,6 +402,13 @@ async def start(message: Message, state: FSMContext) -> None: if message.from_user is None: await message.answer("Не удалось определить пользователя.") return + # /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 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 c35a07a..515a3d9 100644 --- a/tests/test_support.py +++ b/tests/test_support.py @@ -10,12 +10,38 @@ from typing import Any, cast from unittest.mock import AsyncMock, patch import activity_phrases +import bot import support import user_store from aiogram.types import Chat, Message, User +class _StubState: + """Минимальная FSM-заглушка (полная версия — в tests/test_writer_features.py).""" + + def __init__(self, mode="lobby"): + self.value = bot.MODE_STATES[mode].state + self.data = {} + + async def get_state(self): + return self.value + + async def set_state(self, state): + self.value = state.state + + async def clear(self): + self.value = None + self.data = {} + + async def get_data(self): + return dict(self.data) + + async def update_data(self, **kwargs): + self.data.update(kwargs) + return dict(self.data) + + class _TempDbCase(unittest.IsolatedAsyncioTestCase): def setUp(self): self.temp_dir = tempfile.TemporaryDirectory() @@ -319,6 +345,75 @@ class SupportStartTextTests(unittest.TestCase): self.assertNotIn("оператор", rendered.lower()) +class StartClosesSupportCaseTests(_TempDbCase): + """/start — возврат в лобби, поэтому обращение поддержки должно закрываться. + + Регрессия: /start сбрасывал FSM, но не снимал is_active в БД. Пользователь + оказывался в лобби, а следующее сообщение всё равно уходило оператору; + вход в режим дополнительно блокировался проверкой в enter_mode(). + """ + + def _tg_user(self, user_id=42): + return SimpleNamespace(id=user_id, is_bot=False, first_name="Test", username="test") + + def _message(self, text="/start", user_id=42): + message = SimpleNamespace( + text=text, + from_user=self._tg_user(user_id), + chat=SimpleNamespace(id=555, type="private"), + message_thread_id=None, + bot=AsyncMock(), + answers=[], + ) + + async def answer(text, **kwargs): + message.answers.append((text, kwargs)) + + message.answer = answer + return message + + async def test_start_deactivates_active_support_case(self): + # is_support_active() и deactivate() работают только при заданной группе. + 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) + self.assertTrue(support.is_support_active(42, self.db_path)) + + message = self._message() + with ( + patch.object(bot, "notify_development_deploy", new=AsyncMock()), + patch.object(bot, "send_rich_message", new=AsyncMock()), + ): + await bot.start(message, _StubState("help")) + + self.assertFalse(support.is_support_active(42, self.db_path)) + # Топик сохраняется: при следующем обращении переписка продолжается. + self.assertEqual(support.get_topic(42, self.db_path), 777) + + async def test_start_without_case_does_not_fail(self): + message = self._message() + with ( + patch.object(bot, "notify_development_deploy", new=AsyncMock()), + patch.object(bot, "send_rich_message", new=AsyncMock()), + ): + await bot.start(message, _StubState("lobby")) + self.assertFalse(support.is_support_active(42, self.db_path)) + + async def test_enter_mode_no_longer_blocks_other_modes(self): + """Вход в режим из открытого обращения разрешён — пользователя нельзя запирать.""" + 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") + + self.assertEqual(await state.get_state(), bot.BotMode.training.state) + texts = " ".join(text for text, _ in message.answers) + self.assertNotIn("Идёт обращение в поддержку", texts) + + class SupportTopicTests(_TempDbCase): def _tg_user(self, **kwargs): defaults = dict(id=42, first_name="Иван", last_name=None, username="ivan", is_bot=False)