{"repo":"NousResearch/hermes-agent","public_id":"2598","display_label":"Telegram private chat topics feature development","summary":"These members all concern adding support for Telegram's Private Chat Topics feature (Bot API 9.4), including skill binding and project-based conversation organization.","subsystem":"gateway","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":3163,"merged_at":"2026-03-26T09:04:11+00:00"},"resolution":{"state":"resolved","by":"pr-3163"},"freshness":{"state":"stale","last_verified_at":"2026-03-26T09:04:11+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":2598,"pr_number":3163,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-03-26T09:04:11+00:00"}],"members":[{"kind":"issue","number":2598,"title":"Feature: Telegram Private Chat Topics support for project-based conversations","url":null,"state":"closed"},{"kind":"pr","number":3005,"title":"feat(telegram): Private Chat Topics support (Bot API 9.4)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#3163","number":3163}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":3163,"title":"feat(telegram): Private Chat Topics with functional skill binding (#2598)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/base.py","gateway/platforms/telegram.py","gateway/run.py","tests/gateway/test_dm_topics.py","website/docs/user-guide/messaging/telegram.md"],"fix_hunks":[{"file":"gateway/platforms/base.py","hunks":["@@ -296,6 +296,9 @@ class MessageEvent:\n     reply_to_message_id: Optional[str] = None\n     reply_to_text: Optional[str] = None  # Text of the replied-to message (for context injection)\n     \n+    # Auto-loaded skill for topic/channel bindings (e.g., Telegram DM Topics)\n+    auto_skill: Optional[str] = None\n+    \n     # Timestamps\n     timestamp: datetime = field(default_factory=datetime.now)\n     "]},{"file":"gateway/platforms/telegram.py","hunks":["@@ -133,6 +133,10 @@ def __init__(self, config: PlatformConfig):\n         self._polling_conflict_count: int = 0\n         self._polling_network_error_count: int = 0\n         self._polling_error_callback_ref = None\n+        # DM Topics: map of topic_name -> message_thread_id (populated at startup)\n+        self._dm_topics: Dict[str, int] = {}\n+        # DM Topics config from extra.dm_topics\n+        self._dm_topics_config: List[Dict[str, Any]] = self.config.extra.get(\"dm_topics\", [])\n \n     @staticmethod\n     def _looks_like_polling_conflict(error: Exception) -> bool:","@@ -273,6 +277,162 @@ async def _handle_polling_conflict(self, error: Exception) -> None:\n             logger.warning(\"[%s] Failed stopping Telegram polling after conflict: %s\", self.name, stop_error, exc_info=True)\n         await self._notify_fatal_error()\n \n+    async def _create_dm_topic(\n+        self,\n+        chat_id: int,\n+        name: str,\n+        icon_color: Optional[int] = None,\n+        icon_custom_emoji_id: Optional[str] = None,\n+    ) -> Optional[int]:\n+        \"\"\"Create a forum topic in a private (DM) chat.\n+\n+        Uses Bot API 9.4's createForumTopic which now works for 1-on-1 chats.\n+        Returns the message_thread_id on success, None on failure.\n+        \"\"\"\n+        if not self._bot:\n+            return None\n+        try:\n+            kwargs: Dict[str, Any] = {\"chat_id\": chat_id, \"name\": name}\n+            if icon_color is not None:\n+                kwargs[\"icon_color\"] = icon_color\n+            if icon_custom_emoji_id:\n+                kwargs[\"icon_custom_emoji_id\"] = icon_custom_emoji_id\n+\n+            topic = await self._bot.create_forum_topic(**kwargs)\n+            thread_id = topic.message_thread_id\n+            logger.info(\n+                \"[%s] Created DM topic '%s' in chat %s -> thread_id=%s\",\n+                self.name, name, chat_id, thread_id,\n+            )\n+            return thread_id\n+        except Exception as e:\n+            error_text = str(e).lower()\n+            # If topic already exists, try to find it via getForumTopicIconStickers\n+            # or we just log and skip — Telegram doesn't provide a \"list topics\" API\n+            if \"topic_name_duplicate\" in error_text or \"already\" in error_text:\n+                logger.info(\n+                    \"[%s] DM topic '%s' already exists in chat %s (will be mapped from incoming messages)\",\n+                    self.name, name, chat_id,\n+                )\n+            else:\n+                logger.warning(\n+                    \"[%s] Failed to create DM topic '%s' in chat %s: %s\",\n+                    self.name, name, chat_id, e,\n+                )\n+            return None\n+\n+    def _persist_dm_topic_thread_id(self, chat_id: int, topic_name: str, thread_id: int) -> None:\n+        \"\"\"Save a newly created thread_id back into config.yaml so it persists across restarts.\"\"\"\n+        try:\n+            config_path = _Path.home() / \".hermes\" / \"config.yaml\"\n+            if not config_path.exists():\n+                logger.warning(\"[%s] Config file not found at %s, cannot persist thread_id\", self.name, config_path)\n+                return\n+\n+            import yaml as _yaml\n+            with open(config_path, \"r\") as f:\n+                config = _yaml.safe_load(f) or {}\n+\n+            # Navigate to platforms.telegram.extra.dm_topics\n+            dm_topics = (\n+                config.get(\"platforms\", {})\n+                .get(\"telegram\", {})\n+                .get(\"extra\", {})\n+                .get(\"dm_topics\", [])\n+            )\n+            if not dm_topics:\n+                return\n+\n+            changed = False\n+            for chat_entry in dm_topics:\n+                if int(chat_entry.get(\"chat_id\", 0)) != int(chat_id):\n+                    continue\n+                for t in chat_entry.get(\"topics\", []):\n+                    if t.get(\"name\") == topic_name and not t.get(\"thread_id\"):\n+                        t[\"thread_id\"] = thread_id\n+                        changed = True\n+                        break\n+\n+            if changed:\n+                with open(config_path, \"w\") as f:\n+                    _yaml.dump(config, f, default_flow_style=False, sort_keys=False)\n+                logger.info(\n+                    \"[%s] Persisted thread_id=%s for topic '%s' in config.yaml\",\n+                    self.name, thread_id, topic_name,\n+                )\n+        except Exception as e:\n+            logger.warning(\"[%s] Failed to persist thread_id to config: %s\", self.name, e, exc_info=True)\n+\n+    async def _setup_dm_topics(self) -> None:\n+        \"\"\"Load or create configured DM topics for specified chats.\n+\n+        Reads config.extra['dm_topics'] — a list of dicts:\n+        [\n+            {\n+                \"chat_id\": 123456789,\n+                \"topics\": [\n+                    {\"name\": \"General\", \"icon_color\": 7322096, \"thread_id\": 100},\n+                    {\"name\": \"Accessibility Auditor\", \"icon_color\": 9367192, \"skill\": \"accessibility-auditor\"}\n+                ]\n+            }\n+        ]\n+\n+        If a topic already has a thread_id in the config (persisted from a previous\n+        creation), it is loaded into the cache without calling createForumTopic.\n+        Only topics without a thread_id are created via the API, and their thread_id\n+        is then saved back to config.yaml for future restarts.\n+        \"\"\"\n+        if not self._dm_topics_config:\n+            return\n+\n+        for chat_entry in self._dm_topics_config:\n+            chat_id = chat_entry.get(\"chat_id\")\n+            topics = chat_entry.get(\"topics\", [])\n+            if not chat_id or not topics:\n+                continue\n+\n+            logger.info(\n+                \"[%s] Setting up %d DM topic(s) for chat %s\",\n+                self.name, len(topics), chat_id,\n+            )\n+\n+            for topic_conf in topics:\n+                topic_name = topic_conf.get(\"name\")\n+                if not topic_name:\n+                    continue\n+\n+                cache_key = f\"{chat_id}:{topic_name}\"\n+\n+                # If thread_id is already persisted in config, just load into cache\n+                existing_thread_id = topic_conf.get(\"thread_id\")\n+                if existing_thread_id:\n+                    self._dm_topics[cache_key] = int(existing_thread_id)\n+                    logger.info(\n+                        \"[%s] DM topic loaded from config: %s -> thread_id=%s\",\n+                        self.name, cache_key, existing_thread_id,\n+                    )\n+                    continue\n+\n+                # No persisted thread_id — create the topic via API\n+                icon_color = topic_conf.get(\"icon_color\")\n+                icon_emoji = topic_conf.get(\"icon_custom_emoji_id\")\n+\n+                thread_id = await self._create_dm_topic(\n+                    chat_id=int(chat_id),\n+                    name=topic_name,\n+                    icon_color=icon_color,\n+                    icon_custom_emoji_id=icon_emoji,\n+                )\n+\n+                if thread_id:\n+                    self._dm_topics[cache_key] = thread_id\n+                    logger.info(\n+                        \"[%s] DM topic cached: %s -> thread_id=%s\",\n+                        self.name, cache_key, thread_id,\n+                    )\n+                    # Persist thread_id to config so we don't recreate on next restart\n+                    self._persist_dm_topic_thread_id(int(chat_id), topic_name, thread_id)\n+\n     async def connect(self) -> bool:\n         \"\"\"Connect to Telegram and start polling for updates.\"\"\"\n         if not TELEGRAM_AVAILABLE:","@@ -390,6 +550,18 @@ def _polling_error_callback(error: Exception) -> None:\n             \n             self._mark_connected()\n             logger.info(\"[%s] Connected and polling for Telegram updates\", self.name)\n+\n+            # Set up DM topics (Bot API 9.4 — Private Chat Topics)\n+            # Runs after connection is established so the bot can call createForumTopic.\n+            # Failures here are non-fatal — the bot works fine without topics.\n+            try:\n+                await self._setup_dm_topics()\n+            except Exception as topics_err:\n+                logger.warning(\n+                    \"[%s] DM topics setup failed (non-fatal): %s\",\n+                    self.name, topics_err, exc_info=True,\n+                )\n+\n             return True\n             \n         except Exception as e:","@@ -1514,6 +1686,99 @@ async def _handle_sticker(self, msg: Message, event: \"MessageEvent\") -> None:\n                 emoji, set_name,\n             )\n \n+    def _reload_dm_topics_from_config(self) -> None:\n+        \"\"\"Re-read dm_topics from config.yaml and load any new thread_ids into cache.\n+\n+        This allows topics created externally (e.g. by the agent via API) to be\n+        recognized without a gateway restart.\n+        \"\"\"\n+        try:\n+            config_path = _Path.home() / \".hermes\" / \"config.yaml\"\n+            if not config_path.exists():\n+                return\n+\n+            import yaml as _yaml\n+            with open(config_path, \"r\") as f:\n+                config = _yaml.safe_load(f) or {}\n+\n+            dm_topics = (\n+                config.get(\"platforms\", {})\n+                .get(\"telegram\", {})\n+                .get(\"extra\", {})\n+                .get(\"dm_topics\", [])\n+            )\n+            if not dm_topics:\n+                return\n+\n+            # Update in-memory config and cache any new thread_ids\n+            self._dm_topics_config = dm_topics\n+            for chat_entry in dm_topics:\n+                cid = chat_entry.get(\"chat_id\")\n+                if not cid:\n+                    continue\n+                for t in chat_entry.get(\"topics\", []):\n+                    tid = t.get(\"thread_id\")\n+                    name = t.get(\"name\")\n+                    if tid and name:\n+                        cache_key = f\"{cid}:{name}\"\n+                        if cache_key not in self._dm_topics:\n+                            self._dm_topics[cache_key] = int(tid)\n+                            logger.info(\n+                                \"[%s] Hot-loaded DM topic from config: %s -> thread_id=%s\",\n+                                self.name, cache_key, tid,\n+                            )\n+        except Exception as e:\n+            logger.debug(\"[%s] Failed to reload dm_topics from config: %s\", self.name, e)\n+\n+    def _get_dm_topic_info(self, chat_id: str, thread_id: Optional[str]) -> Optional[Dict[str, Any]]:\n+        \"\"\"Look up DM topic config by chat_id and thread_id.\n+\n+        Returns the topic config dict (name, skill, etc.) if this thread_id\n+        matches a known DM topic, or None.\n+        \"\"\"\n+        if not thread_id:\n+            return None\n+\n+        thread_id_int = int(thread_id)\n+\n+        # Check cached topics first (created by us or loaded at startup)\n+        for key, cached_tid in self._dm_topics.items():\n+            if cached_tid == thread_id_int and key.startswith(f\"{chat_id}:\"):\n+                topic_name = key.split(\":\", 1)[1]\n+                # Find the full config for this topic\n+                for chat_entry in self._dm_topics_config:\n+                    if str(chat_entry.get(\"chat_id\")) == chat_id:\n+                        for t in chat_entry.get(\"topics\", []):\n+                            if t.get(\"name\") == topic_name:\n+                                return t\n+                return {\"name\": topic_name}\n+\n+        # Not in cache — hot-reload config in case topics were added externally\n+        self._reload_dm_topics_from_config()\n+\n+        # Check cache again after reload\n+        for key, cached_tid in self._dm_topics.items():\n+            if cached_tid == thread_id_int and key.startswith(f\"{chat_id}:\"):\n+                topic_name = key.split(\":\", 1)[1]\n+                for chat_entry in self._dm_topics_config:\n+                    if str(chat_entry.get(\"chat_id\")) == chat_id:\n+                        for t in chat_entry.get(\"topics\", []):\n+                            if t.get(\"name\") == topic_name:\n+                                return t\n+                return {\"name\": topic_name}\n+\n+        return None\n+\n+    def _cache_dm_topic_from_message(self, chat_id: str, thread_id: str, topic_name: str) -> None:\n+        \"\"\"Cache a thread_id -> topic_name mapping discovered from an incoming message.\"\"\"\n+        cache_key = f\"{chat_id}:{topic_name}\"\n+        if cache_key not in self._dm_topics:\n+            self._dm_topics[cache_key] = int(thread_id)\n+            logger.info(\n+                \"[%s] Cached DM topic from message: %s -> thread_id=%s\",\n+                self.name, cache_key, thread_id,\n+            )\n+\n     def _build_message_event(self, message: Message, msg_type: MessageType) -> MessageEvent:\n         \"\"\"Build a MessageEvent from a Telegram message.\"\"\"\n         chat = message.chat","@@ -1525,7 +1790,27 @@ def _build_message_event(self, message: Message, msg_type: MessageType) -> Messa\n             chat_type = \"group\"\n         elif chat.type == ChatType.CHANNEL:\n             chat_type = \"channel\"\n-        \n+\n+        # Resolve DM topic name and skill binding\n+        thread_id_raw = message.message_thread_id\n+        thread_id_str = str(thread_id_raw) if thread_id_raw else None\n+        chat_topic = None\n+        topic_skill = None\n+\n+        if chat_type == \"dm\" and thread_id_str:\n+            topic_info = self._get_dm_topic_info(str(chat.id), thread_id_str)\n+            if topic_info:\n+                chat_topic = topic_info.get(\"name\")\n+                topic_skill = topic_info.get(\"skill\")\n+\n+            # Also check forum_topic_created service message for topic discovery\n+            if hasattr(message, \"forum_topic_created\") and message.forum_topic_created:\n+                created_name = message.forum_topic_created.name\n+                if created_name:\n+                    self._cache_dm_topic_from_message(str(chat.id), thread_id_str, created_name)\n+                    if not chat_topic:\n+                        chat_topic = created_name\n+\n         # Build source\n         source = self.build_source(\n             chat_id=str(chat.id),","@@ -1533,7 +1818,8 @@ def _build_message_event(self, message: Message, msg_type: MessageType) -> Messa\n             chat_type=chat_type,\n             user_id=str(user.id) if user else None,\n             user_name=user.full_name if user else None,\n-            thread_id=str(message.message_thread_id) if message.message_thread_id else None,\n+            thread_id=thread_id_str,\n+            chat_topic=chat_topic,\n         )\n         \n         # Extract reply context if this message is a reply","@@ -1551,5 +1837,6 @@ def _build_message_event(self, message: Message, msg_type: MessageType) -> Messa\n             message_id=str(message.message_id),\n             reply_to_message_id=reply_to_id,\n             reply_to_text=reply_to_text,\n+            auto_skill=topic_skill,\n             timestamp=message.date,\n         )"]},{"file":"gateway/run.py","hunks":["@@ -1957,7 +1957,39 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str):\n \n             session_entry.was_auto_reset = False\n             session_entry.auto_reset_reason = None\n-        \n+\n+        # Auto-load skill for DM topic bindings (e.g., Telegram Private Chat Topics)\n+        # Only inject on NEW sessions — for ongoing conversations the skill content\n+        # is already in the conversation history from the first message.\n+        if _is_new_session and getattr(event, \"auto_skill\", None):\n+            try:\n+                from agent.skill_commands import _load_skill_payload, _build_skill_message\n+                _skill_name = event.auto_skill\n+                _loaded = _load_skill_payload(_skill_name, task_id=_quick_key)\n+                if _loaded:\n+                    _loaded_skill, _skill_dir, _display_name = _loaded\n+                    _activation_note = (\n+                        f'[SYSTEM: This conversation is in a topic with the \"{_display_name}\" skill '\n+                        f\"auto-loaded. Follow its instructions for the duration of this session.]\"\n+                    )\n+                    _skill_msg = _build_skill_message(\n+                        _loaded_skill, _skill_dir, _activation_note,\n+                        user_instruction=event.text,\n+                    )\n+                    if _skill_msg:\n+                        event.text = _skill_msg\n+                        logger.info(\n+                            \"[Gateway] Auto-loaded skill '%s' for DM topic session %s\",\n+                            _skill_name, session_key,\n+                        )\n+                else:\n+                    logger.warning(\n+                        \"[Gateway] DM topic skill '%s' not found in available skills\",\n+                        _skill_name,\n+                    )\n+            except Exception as e:\n+                logger.warning(\"[Gateway] Failed to auto-load topic skill '%s': %s\", event.auto_skill, e)\n+\n         # Load conversation history from transcript\n         history = self.session_store.load_transcript(session_entry.session_id)\n         "]},{"file":"tests/gateway/test_dm_topics.py","hunks":["@@ -0,0 +1,484 @@\n+\"\"\"Tests for Telegram DM Private Chat Topics (Bot API 9.4).\n+\n+Covers:\n+- _setup_dm_topics: loading persisted thread_ids from config\n+- _setup_dm_topics: creating new topics via API when no thread_id\n+- _persist_dm_topic_thread_id: saving thread_id back to config.yaml\n+- _get_dm_topic_info: looking up topic config by thread_id\n+- _cache_dm_topic_from_message: caching thread_ids from incoming messages\n+- _build_message_event: DM topic resolution in message events\n+\"\"\"\n+\n+import asyncio\n+import sys\n+from pathlib import Path\n+from types import SimpleNamespace\n+from unittest.mock import AsyncMock, MagicMock, patch, mock_open\n+\n+import pytest\n+\n+from gateway.config import PlatformConfig\n+\n+\n+def _ensure_telegram_mock():\n+    if \"telegram\" in sys.modules and hasattr(sys.modules[\"telegram\"], \"__file__\"):\n+        return\n+\n+    telegram_mod = MagicMock()\n+    telegram_mod.ext.ContextTypes.DEFAULT_TYPE = type(None)\n+    telegram_mod.constants.ParseMode.MARKDOWN_V2 = \"MarkdownV2\"\n+    telegram_mod.constants.ChatType.GROUP = \"group\"\n+    telegram_mod.constants.ChatType.SUPERGROUP = \"supergroup\"\n+    telegram_mod.constants.ChatType.CHANNEL = \"channel\"\n+    telegram_mod.constants.ChatType.PRIVATE = \"private\"\n+\n+    for name in (\"telegram\", \"telegram.ext\", \"telegram.constants\"):\n+        sys.modules.setdefault(name, telegram_mod)\n+\n+\n+_ensure_telegram_mock()\n+\n+from gateway.platforms.telegram import TelegramAdapter  # noqa: E402\n+\n+\n+def _make_adapter(dm_topics_config=None):\n+    \"\"\"Create a TelegramAdapter with optional DM topics config.\"\"\"\n+    extra = {}\n+    if dm_topics_config is not None:\n+        extra[\"dm_topics\"] = dm_topics_config\n+    config = PlatformConfig(enabled=True, token=\"***\", extra=extra)\n+    adapter = TelegramAdapter(config)\n+    return adapter\n+\n+\n+# ── _setup_dm_topics: load persisted thread_ids ──\n+\n+\n[redacted:email]\n+async def test_setup_dm_topics_loads_persisted_thread_ids():\n+    \"\"\"Topics with thread_id in config should be loaded into cache, not created.\"\"\"\n+    adapter = _make_adapter([\n+        {\n+            \"chat_id\": 111,\n+            \"topics\": [\n+                {\"name\": \"General\", \"thread_id\": 100},\n+                {\"name\": \"Work\", \"thread_id\": 200},\n+            ],\n+        }\n+    ])\n+    adapter._bot = AsyncMock()\n+\n+    await adapter._setup_dm_topics()\n+\n+    # Both should be in cache\n+    assert adapter._dm_topics[\"111:General\"] == 100\n+    assert adapter._dm_topics[\"111:Work\"] == 200\n+    # create_forum_topic should NOT have been called\n+    adapter._bot.create_forum_topic.assert_not_called()\n+\n+\n[redacted:email]\n+async def test_setup_dm_topics_creates_when_no_thread_id():\n+    \"\"\"Topics without thread_id should be created via API.\"\"\"\n+    adapter = _make_adapter([\n+        {\n+            \"chat_id\": 222,\n+            \"topics\": [\n+                {\"name\": \"NewTopic\", \"icon_color\": 7322096},\n+            ],\n+        }\n+    ])\n+    adapter._bot = AsyncMock()\n+    mock_topic = SimpleNamespace(message_thread_id=999)\n+    adapter._bot.create_forum_topic.return_value = mock_topic\n+\n+    # Mock the persist method so it doesn't touch the filesystem\n+    adapter._persist_dm_topic_thread_id = MagicMock()\n+\n+    await adapter._setup_dm_topics()\n+\n+    # Should have been created\n+    adapter._bot.create_forum_topic.assert_called_once_with(\n+        chat_id=222, name=\"NewTopic\", icon_color=7322096,\n+    )\n+    # Should be in cache\n+    assert adapter._dm_topics[\"222:NewTopic\"] == 999\n+    # Should persist\n+    adapter._persist_dm_topic_thread_id.assert_called_once_with(222, \"NewTopic\", 999)\n+\n+\n[redacted:email]\n+async def test_setup_dm_topics_mixed_persisted_and_new():\n+    \"\"\"Mix of persisted and new topics should work correctly.\"\"\"\n+    adapter = _make_adapter([\n+        {\n+            \"chat_id\": 333,\n+            \"topics\": [\n+                {\"name\": \"Existing\", \"thread_id\": 50},\n+                {\"name\": \"New\", \"icon_color\": 123},\n+            ],\n+        }\n+    ])\n+    adapter._bot = AsyncMock()\n+    mock_topic = SimpleNamespace(message_thread_id=777)\n+    adapter._bot.create_forum_topic.return_value = mock_topic\n+    adapter._persist_dm_topic_thread_id = MagicMock()\n+\n+    await adapter._setup_dm_topics()\n+\n+    # Existing loaded from config\n+    assert adapter._dm_topics[\"333:Existing\"] == 50\n+    # New created via API\n+    assert adapter._dm_topics[\"333:New\"] == 777\n+    # Only one API call (for \"New\")\n+    adapter._bot.create_forum_topic.assert_called_once()\n+\n+\n[redacted:email]\n+async def test_setup_dm_topics_skips_empty_config():\n+    \"\"\"Empty dm_topics config should be a no-op.\"\"\"\n+    adapter = _make_adapter([])\n+    adapter._bot = AsyncMock()\n+\n+    await adapter._setup_dm_topics()\n+\n+    adapter._bot.create_forum_topic.assert_not_called()\n+    assert adapter._dm_topics == {}\n+\n+\n[redacted:email]\n+async def test_setup_dm_topics_no_config():\n+    \"\"\"No dm_topics in config at all should be a no-op.\"\"\"\n+    adapter = _make_adapter()\n+    adapter._bot = AsyncMock()\n+\n+    await adapter._setup_dm_topics()\n+\n+    adapter._bot.create_forum_topic.assert_not_called()\n+\n+\n+# ── _create_dm_topic: error handling ──\n+\n+\n[redacted:email]\n+async def test_create_dm_topic_handles_duplicate_error():\n+    \"\"\"Duplicate topic error should return None gracefully.\"\"\"\n+    adapter = _make_adapter()\n+    adapter._bot = AsyncMock()\n+    adapter._bot.create_forum_topic.side_effect = Exception(\"topic_name_duplicate\")\n+\n+    result = await adapter._create_dm_topic(chat_id=111, name=\"General\")\n+\n+    assert result is None\n+\n+\n[redacted:email]\n+async def test_create_dm_topic_handles_generic_error():\n+    \"\"\"Generic error should return None with warning.\"\"\"\n+    adapter = _make_adapter()\n+    adapter._bot = AsyncMock()\n+    adapter._bot.create_forum_topic.side_effect = Exception(\"some random error\")\n+\n+    result = await adapter._create_dm_topic(chat_id=111, name=\"General\")\n+\n+    assert result is None\n+\n+\n[redacted:email]\n+async def test_create_dm_topic_returns_none_without_bot():\n+    \"\"\"No bot instance should return None.\"\"\"\n+    adapter = _make_adapter()\n+    adapter._bot = None\n+\n+    result = await adapter._create_dm_topic(chat_id=111, name=\"General\")\n+\n+    assert result is None\n+\n+\n+# ── _persist_dm_topic_thread_id ──\n+\n+\n+def test_persist_dm_topic_thread_id_writes_config(tmp_path):\n+    \"\"\"Should write thread_id into the correct topic in config.yaml.\"\"\"\n+    import yaml\n+\n+    config_data = {\n+        \"platforms\": {\n+            \"telegram\": {\n+                \"extra\": {\n+                    \"dm_topics\": [\n+                        {\n+                            \"chat_id\": 111,\n+                            \"topics\": [\n+                                {\"name\": \"General\", \"icon_color\": 123},\n+                                {\"name\": \"Work\", \"icon_color\": 456},\n+                            ],\n+                        }\n+                    ]\n+                }\n+            }\n+        }\n+    }\n+\n+    config_file = tmp_path / \".hermes\" / \"config.yaml\"\n+    config_file.parent.mkdir(parents=True)\n+    with open(config_file, \"w\") as f:\n+        yaml.dump(config_data, f)\n+\n+    adapter = _make_adapter()\n+\n+    with patch.object(Path, \"home\", return_value=tmp_path):\n+        adapter._persist_dm_topic_thread_id(111, \"General\", 999)\n+\n+    with open(config_file) as f:\n+        result = yaml.safe_load(f)\n+\n+    topics = result[\"platforms\"][\"telegram\"][\"extra\"][\"dm_topics\"][0][\"topics\"]\n+    assert topics[0][\"thread_id\"] == 999\n+    assert \"thread_id\" not in topics[1]  # \"Work\" should be untouched\n+\n+\n+def test_persist_dm_topic_thread_id_skips_if_already_set(tmp_path):\n+    \"\"\"Should not overwrite an existing thread_id.\"\"\"\n+    import yaml\n+\n+    config_data = {\n+        \"platforms\": {\n+            \"telegram\": {\n+                \"extra\": {\n+                    \"dm_topics\": [\n+                        {\n+                            \"chat_id\": 111,\n+                            \"topics\": [\n+                                {\"name\": \"General\", \"icon_color\": 123, \"thread_id\": 500},\n+                            ],\n+                        }\n+                    ]\n+                }\n+            }\n+        }\n+    }\n+\n+    config_file = tmp_path / \".hermes\" / \"config.yaml\"\n+    config_file.parent.mkdir(parents=True)\n+    with open(config_file, \"w\") as f:\n+        yaml.dump(config_data, f)\n+\n+    adapter = _make_adapter()\n+\n+    with patch.object(Path, \"home\", return_value=tmp_path):\n+        adapter._persist_dm_topic_thread_id(111, \"General\", 999)\n+\n+    with open(config_file) as f:\n+        result = yaml.safe_load(f)\n+\n+    topics = result[\"platforms\"][\"telegram\"][\"extra\"][\"dm_topics\"][0][\"topics\"]\n+    assert topics[0][\"thread_id\"] == 500  # unchanged\n+\n+\n+# ── _get_dm_topic_info ──\n+\n+\n+def test_get_dm_topic_info_finds_cached_topic():\n+    \"\"\"Should return topic config when thread_id is in cache.\"\"\"\n+    adapter = _make_adapter([\n+        {\n+            \"chat_id\": 111,\n+            \"topics\": [\n+                {\"name\": \"General\", \"skill\": \"my-skill\"},\n+            ],\n+        }\n+    ])\n+    adapter._dm_topics[\"111:General\"] = 100\n+\n+    result = adapter._get_dm_topic_info(\"111\", \"100\")\n+\n+    assert result is not None\n+    assert result[\"name\"] == \"General\"\n+    assert result[\"skill\"] == \"my-skill\"\n+\n+\n+def test_get_dm_topic_info_returns_none_for_unknown():\n+    \"\"\"Should return None for unknown thread_id.\"\"\"\n+    adapter = _make_adapter([\n+        {\n+            \"chat_id\": 111,\n+            \"topics\": [{\"name\": \"General\"}],\n+        }\n+    ])\n+    # Mock reload to avoid filesystem access\n+    adapter._reload_dm_topics_from_config = lambda: None\n+\n+    result = adapter._get_dm_topic_info(\"111\", \"999\")\n+\n+    assert result is None\n+\n+\n+def test_get_dm_topic_info_returns_none_without_config():\n+    \"\"\"Should return None if no dm_topics config.\"\"\"\n+    adapter = _make_adapter()\n+    adapter._reload_dm_topics_from_config = lambda: None\n+\n+    result = adapter._get_dm_topic_info(\"111\", \"100\")\n+\n+    assert result is None\n+\n+\n+def test_get_dm_topic_info_returns_none_for_none_thread():\n+    \"\"\"Should return None if thread_id is None.\"\"\"\n+    adapter = _make_adapter([\n+        {\"chat_id\": 111, \"topics\": [{\"name\": \"General\"}]}\n+    ])\n+\n+    result = adapter._get_dm_topic_info(\"111\", None)\n+\n+    assert result is None\n+\n+\n+def test_get_dm_topic_info_hot_reloads_from_config(tmp_path):\n+    \"\"\"Should find a topic added to config after startup (hot-reload).\"\"\"\n+    import yaml\n+\n+    # Start with empty topics\n+    adapter = _make_adapter([\n+        {\"chat_id\": 111, \"topics\": []}\n+    ])\n+\n+    # Write config with a new topic + thread_id\n+    config_data = {\n+        \"platforms\": {\n+            \"telegram\": {\n+                \"extra\": {\n+                    \"dm_topics\": [\n+                        {\n+                            \"chat_id\": 111,\n+                            \"topics\": [\n+                                {\"name\": \"NewProject\", \"thread_id\": 555},\n+                            ],\n+                        }\n+                    ]\n+                }\n+            }\n+        }\n+    }\n+    config_file = tmp_path / \".hermes\" / \"config.yaml\"\n+    config_file.parent.mkdir(parents=True)\n+    with open(config_file, \"w\") as f:\n+        yaml.dump(config_data, f)\n+\n+    with patch.object(Path, \"home\", return_value=tmp_path):\n+        result = adapter._get_dm_topic_info(\"111\", \"555\")\n+\n+    assert result is not None\n+    assert result[\"name\"] == \"NewProject\"\n+    # Should now be cached\n+    assert adapter._dm_topics[\"111:NewProject\"] == 555\n+\n+\n+# ── _cache_dm_topic_from_message ──\n+\n+\n+def test_cache_dm_topic_from_message():\n+    \"\"\"Should cache a new topic mapping.\"\"\"\n+    adapter = _make_adapter()\n+\n+    adapter._cache_dm_topic_from_message(\"111\", \"100\", \"General\")\n+\n+    assert adapter._dm_topics[\"111:General\"] == 100\n+\n+\n+def test_cache_dm_topic_from_message_no_overwrite():\n+    \"\"\"Should not overwrite an existing cached topic.\"\"\"\n+    adapter = _make_adapter()\n+    adapter._dm_topics[\"111:General\"] = 100\n+\n+    adapter._cache_dm_topic_from_message(\"111\", \"999\", \"General\")\n+\n+    assert adapter._dm_topics[\"111:General\"] == 100  # unchanged\n+\n+\n+# ── _build_message_event: auto_skill binding ──\n+\n+\n+def _make_mock_message(chat_id=111, chat_type=\"private\", text=\"hello\", thread_id=None,\n+                       user_id=42, user_name=\"Test User\", forum_topic_created=None):\n+    \"\"\"Create a mock Telegram Message for _build_message_event tests.\"\"\"\n+    chat = SimpleNamespace(\n+        id=chat_id,\n+        type=chat_type,\n+        title=None,\n+    )\n+    # Add full_name attribute for DM chats\n+    if not hasattr(chat, \"full_name\"):\n+        chat.full_name = user_name\n+\n+    user = SimpleNamespace(\n+        id=user_id,\n+        full_name=user_name,\n+    )\n+\n+    msg = SimpleNamespace(\n+        chat=chat,\n+        from_user=user,\n+        text=text,\n+        message_thread_id=thread_id,\n+        message_id=1001,\n+        reply_to_message=None,\n+        date=None,\n+        forum_topic_created=forum_topic_created,\n+    )\n+    return msg\n+\n+\n+def test_build_message_event_sets_auto_skill():\n+    \"\"\"When topic has a skill binding, auto_skill should be set on the event.\"\"\"\n+    from gateway.platforms.base import MessageType\n+\n+    adapter = _make_adapter([\n+        {\n+            \"chat_id\": 111,\n+            \"topics\": [\n+                {\"name\": \"My Project\", \"skill\": \"accessibility-auditor\", \"thread_id\": 100},\n+            ],\n+        }\n+    ])\n+    adapter._dm_topics[\"111:My Project\"] = 100\n+\n+    msg = _make_mock_message(chat_id=111, thread_id=100, text=\"check this page\")\n+    event = adapter._build_message_event(msg, MessageType.TEXT)\n+\n+    assert event.auto_skill == \"accessibility-auditor\"\n+    # chat_topic should be the clean topic name, no [skill: ...] suffix\n+    assert event.source.chat_topic == \"My Project\"\n+\n+\n+def test_build_message_event_no_auto_skill_without_binding():\n+    \"\"\"Topics without skill binding should have auto_skill=None.\"\"\"\n+    from gateway.platforms.base import MessageType\n+\n+    adapter = _make_adapter([\n+        {\n+            \"chat_id\": 111,\n+            \"topics\": [\n+                {\"name\": \"General\", \"thread_id\": 200},\n+            ],\n+        }\n+    ])\n+    adapter._dm_topics[\"111:General\"] = 200\n+\n+    msg = _make_mock_message(chat_id=111, thread_id=200)\n+    event = adapter._build_message_event(msg, MessageType.TEXT)\n+\n+    assert event.auto_skill is None\n+    assert event.source.chat_topic == \"General\"\n+\n+\n+def test_build_message_event_no_auto_skill_without_thread():\n+    \"\"\"Regular DM messages (no thread_id) should have auto_skill=None.\"\"\"\n+    from gateway.platforms.base import MessageType\n+\n+    adapter = _make_adapter()\n+    msg = _make_mock_message(chat_id=111, thread_id=None)\n+    event = adapter._build_message_event(msg, MessageType.TEXT)\n+\n+    assert event.auto_skill is None"]},{"file":"website/docs/user-guide/messaging/telegram.md","hunks":["@@ -165,8 +165,70 @@ Hermes Agent works in Telegram group chats with a few considerations:\n - When privacy mode is off (or bot is admin), the bot sees all messages and can participate naturally\n - `TELEGRAM_ALLOWED_USERS` still applies — only authorized users can trigger the bot, even in groups\n \n-## Recent Bot API Features (2024–2025)\n+## Private Chat Topics (Bot API 9.4)\n \n+Telegram Bot API 9.4 (February 2026) introduced **Private Chat Topics** — bots can create forum-style topic threads directly in 1-on-1 DM chats, no supergroup needed. This lets you run multiple isolated workspaces within your existing DM with Hermes.\n+\n+### Use case\n+\n+If you work on several long-running projects, topics keep their context separate:\n+\n+- **Topic \"Website\"** — work on your production web service\n+- **Topic \"Research\"** — literature review and paper exploration\n+- **Topic \"General\"** — miscellaneous tasks and quick questions\n+\n+Each topic gets its own conversation session, history, and context — completely isolated from the others.\n+\n+### Configuration\n+\n+Add topics under `platforms.telegram.extra.dm_topics` in `~/.hermes/config.yaml`:\n+\n+```yaml\n+platforms:\n+  telegram:\n+    extra:\n+      dm_topics:\n+      - chat_id: 123456789        # Your Telegram user ID\n+        topics:\n+        - name: General\n+          icon_color: 7322096\n+        - name: Website\n+          icon_color: 9367192\n+        - name: Research\n+          icon_color: 16766590\n+          skill: arxiv              # Auto-load a skill in this topic\n+```\n+\n+**Fields:**\n+\n+| Field | Required | Description |\n+|-------|----------|-------------|\n+| `name` | Yes | Topic display name |\n+| `icon_color` | No | Telegram icon color code (integer) |\n+| `icon_custom_emoji_id` | No | Custom emoji ID for the topic icon |\n+| `skill` | No | Skill to auto-load on new sessions in this topic |\n+| `thread_id` | No | Auto-populated after topic creation — don't set manually |\n+\n+### How it works\n+\n+1. On gateway startup, Hermes calls `createForumTopic` for each topic that doesn't have a `thread_id` yet\n+2. The `thread_id` is saved back to `config.yaml` automatically — subsequent restarts skip the API call\n+3. Each topic maps to an isolated session key: `agent:main:telegram:dm:{chat_id}:{thread_id}`\n+4. Messages in each topic have their own conversation history, memory flush, and context window\n+\n+### Skill binding\n+\n+Topics with a `skill` field automatically load that skill when a new session starts in the topic. This works exactly like typing `/skill-name` at the start of a conversation — the skill content is injected into the first message, and subsequent messages see it in the conversation history.\n+\n+For example, a topic with `skill: arxiv` will have the arxiv skill pre-loaded whenever its session resets (due to idle timeout, daily reset, or manual `/reset`).\n+\n+:::tip\n+Topics created outside of the config (e.g., by manually calling the Telegram API) are discovered automatically when a `forum_topic_created` service message arrives. You can also add topics to the config while the gateway is running — they'll be picked up on the next cache miss.\n+:::\n+\n+## Recent Bot API Features\n+\n+- **Bot API 9.4 (Feb 2026):** Private Chat Topics — bots can create forum topics in 1-on-1 DM chats via `createForumTopic`. See [Private Chat Topics](#private-chat-topics-bot-api-94) above.\n - **Privacy policy:** Telegram now requires bots to have a privacy policy. Set one via BotFather with `/setprivacy_policy`, or Telegram may auto-generate a placeholder. This is particularly important if your bot is public-facing.\n - **Message streaming:** Bot API 9.x added support for streaming long responses, which can improve perceived latency for lengthy agent replies.\n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}