fix: /start закрывает обращение в поддержку
/start возвращает в лобби, но не снимал is_active в support_topics: FSM сбрасывался, а обращение в БД оставалось открытым. Пользователь оказывался в лобби, а следующее сообщение всё равно уходило оператору. Дополнительно enter_mode() блокировал вход в режим сообщением «Идёт обращение в поддержку» — после /start это выглядело как необъяснимый отказ. - start() вызывает support.deactivate() перед сбросом FSM. - Блокировка из enter_mode() удалена: выход в любой режим из открытого обращения разрешён — пользователя нельзя запирать. Проверка дублировала middleware иConflictовала с /start. Тесты: 227 OK (3 новых). Регрессия проверена на старом коде: /start оставляет is_active=True (баг), с фиксом — False. Топик при этом сохраняется, поэтому следующее обращение продолжает ту же переписку. VERSION: 0.1.0+54 -> 0.1.0+55
This commit is contained in:
1 parent
562442b979
commit
12ef2c6b44
5 files changed
+114
-13
No files matched your search
@@ -150,7 +150,9 @@ Telegram-бот, который по запросу пользователя г
|
||||
Пул переопределяется переменной `SUPPORT_GREETINGS` (разделитель `|`) без деплоя.
|
||||
- **Заметки оператора**: сообщение с префиксом `//` (`SUPPORT_NOTE_PREFIX`) остаётся в топике и
|
||||
пользователю не отправляется.
|
||||
- **Завершение**: «🏠 Главное меню» снимает `is_active`, топик не удаляется и не закрывается.
|
||||
- **Завершение**: «🏠 Главное меню» **или `/start`** снимают `is_active`, топик не удаляется.
|
||||
Обе точки возврата в лобби должны деактивировать обращение: иначе FSM сброшен, а в БД
|
||||
обращение открыто — следующее сообщение уходит оператору, а вход в режим блокируется.
|
||||
- Если топик удалили вручную, при следующем сообщении бот создаёт его заново.
|
||||
|
||||
**Требования к группе:** `is_forum = true`, бот — **администратор** с правом управления темами
|
||||
|
||||
@@ -103,7 +103,9 @@ onboarding. В «Подписке» сразу отображаются тип
|
||||
Пул можно переопределить без деплоя: `SUPPORT_GREETINGS` в Coolify, разделитель `|`.
|
||||
- Ответ оператора — любое сообщение в топике от живого участника. Сообщение с префиксом `//`
|
||||
(`SUPPORT_NOTE_PREFIX`) остаётся заметкой внутри топика и пользователю не уходит.
|
||||
- Завершение — кнопка «🏠 Главное меню»: обращение деактивируется, топик **не удаляется**.
|
||||
- Завершение — кнопка «🏠 Главное меню» **или `/start`**: обращение деактивируется, топик
|
||||
**не удаляется**. Обе точки возврата в лобби обязаны снимать `is_active`, иначе FSM уже
|
||||
сброшен, а в БД обращение открыто — и следующее сообщение всё равно уйдёт оператору.
|
||||
Повторное «Поддержка» продолжает переписку в том же топике с сохранённой историей.
|
||||
- Пока обращение активно, клавиатура содержит только «Главное меню», а переключение в другие
|
||||
режимы отклоняется — чтобы режимы и генерация не смешивались с обращением.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user