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:
1 parent
21db563f28
commit
4076765618
5 files changed
+107
-18
No files matched your search
@@ -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** → **В работе** → **Готово**.
|
||||
|
||||
@@ -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 — только общую заглушку.
|
||||
|
||||
### Основные сценарии
|
||||
|
||||
|
||||
+43
-11
@@ -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
@@ -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("<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()
|
||||
|
||||
Reference in new issue
Block a user