fix: показывать причину сбоя поддержки в development

Создание топика падало с «not enough rights to create a topic» (бот был
обычным участником группы, а не администратором), но пользователь видел
только «Поддержка временно недоступна. Попробуй позже» — по такому сообщению
невозможно понять, что чинить.

- ensure_topic возвращает (thread_id, причина) вместо одного thread_id.
- Текст Telegram логируется на ERROR: по типу исключения причина нечитаема.
- В DEPLOY_ENV=development причина добавляется в сообщение пользователю;
  в production остаётся общая заглушка. HTML экранируется.

Проверено: 214 тестов OK (7 новых на причину и её выдачу).
Живая проверка на dev: после выдачи боту прав администратора с «Управление
темами» create_forum_topic создал топик (thread_id 18, пробный удалён).

VERSION: 0.1.0+52 -> 0.1.0+53
This commit is contained in:
hermes committed 2026-10-11 17:04:33 +02:00
1 parent 21db563f28
commit 4076765618
5 files changed
+107 -18

No files matched your search

+2 -2
View File
@@ -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** → **В работе** → **Готово**.
+5 -2
View File
@@ -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 — только общую заглушку.
### Основные сценарии
+1 -1
View File
@@ -1 +1 @@
0.1.0+52
0.1.0+53
+43 -11
View File
@@ -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-диагностика: <code>{html.escape(reason)}</code>"
)
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()
+56 -2
View File
@@ -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("<b>x</b> & y")
self.assertNotIn("<b>x</b>", text)
self.assertIn("&lt;b&gt;x&lt;/b&gt;", 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()