diff --git a/IDEA.MD b/IDEA.MD index ec9f45a..e5c86e8 100644 --- a/IDEA.MD +++ b/IDEA.MD @@ -153,8 +153,8 @@ Telegram-бот, который по запросу пользователя г - **Завершение**: «🏠 Главное меню» снимает `is_active`, топик не удаляется и не закрывается. - Если топик удалили вручную, при следующем сообщении бот создаёт его заново. -**Требования к группе:** `is_forum = true`, бот — участник с правом управления темами -(`create_forum_topic`) и отправки сообщений. +**Требования к группе:** `is_forum = true`, бот — **администратор** с правом управления темами +(`create_forum_topic`). Обычного участника недостаточно — будет «not enough rights to create a topic». ## План работ (проект Gitea) Колонки: **Backlog** → **В работе** → **Готово**. diff --git a/README.md b/README.md index 2475a18..25c98de 100644 --- a/README.md +++ b/README.md @@ -103,8 +103,11 @@ onboarding. В «Подписке» сразу отображаются тип `SUPPORT_NOTE_PREFIX` (default `//`), `SUPPORT_TOPIC_EMOJI_ID` (optional custom emoji id), `SUPPORT_GREETINGS` (optional; пул приветствий оператора, разделитель `|`). -> Группа должна быть **супергруппой с включёнными темами** (`is_forum`), бот — её участником. -> Права нужны: управление темами (`create_forum_topic`) и отправка сообщений. +> Группа должна быть **супергруппой с включёнными темами** (`is_forum`), бот — её **администратором** +> с правом «Управление темами» (`create_forum_topic`). Обычного участника недостаточно: Telegram +> вернёт «not enough rights to create a topic», и топик не создастся. +> В `DEPLOY_ENV=development` бот покажет эту причину прямо в сообщении об ошибке, +> в production — только общую заглушку. ### Основные сценарии diff --git a/VERSION b/VERSION index 1d836d0..aecb41d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+52 \ No newline at end of file +0.1.0+53 \ No newline at end of file diff --git a/support.py b/support.py index 5657085..1186f1a 100644 --- a/support.py +++ b/support.py @@ -73,6 +73,17 @@ def is_enabled() -> bool: return support_group_id() is not None +def _exc_detail(exc: Exception, limit: int = 240) -> str: + """Текст ошибки без секретов; Telegram кладёт причину именно в текст.""" + detail = str(exc).strip() or "нет подробностей" + return detail[:limit] + + +def is_development_environment() -> bool: + """Dev-поведение включается только точным значением DEPLOY_ENV=development.""" + return os.getenv("DEPLOY_ENV", "production") == "development" + + def _is_support_chat(chat: Any) -> bool: group_id = support_group_id() return group_id is not None and int(chat.id) == group_id @@ -255,14 +266,18 @@ async def send_profile_card(bot: Bot, user: User, thread_id: int) -> None: logger.warning("Support profile card send failed (%s)", type(exc).__name__) -async def ensure_topic(bot: Bot, user: User) -> int | None: - """Возвращает thread_id персонального топика, создавая его при необходимости.""" +async def ensure_topic(bot: Bot, user: User) -> tuple[int | None, str | None]: + """Возвращает (thread_id, причина_ошибки), создавая топик при необходимости. + + Причина возвращается, а не только логируется: в development она показывается + пользователю, иначе «бот не админ группы» выглядит как «попробуй позже». + """ group_id = support_group_id() if group_id is None: - return None + return None, None existing = await asyncio.to_thread(get_topic, user.id) if existing is not None: - return existing + return existing, None emoji_id = topic_icon_emoji_id() try: kwargs: dict[str, Any] = {"chat_id": group_id, "name": topic_title(user)} @@ -270,12 +285,15 @@ async def ensure_topic(bot: Bot, user: User) -> int | None: kwargs["icon_custom_emoji_id"] = emoji_id topic = await bot.create_forum_topic(**kwargs) except Exception as exc: - logger.error("Support topic creation failed (%s)", type(exc).__name__) - return None + # Текст Telegram (например «not enough rights to create a topic») — единственный + # способ понять настоящую причину: по типу исключения это нечитаемо. + detail = _exc_detail(exc) + logger.error("Support topic creation failed (%s): %s", type(exc).__name__, detail) + return None, detail thread_id = int(topic.message_thread_id) await asyncio.to_thread(set_topic, user.id, thread_id) await send_profile_card(bot, user, thread_id) - return thread_id + return thread_id, None async def deactivate(user_id: int) -> None: @@ -286,6 +304,20 @@ async def deactivate(user_id: int) -> None: # Пользователь → топик # --------------------------------------------------------------------------- # +def _unavailable_text(reason: str | None = None) -> str: + """Заглушка «поддержка недоступна»; в development добавляет реальную причину. + + Иначе типичная поломка (бот не админ группы → «not enough rights to create + a topic») выглядит как «попробуй позже» и не подсказывает, что чинить. + """ + if not is_development_environment() or not reason: + return SUPPORT_UNAVAILABLE_TEXT + return ( + f"{SUPPORT_UNAVAILABLE_TEXT}\n\n" + f"🧪 Dev-диагностика: {html.escape(reason)}" + ) + + async def relay_user_message(message: Message) -> bool: """Копирует сообщение пользователя в его топик. True — сообщение ушло.""" bot = message.bot @@ -293,10 +325,10 @@ async def relay_user_message(message: Message) -> bool: group_id = support_group_id() if group_id is None or bot is None or user is None: return False - thread_id = await ensure_topic(bot, user) + thread_id, failure = await ensure_topic(bot, user) if thread_id is None: try: - await message.answer(SUPPORT_UNAVAILABLE_TEXT) + await message.answer(_unavailable_text(failure), parse_mode="HTML") except Exception: logger.debug("Support unavailable notice failed") return False @@ -335,9 +367,9 @@ async def start_support(message: Message) -> None: if not is_enabled(): await message.answer(SUPPORT_UNAVAILABLE_TEXT) return - thread_id = await ensure_topic(bot, user) + thread_id, failure = await ensure_topic(bot, user) if thread_id is None: - await message.answer(SUPPORT_UNAVAILABLE_TEXT) + await message.answer(_unavailable_text(failure), parse_mode="HTML") return await asyncio.to_thread(set_active, user.id, True) greeting = support_greeting() diff --git a/tests/test_support.py b/tests/test_support.py index b160aeb..776504f 100644 --- a/tests/test_support.py +++ b/tests/test_support.py @@ -249,10 +249,64 @@ class SupportTopicTests(_TempDbCase): first = asyncio.run(support.ensure_topic(bot, user)) second = asyncio.run(support.ensure_topic(bot, user)) - self.assertEqual(first, 777) - self.assertEqual(second, 777) + self.assertEqual(first[0], 777) + self.assertIsNone(first[1]) + self.assertEqual(second[0], 777) bot.create_forum_topic.assert_awaited_once() + def test_topic_creation_failure_returns_reason(self): + with patch.dict("os.environ", {"SUPPORT_GROUP_ID": "-100123"}, clear=False): + bot = AsyncMock() + bot.create_forum_topic = AsyncMock( + side_effect=RuntimeError("Bad Request: not enough rights to create a topic") + ) + thread_id, reason = asyncio.run(support.ensure_topic(bot, self._tg_user())) + + self.assertIsNone(thread_id) + self.assertIn("not enough rights", reason) + + def test_topic_creation_failure_logs_telegram_reason(self): + with patch.dict("os.environ", {"SUPPORT_GROUP_ID": "-100123"}, clear=False): + bot = AsyncMock() + bot.create_forum_topic = AsyncMock( + side_effect=RuntimeError("Bad Request: not enough rights to create a topic") + ) + with self.assertLogs("support", level="ERROR") as captured: + asyncio.run(support.ensure_topic(bot, self._tg_user())) + + self.assertIn("not enough rights", "\n".join(captured.output)) + + def test_unavailable_text_hides_reason_outside_development(self): + with patch.dict("os.environ", {"DEPLOY_ENV": "production"}, clear=False): + text = support._unavailable_text("not enough rights to create a topic") + self.assertNotIn("not enough rights", text) + self.assertIn("временно недоступна", text) + + def test_unavailable_text_shows_reason_in_development(self): + with patch.dict("os.environ", {"DEPLOY_ENV": "development"}, clear=False): + text = support._unavailable_text("not enough rights to create a topic") + self.assertIn("not enough rights", text) + self.assertIn("Dev-диагностика", text) + + def test_unavailable_text_without_reason_is_plain(self): + with patch.dict("os.environ", {"DEPLOY_ENV": "development"}, clear=False): + text = support._unavailable_text(None) + self.assertEqual(text, support.SUPPORT_UNAVAILABLE_TEXT) + + def test_unavailable_text_escapes_html(self): + with patch.dict("os.environ", {"DEPLOY_ENV": "development"}, clear=False): + text = support._unavailable_text("x & y") + self.assertNotIn("x", text) + self.assertIn("<b>x</b>", text) + + def test_topic_creation_is_skipped_when_disabled(self): + with patch.dict("os.environ", {"SUPPORT_GROUP_ID": ""}, clear=False): + bot = AsyncMock() + thread_id, reason = asyncio.run(support.ensure_topic(bot, self._tg_user())) + self.assertIsNone(thread_id) + self.assertIsNone(reason) + bot.create_forum_topic.assert_not_awaited() + def test_profile_card_is_sent_only_on_topic_creation(self): with patch.dict("os.environ", {"SUPPORT_GROUP_ID": "-100123"}, clear=False): bot = AsyncMock()