fix: развести приватные и групповые чаты по роутерам
Сообщение оператора в топике поддержки не доходило до пользователя: бот отвечал прямо в группу «Выбери режим работы кнопкой ниже». Причина — порядок регистрации роутеров: основной router с catch-all @router.message() (без фильтра по чату) включался ПЕРВЫМ, а support.router — вторым. У aiogram побеждает первый подходящий, поэтому до поддержки очередь не доходила. Комментарий в main() утверждал обратное. - support.router регистрируется до основного router. - Основной роутер отфильтрован на F.chat.type == "private" (роль «работник»); групповые сценарии обслуживаются своими роутерами. Это первый шаг к ролям: работник / помощник поддержки / аналитик / почтальон. - Роутер поддержки дополнительно отсекает сообщения от ботов. Вторая поломка того же корня: при активном обращении «📖 Помощь» и «🎓 Обучение» уезжали оператору как обычный текст, режим не переключался и бот в личке молчал — пользователь был заперт в обращении. Middleware теперь пропускает все команды и все кнопки reply-клавиатуры через _is_bot_control(), а не только «Поддержка» и «Главное меню». Тесты: 224 OK (10 новых). Регрессия проверена: на старой логике «Помощь» уходит оператору (relay=1), на новой обрабатывается ботом (relay=0). Заглушки заменены на настоящие Message — middleware проверяет isinstance(event, Message), поэтому SimpleNamespace проходил вхолостую. is_support_active вызывается через to_thread — AsyncMock-заглушка возвращала truthy-корутину и делала проверку фиктивной. VERSION: 0.1.0+53 -> 0.1.0+54
This commit is contained in:
1 parent
4076765618
commit
562442b979
6 files changed
+160
-6
No files matched your search
@@ -156,6 +156,16 @@ Telegram-бот, который по запросу пользователя г
|
||||
**Требования к группе:** `is_forum = true`, бот — **администратор** с правом управления темами
|
||||
(`create_forum_topic`). Обычного участника недостаточно — будет «not enough rights to create a topic».
|
||||
|
||||
**Разделение чатов по ролям.** Основной роутер `bot.py` отфильтрован на `F.chat.type == "private"` —
|
||||
это роль «работник». Групповые сценарии регистрируются в диспетчере **раньше** основного роутера:
|
||||
у aiogram побеждает первый подходящий, поэтому catch-all `@router.message()` иначе перехватывает
|
||||
апдейты из групп и отвечает в группу «Выбери режим работы кнопкой ниже» вместо пересылки оператору.
|
||||
Порядок `dp.include_router(...)` критичен и зафиксирован комментарием в `main()`.
|
||||
|
||||
Роли бота: **работник** (личка, основной сценарий), **помощник поддержки** (группа поддержки),
|
||||
**аналитик** (сбор аналитики, позже), **почтальон** (рассылки, позже). Назначение чата на роль —
|
||||
переменные окружения (добавление группы = деплой, это допустимо).
|
||||
|
||||
## План работ (проект Gitea)
|
||||
Колонки: **Backlog** → **В работе** → **Готово**.
|
||||
|
||||
|
||||
@@ -86,7 +86,16 @@ onboarding. В «Подписке» сразу отображаются тип
|
||||
- **Сводка профиля в карточке** (`user_store.support_profile_summary`): ID пользователя;
|
||||
сколько дней пользуется ботом; сколько раз продлевалась подписка; сколько дней осталось;
|
||||
обучен бот или нет.
|
||||
- **Первое сообщение — приветствие оператора.** Бот не объясняет, что «передаёт» сообщение:
|
||||
- **Разделение чатов по ролям.** Основной роутер (`bot.py`) отфильтрован на
|
||||
`F.chat.type == "private"` — это роль «работник». Групповые сценарии (роль
|
||||
«помощник поддержки») живут в `support.py` и регистрируются в диспетчере
|
||||
**раньше** основного роутера: иначе catch-all `@router.message()` перехватывает
|
||||
апдейты из групп и отвечает в группу «Выбери режим работы кнопкой ниже»
|
||||
вместо пересылки сообщения оператору.
|
||||
Порядок включения роутеров критичен — у aiogram побеждает первый подходящий.
|
||||
Планируемые роли: **работник** (личка), **помощник поддержки** (группа),
|
||||
**аналитик** (сбор аналитики), **почтальон** (рассылки).
|
||||
- Первое сообщение — приветствие оператора. Бот не объясняет, что «передаёт» сообщение:
|
||||
это разрушает ощущение живого диалога. Пользователь видит случайную фразу из пула
|
||||
`SUPPORT_GREETINGS` (`activity_phrases.support_greeting()`), например
|
||||
«Здравствуйте! На связи поддержка. Расскажите, что случилось — разберёмся.»
|
||||
@@ -98,6 +107,10 @@ onboarding. В «Подписке» сразу отображаются тип
|
||||
Повторное «Поддержка» продолжает переписку в том же топике с сохранённой историей.
|
||||
- Пока обращение активно, клавиатура содержит только «Главное меню», а переключение в другие
|
||||
режимы отклоняется — чтобы режимы и генерация не смешивались с обращением.
|
||||
- **Команды и кнопки бота работают всегда**, даже при активном обращении:
|
||||
`SupportRelayMiddleware` пропускает их через `_is_bot_control()` и не отправляет
|
||||
оператору. Иначе «📖 Помощь» и «🎓 Обучение» уезжали оператору как обычный текст,
|
||||
режим не переключался, и бот в личке молчал.
|
||||
|
||||
**Переменные Coolify:** `SUPPORT_GROUP_ID` (обязательно, без неё функция выключена),
|
||||
`SUPPORT_NOTE_PREFIX` (default `//`), `SUPPORT_TOPIC_EMOJI_ID` (optional custom emoji id),
|
||||
|
||||
@@ -88,6 +88,12 @@ DEPLOY_ENV = os.getenv("DEPLOY_ENV", "production")
|
||||
_deploy_notice_lock = asyncio.Lock()
|
||||
|
||||
router = Router()
|
||||
# Роль «работник»: основной сценарий работает только в личке бота.
|
||||
# Групповые чаты (поддержка и будущие роли) обслуживаются своими роутерами —
|
||||
# иначе catch-all @router.message() перехватывает апдейты из групп и отвечает
|
||||
# в группу «Выбери режим работы кнопкой ниже» вместо работы оператора.
|
||||
router.message.filter(F.chat.type == "private")
|
||||
router.callback_query.filter(F.message.chat.type == "private")
|
||||
MAX_AUDIO_BYTES = 20 * 1024 * 1024
|
||||
MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
||||
TYPING_REFRESH_SECONDS = 4
|
||||
@@ -1498,9 +1504,10 @@ async def main() -> None:
|
||||
dp = Dispatcher(storage=MemoryStorage())
|
||||
configure_raw_update_logging(dp)
|
||||
dp.include_router(payments.router)
|
||||
dp.include_router(router)
|
||||
# Группа поддержки: роутер раньше основного, чтобы служебные чаты не шли в обычные режимы.
|
||||
# Роли: групповые сценарии регистрируются ДО основного роутера, иначе
|
||||
# catch-all @router.message() основного роутера перехватывает их апдейты.
|
||||
dp.include_router(support.router)
|
||||
dp.include_router(router)
|
||||
dp.message.outer_middleware(support.SupportRelayMiddleware())
|
||||
# Ставим меню один раз при старте, чтобы оно было у всех, кто писал боту.
|
||||
await set_main_menu(bot)
|
||||
|
||||
+39
-2
@@ -389,7 +389,11 @@ def _is_service_message(message: Message) -> bool:
|
||||
return bool(prefix) and text.startswith(prefix)
|
||||
|
||||
|
||||
@router.message(F.chat.func(_is_support_chat), F.is_topic_message.as_("topic"))
|
||||
@router.message(
|
||||
F.chat.func(_is_support_chat),
|
||||
F.is_topic_message.as_("topic"),
|
||||
F.from_user.func(lambda user: user is not None and not user.is_bot),
|
||||
)
|
||||
async def handle_support_group_message(message: Message) -> None:
|
||||
"""Все сообщения из топиков группы поддержки, кроме служебных, уходят пользователю."""
|
||||
group_id = support_group_id()
|
||||
@@ -429,6 +433,39 @@ async def handle_support_group_message(message: Message) -> None:
|
||||
# Middleware: пользователь → топик
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
# Команды бота, которые должны работать всегда — даже при активном обращении.
|
||||
_ESCAPE_COMMANDS: frozenset[str] = frozenset({
|
||||
"/start",
|
||||
"/help",
|
||||
"/training",
|
||||
"/generate",
|
||||
"/subscription",
|
||||
"/balance",
|
||||
"/style",
|
||||
"/clear",
|
||||
"/post",
|
||||
"/idea",
|
||||
"/subscribe",
|
||||
"/support",
|
||||
})
|
||||
|
||||
|
||||
def _is_bot_control(message: Message) -> bool:
|
||||
"""True, если сообщение — управление ботом, а не текст для оператора.
|
||||
|
||||
Иначе пользователь заперт в обращении: «📖 Помощь» и «🎓 Обучение» уходят
|
||||
оператору как обычный текст, режим не переключается, бот в личке молчит.
|
||||
"""
|
||||
text = (message.text or "").strip()
|
||||
if not text:
|
||||
return False
|
||||
if text.startswith("/"):
|
||||
command = text.split(maxsplit=1)[0].split("@", 1)[0].casefold()
|
||||
return command in _ESCAPE_COMMANDS
|
||||
# Любая кнопка reply-клавиатуры — это управление ботом.
|
||||
return resolve_button_text(text) is not None
|
||||
|
||||
|
||||
def _is_support_button(message: Message) -> bool:
|
||||
if not message.text:
|
||||
return False
|
||||
@@ -453,7 +490,7 @@ class SupportRelayMiddleware(BaseMiddleware):
|
||||
return await handler(event, data)
|
||||
if _is_support_chat(event.chat):
|
||||
return await handler(event, data)
|
||||
if _is_support_button(event) or _is_main_menu_button(event):
|
||||
if _is_bot_control(event):
|
||||
return await handler(event, data)
|
||||
try:
|
||||
active = await asyncio.to_thread(is_support_active, user.id)
|
||||
|
||||
@@ -13,6 +13,8 @@ import activity_phrases
|
||||
import support
|
||||
import user_store
|
||||
|
||||
from aiogram.types import Chat, Message, User
|
||||
|
||||
|
||||
class _TempDbCase(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
@@ -220,6 +222,91 @@ class SupportGreetingTests(unittest.TestCase):
|
||||
self.assertIn(support.support_greeting(), activity_phrases.SUPPORT_GREETINGS)
|
||||
|
||||
|
||||
class SupportBotControlTests(unittest.IsolatedAsyncioTestCase):
|
||||
"""При активном обращении кнопки и команды бота не должны уходить оператору."""
|
||||
|
||||
def _msg(self, text, chat_type="private", chat_id=555, user_id=42, is_bot=False):
|
||||
# Нужен настоящий Message: middleware проверяет isinstance(event, Message),
|
||||
# иначе все проверки проходят вхолостую.
|
||||
return Message(
|
||||
message_id=1,
|
||||
date=datetime.datetime.now(),
|
||||
chat=Chat(id=chat_id, type=chat_type),
|
||||
from_user=User(id=user_id, is_bot=is_bot, first_name="Test"),
|
||||
text=text,
|
||||
)
|
||||
|
||||
def test_mode_buttons_are_bot_control(self):
|
||||
for text in ("📖 Помощь", "🎓 Обучение", "✍️ Генерация", "💳 Подписка"):
|
||||
self.assertTrue(support._is_bot_control(self._msg(text)), text)
|
||||
|
||||
def test_main_menu_and_support_buttons_are_bot_control(self):
|
||||
self.assertTrue(support._is_bot_control(self._msg("🏠 Главное меню")))
|
||||
self.assertTrue(support._is_bot_control(self._msg("🆘 Поддержка")))
|
||||
self.assertTrue(support._is_bot_control(self._msg("🗑 Сбросить данные")))
|
||||
self.assertTrue(support._is_bot_control(self._msg("✍️ Придумать пост")))
|
||||
|
||||
def test_commands_are_bot_control(self):
|
||||
for text in ("/start", "/help", "/training", "/generate", "/post", "/idea", "/balance"):
|
||||
self.assertTrue(support._is_bot_control(self._msg(text)), text)
|
||||
|
||||
def test_plain_text_is_not_bot_control(self):
|
||||
for text in ("не работает оплата", "ау", "помогите", "почему списали деньги"):
|
||||
self.assertFalse(support._is_bot_control(self._msg(text)), text)
|
||||
|
||||
def test_unknown_slash_command_is_not_bot_control(self):
|
||||
self.assertFalse(support._is_bot_control(self._msg("/что-то-неизвестное")))
|
||||
|
||||
def test_empty_text_is_not_bot_control(self):
|
||||
self.assertFalse(support._is_bot_control(self._msg("")))
|
||||
self.assertFalse(support._is_bot_control(self._msg(None)))
|
||||
|
||||
def test_middleware_passes_mode_buttons_to_the_bot(self):
|
||||
# Регрессия: «Помощь»/«Обучение» уезжали оператору, бот в личке молчал.
|
||||
with patch.dict("os.environ", {"SUPPORT_GROUP_ID": "-100123"}, clear=False):
|
||||
for text in ("📖 Помощь", "🎓 Обучение", "🏠 Главное меню", "🆘 Поддержка"):
|
||||
event = self._msg(text)
|
||||
# is_support_active вызывается через asyncio.to_thread — нужна
|
||||
# синхронная заглушка: AsyncMock вернул бы корутину (всегда truthy).
|
||||
with patch.object(support, "is_support_active", new=lambda uid, db_path=None: True):
|
||||
with patch.object(support, "relay_user_message", new=AsyncMock()) as relay:
|
||||
handled = asyncio.run(self._call_middleware(event))
|
||||
self.assertEqual(handled, "handled", text)
|
||||
relay.assert_not_awaited()
|
||||
|
||||
def test_middleware_relays_plain_text_to_operator(self):
|
||||
with patch.dict("os.environ", {"SUPPORT_GROUP_ID": "-100123"}, clear=False):
|
||||
event = self._msg("не работает оплата")
|
||||
with patch.object(support, "is_support_active", new=lambda uid, db_path=None: True):
|
||||
with patch.object(support, "relay_user_message", new=AsyncMock()) as relay:
|
||||
handled = asyncio.run(self._call_middleware(event))
|
||||
self.assertIsNone(handled)
|
||||
relay.assert_awaited_once_with(event)
|
||||
|
||||
def test_middleware_passes_text_to_bot_when_no_active_case(self):
|
||||
with patch.dict("os.environ", {"SUPPORT_GROUP_ID": "-100123"}, clear=False):
|
||||
event = self._msg("напиши пост про запуск продукта")
|
||||
with patch.object(support, "is_support_active", new=lambda uid, db_path=None: False):
|
||||
with patch.object(support, "relay_user_message", new=AsyncMock()) as relay:
|
||||
handled = asyncio.run(self._call_middleware(event))
|
||||
self.assertEqual(handled, "handled")
|
||||
relay.assert_not_awaited()
|
||||
|
||||
def test_middleware_ignores_group_chats(self):
|
||||
with patch.dict("os.environ", {"SUPPORT_GROUP_ID": "-100123"}, clear=False):
|
||||
event = self._msg("текст", chat_type="supergroup", chat_id=-100123)
|
||||
with patch.object(support, "relay_user_message", new=AsyncMock()) as relay:
|
||||
asyncio.run(self._call_middleware(event))
|
||||
relay.assert_not_awaited()
|
||||
|
||||
async def _call_middleware(self, event):
|
||||
async def handler(_event, _data):
|
||||
return "handled"
|
||||
|
||||
middleware = support.SupportRelayMiddleware()
|
||||
return await middleware(handler, event, {})
|
||||
|
||||
|
||||
class SupportStartTextTests(unittest.TestCase):
|
||||
def test_start_text_embeds_greeting(self):
|
||||
rendered = support.SUPPORT_START_TEXT.format(greeting="Привет!")
|
||||
|
||||
Reference in new issue
Block a user