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()