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:
hermes committed 2026-10-11 18:16:11 +02:00
1 parent 4076765618
commit 562442b979
6 files changed
+160 -6

No files matched your search

+10
View File
@@ -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** → **В работе** → **Готово**.
+14 -1
View File
@@ -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),
+1 -1
View File
@@ -1 +1 @@
0.1.0+53
0.1.0+54
+9 -2
View File
@@ -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
View File
@@ -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)
+87
View File
@@ -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="Привет!")