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:
hermes committed 2026-10-11 18:34:14 +02:00
1 parent 562442b979
commit 12ef2c6b44
5 files changed
+114 -13

No files matched your search

+3 -1
View File
@@ -150,7 +150,9 @@ Telegram-бот, который по запросу пользователя г
Пул переопределяется переменной `SUPPORT_GREETINGS` (разделитель `|`) без деплоя.
- **Заметки оператора**: сообщение с префиксом `//` (`SUPPORT_NOTE_PREFIX`) остаётся в топике и
пользователю не отправляется.
- **Завершение**: «🏠 Главное меню» снимает `is_active`, топик не удаляется и не закрывается.
- **Завершение**: «🏠 Главное меню» **или `/start`** снимают `is_active`, топик не удаляется.
Обе точки возврата в лобби должны деактивировать обращение: иначе FSM сброшен, а в БД
обращение открыто — следующее сообщение уходит оператору, а вход в режим блокируется.
- Если топик удалили вручную, при следующем сообщении бот создаёт его заново.
**Требования к группе:** `is_forum = true`, бот — **администратор** с правом управления темами
+3 -1
View File
@@ -103,7 +103,9 @@ onboarding. В «Подписке» сразу отображаются тип
Пул можно переопределить без деплоя: `SUPPORT_GREETINGS` в Coolify, разделитель `|`.
- Ответ оператора — любое сообщение в топике от живого участника. Сообщение с префиксом `//`
(`SUPPORT_NOTE_PREFIX`) остаётся заметкой внутри топика и пользователю не уходит.
- Завершение — кнопка «🏠 Главное меню»: обращение деактивируется, топик **не удаляется**.
- Завершение — кнопка «🏠 Главное меню» **или `/start`**: обращение деактивируется, топик
**не удаляется**. Обе точки возврата в лобби обязаны снимать `is_active`, иначе FSM уже
сброшен, а в БД обращение открыто — и следующее сообщение всё равно уйдёт оператору.
Повторное «Поддержка» продолжает переписку в том же топике с сохранённой историей.
- Пока обращение активно, клавиатура содержит только «Главное меню», а переключение в другие
режимы отклоняется — чтобы режимы и генерация не смешивались с обращением.
+1 -1
View File
@@ -1 +1 @@
0.1.0+54
0.1.0+55
+12 -10
View File
@@ -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)
+95
View File
@@ -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)