From 562442b97940172a41a863615098120da1a1f8c0 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 11 Oct 2026 18:16:11 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20=D1=80=D0=B0=D0=B7=D0=B2=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B8=20=D0=BF=D1=80=D0=B8=D0=B2=D0=B0=D1=82=D0=BD=D1=8B?= =?UTF-8?q?=D0=B5=20=D0=B8=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D0=BE=D0=B2?= =?UTF-8?q?=D1=8B=D0=B5=20=D1=87=D0=B0=D1=82=D1=8B=20=D0=BF=D0=BE=20=D1=80?= =?UTF-8?q?=D0=BE=D1=83=D1=82=D0=B5=D1=80=D0=B0=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сообщение оператора в топике поддержки не доходило до пользователя: бот отвечал прямо в группу «Выбери режим работы кнопкой ниже». Причина — порядок регистрации роутеров: основной 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 --- IDEA.MD | 10 +++++ README.md | 15 +++++++- VERSION | 2 +- bot.py | 11 +++++- support.py | 41 +++++++++++++++++++- tests/test_support.py | 87 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 160 insertions(+), 6 deletions(-) diff --git a/IDEA.MD b/IDEA.MD index e5c86e8..02615b9 100644 --- a/IDEA.MD +++ b/IDEA.MD @@ -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** → **В работе** → **Готово**. diff --git a/README.md b/README.md index 25c98de..1dc7858 100644 --- a/README.md +++ b/README.md @@ -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), diff --git a/VERSION b/VERSION index aecb41d..cf58a4c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+53 \ No newline at end of file +0.1.0+54 \ No newline at end of file diff --git a/bot.py b/bot.py index 025fd33..586bf09 100644 --- a/bot.py +++ b/bot.py @@ -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) diff --git a/support.py b/support.py index 1186f1a..7771460 100644 --- a/support.py +++ b/support.py @@ -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) diff --git a/tests/test_support.py b/tests/test_support.py index 776504f..c35a07a 100644 --- a/tests/test_support.py +++ b/tests/test_support.py @@ -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="Привет!")