# NousResearch/hermes-agent · Telegram private chat topics feature development

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #3163. Verify it addresses your case.
- **Recommended:** read PR #3163 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-3163`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #2598: Feature: Telegram Private Chat Topics support for project-based conversations

## Prior & competing PRs

- **#3163: feat(telegram): Private Chat Topics with functional skill binding (#2598)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `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:
  - `gateway/platforms/base.py`

    ```diff
    @@ -296,6 +296,9 @@ class MessageEvent:
         reply_to_message_id: Optional[str] = None
         reply_to_text: Optional[str] = None  # Text of the replied-to message (for context injection)
         
    +    # Auto-loaded skill for topic/channel bindings (e.g., Telegram DM Topics)
    +    auto_skill: Optional[str] = None
    +    
         # Timestamps
         timestamp: datetime = field(default_factory=datetime.now)
         
    ```
  - `gateway/platforms/telegram.py`

    ```diff
    @@ -133,6 +133,10 @@ def __init__(self, config: PlatformConfig):
             self._polling_conflict_count: int = 0
             self._polling_network_error_count: int = 0
             self._polling_error_callback_ref = None
    +        # DM Topics: map of topic_name -> message_thread_id (populated at startup)
    +        self._dm_topics: Dict[str, int] = {}
    +        # DM Topics config from extra.dm_topics
    +        self._dm_topics_config: List[Dict[str, Any]] = self.config.extra.get("dm_topics", [])
     
         @staticmethod
         def _looks_like_polling_conflict(error: Exception) -> bool:
    ```

    ```diff
    @@ -273,6 +277,162 @@ async def _handle_polling_conflict(self, error: Exception) -> None:
                 logger.warning("[%s] Failed stopping Telegram polling after conflict: %s", self.name, stop_error, exc_info=True)
             await self._notify_fatal_error()
     
    +    async def _create_dm_topic(
    +        self,
    +        chat_id: int,
    +        name: str,
    +        icon_color: Optional[int] = None,
    +        icon_custom_emoji_id: Optional[str] = None,
    +    ) -> Optional[int]:
    +        """Create a forum topic in a private (DM) chat.
    +
    +        Uses Bot API 9.4's createForumTopic which now works for 1-on-1 chats.
    +        Returns the message_thread_id on success, None on failure.
    +        """
    +        if not self._bot:
    +            return None
    +        try:
    +            kwargs: Dict[str, Any] = {"chat_id": chat_id, "name": name}
    +            if icon_color is not None:
    +                kwargs["icon_color"] = icon_color
    +            if icon_custom_emoji_id:
    +                kwargs["icon_custom_emoji_id"] = icon_custom_emoji_id
    +
    +            topic = await self._bot.create_forum_topic(**kwargs)
    +            thread_id = topic.message_thread_id
    +            logger.info(
    +                "[%s] Created DM topic '%s' in chat %s -> thread_id=%s",
    +                self.name, name, chat_id, thread_id,
    +            )
    +            return thread_id
    +        except Exception as e:
    +            error_text = str(e).lower()
    +            # If topic already exists, try to find it via getForumTopicIconStickers
    +            # or we just log and skip — Telegram doesn't provide a "list topics" API
    +            if "topic_name_duplicate" in error_text or "already" in error_text:
    +                logger.info(
    +                    "[%s] DM topic '%s' already exists in chat %s (will be mapped from incoming messages)",
    +                    self.name, name, chat_id,
    +                )
    +            else:
    +                logger.warning(
    +                    "[%s] Failed to create DM topic '%s' in chat %s: %s",
    +                    self.name, name, chat_id, e,
    +                )
    +            return None
    +
    +    def _persist_dm_topic_thread_id(self, chat_id: int, topic_name: str, thread_id: int) -> None:
    +        """Save a newly created thread_id back into config.yaml so it persists across restarts."""
    +        try:
    +            config_path = _Path.home() / ".hermes" / "config.yaml"
    +            if not config_path.exists():
    +                logger.warning("[%s] Config file not found at %s, cannot persist thread_id", self.name, config_path)
    +                return
    +
    +            import yaml as _yaml
    +            with open(config_path, "r") as f:
    +                config = _yaml.safe_load(f) or {}
    +
    +            # Navigate to platforms.telegram.extra.dm_topics
    +            dm_topics = (
    +                config.get("platforms", {})
    +                .get("telegram", {})
    +                .get("extra", {})
    +                .get("dm_topics", [])
    +            )
    +            if not dm_topics:
    +                return
    +
    +            changed = False
    +            for chat_entry in dm_topics:
    +                if int(chat_entry.get("chat_id", 0)) != int(chat_id):
    +                    continue
    +                for t in chat_entry.get("topics", []):
    +                    if t.get("name") == topic_name and not t.get("thread_id"):
    +                        t["thread_id"] = thread_id
    +                        changed = True
    +                        break
    +
    +            if changed:
    +                with open(config_path, "w") as f:
    +                    _yaml.dump(config, f, default_flow_style=False, sort_keys=False)
    +                logger.info(
    +                    "[%s] Persisted thread_id=%s for topic '%s' in config.yaml",
    +                    self.name, thread_id, topic_name,
    +                )
    +        except Exception as e:
    +            logger.warning("[%s] Failed to persist thread_id to config: %s", self.name, e, exc_info=True)
    +
    +    async def _setup_dm_topics(self) -> None:
    +        """Load or create configured DM topics for specified chats.
    +
    +        Reads config.extra['dm_topics'] — a list of dicts:
    +        [
    +            {
    +                "chat_id": 123456789,
    +                "topics": [
    +                    {"name": "General", "icon_color": 7322096, "thread_id": 100},
    +                    {"name": "Accessibility Auditor", "icon_color": 9367192, "skill": "accessibility-auditor"}
    +                ]
    +            }
    +        ]
    +
    +        If a topic already has a thread_id in the config (persisted from a previous
    +        creation), it is loaded into the cache without calling createForumTopic.
    +        Only topics without a thread_id are created via the API, and their thread_id
    +        is then saved back to config.yaml for future restarts.
    +        """
    +        if not self._dm_topics_config:
    +            return
    +
    +        for chat_entry in self._dm_topics_config:
    +            chat_id = chat_entry.get("chat_id")
    +            topics = chat_entry.get("topics", [])
    +            if not chat_id or not topics:
    +                continue
    +
    +            logger.info(
    +                "[%s] Setting up %d DM topic(s) for chat %s",
    +                self.name, len(topics), chat_id,
    +            )
    +
    +            for topic_conf in topics:
    +                topic_name = topic_conf.get("name")
    +                if not topic_name:
    +                    continue
    +
    +                cache_key = f"{chat_id}:{topic_name}"
    +
    +                # If thread_id is already persisted in config, just load into cache
    +                existing_thread_id = topic_conf.get("thread_id")
    +                if existing_thread_id:
    +                    self._dm_topics[cache_key] = int(existing_thread_id)
    +                    logger.info(
    +                        "[%s] DM topic loaded from config: %s -> thread_id=%s",
    +                        self.name, cache_key, existing_thread_id,
    +                    )
    +                    continue
    +
    +                # No persisted thread_id — create the topic via API
    +                icon_color = topic_conf.get("icon_color")
    +                icon_emoji = topic_conf.get("icon_custom_emoji_id")
    +
    +                thread_id = await self._create_dm_topic(
    +                    chat_id=int(chat_id),
    +                    name=topic_name,
    +                    icon_color=icon_color,
    +                    icon_custom_emoji_id=icon_emoji,
    +                )
    +
    +                if thread_id:
    +                    self._dm_topics[cache_key] = thread_id
    +                    logger.info(
    +                        "[%s] DM topic cached: %s -> thread_id=%s",
    +                        self.name, cache_key, thread_id,
    +                    )
    +                    # Persist thread_id to config so we don't recreate on next restart
    +                    self._persist_dm_topic_thread_id(int(chat_id), topic_name, thread_id)
    +
         async def connect(self) -> bool:
             """Connect to Telegram and start polling for updates."""
             if not TELEGRAM_AVAILABLE:
    ```

    ```diff
    @@ -390,6 +550,18 @@ def _polling_error_callback(error: Exception) -> None:
                 
                 self._mark_connected()
                 logger.info("[%s] Connected and polling for Telegram updates", self.name)
    +
    +            # Set up DM topics (Bot API 9.4 — Private Chat Topics)
    +            # Runs after connection is established so the bot can call createForumTopic.
    +            # Failures here are non-fatal — the bot works fine without topics.
    +            try:
    +                await self._setup_dm_topics()
    +            except Exception as topics_err:
    +                logger.warning(
    +                    "[%s] DM topics setup failed (non-fatal): %s",
    +                    self.name, topics_err, exc_info=True,
    +                )
    +
                 return True
                 
             except Exception as e:
    ```

    ```diff
    @@ -1514,6 +1686,99 @@ async def _handle_sticker(self, msg: Message, event: "MessageEvent") -> None:
                     emoji, set_name,
                 )
     
    +    def _reload_dm_topics_from_config(self) -> None:
    +        """Re-read dm_topics from config.yaml and load any new thread_ids into cache.
    +
    +        This allows topics created externally (e.g. by the agent via API) to be
    +        recognized without a gateway restart.
    +        """
    +        try:
    +            config_path = _Path.home() / ".hermes" / "config.yaml"
    +            if not config_path.exists():
    +                return
    +
    +            import yaml as _yaml
    +            with open(config_path, "r") as f:
    +                config = _yaml.safe_load(f) or {}
    +
    +            dm_topics = (
    +                config.get("platforms", {})
    +                .get("telegram", {})
    +                .get("extra", {})
    +                .get("dm_topics", [])
    +            )
    +            if not dm_topics:
    +                return
    +
    +            # Update in-memory config and cache any new thread_ids
    +            self._dm_topics_config = dm_topics
    +            for chat_entry in dm_topics:
    +                cid = chat_entry.get("chat_id")
    +                if not cid:
    +                    continue
    +                for t in chat_entry.get("topics", []):
    +                    tid = t.get("thread_id")
    +                    name = t.get("name")
    +                    if tid and name:
    +                        cache_key = f"{cid}:{name}"
    +                        if cache_key not in self._dm_topics:
    +                            self._dm_topics[cache_key] = int(tid)
    +                            logger.info(
    +                                "[%s] Hot-loaded DM topic from config: %s -> thread_id=%s",
    +                                self.name, cache_key, tid,
    +                            )
    +        except Exception as e:
    +            logger.debug("[%s] Failed to reload dm_topics from config: %s", self.name, e)
    +
    +    def _get_dm_topic_info(self, chat_id: str, thread_id: Optional[str]) -> Optional[Dict[str, Any]]:
    +        """Look up DM topic config by chat_id and thread_id.
    +
    +        Returns the topic config dict (name, skill, etc.) if this thread_id
    +        matches a known DM topic, or None.
    +        """
    +        if not thread_id:
    +            return None
    +
    +        thread_id_int = int(thread_id)
    +
    +        # Check cached topics first (created by us or loaded at startup)
    +        for key, cached_tid in self._dm_topics.items():
    +            if cached_tid == thread_id_int and key.startswith(f"{chat_id}:"):
    +                topic_name = key.split(":", 1)[1]
    +                # Find the full config for this topic
    +                for chat_entry in self._dm_topics_config:
    +                    if str(chat_entry.get("chat_id")) == chat_id:
    +                        for t in chat_entry.get("topics", []):
    +                            if t.get("name") == topic_name:
    +                                return t
    +                return {"name": topic_name}
    +
    +        # Not in cache — hot-reload config in case topics were added externally
    +        self._reload_dm_topics_from_config()
    +
    +        # Check cache again after reload
    +        for key, cached_tid in self._dm_topics.items():
    +            if cached_tid == thread_id_int and key.startswith(f"{chat_id}:"):
    +                topic_name = key.split(":", 1)[1]
    +                for chat_entry in self._dm_topics_config:
    +                    if str(chat_entry.get("chat_id")) == chat_id:
    +                        for t in chat_entry.get("topics", []):
    +                            if t.get("name") == topic_name:
    +                                return t
    +                return {"name": topic_name}
    +
    +        return None
    +
    +    def _cache_dm_topic_from_message(self, chat_id: str, thread_id: str, topic_name: str) -> None:
    +        """Cache a thread_id -> topic_name mapping discovered from an incoming message."""
    +        cache_key = f"{chat_id}:{topic_name}"
    +        if cache_key not in self._dm_topics:
    +            self._dm_topics[cache_key] = int(thread_id)
    +            logger.info(
    +                "[%s] Cached DM topic from message: %s -> thread_id=%s",
    +                self.name, cache_key, thread_id,
    +            )
    +
         def _build_message_event(self, message: Message, msg_type: MessageType) -> MessageEvent:
             """Build a MessageEvent from a Telegram message."""
             chat = message.chat
    ```

    ```diff
    @@ -1525,7 +1790,27 @@ def _build_message_event(self, message: Message, msg_type: MessageType) -> Messa
                 chat_type = "group"
             elif chat.type == ChatType.CHANNEL:
                 chat_type = "channel"
    -        
    +
    +        # Resolve DM topic name and skill binding
    +        thread_id_raw = message.message_thread_id
    +        thread_id_str = str(thread_id_raw) if thread_id_raw else None
    +        chat_topic = None
    +        topic_skill = None
    +
    +        if chat_type == "dm" and thread_id_str:
    +            topic_info = self._get_dm_topic_info(str(chat.id), thread_id_str)
    +            if topic_info:
    +                chat_topic = topic_info.get("name")
    +                topic_skill = topic_info.get("skill")
    +
    +            # Also check forum_topic_created service message for topic discovery
    +            if hasattr(message, "forum_topic_created") and message.forum_topic_created:
    +                created_name = message.forum_topic_created.name
    +                if created_name:
    +                    self._cache_dm_topic_from_message(str(chat.id), thread_id_str, created_name)
    +                    if not chat_topic:
    +                        chat_topic = created_name
    +
             # Build source
             source = self.build_source(
                 chat_id=str(chat.id),
    ```

    ```diff
    @@ -1533,7 +1818,8 @@ def _build_message_event(self, message: Message, msg_type: MessageType) -> Messa
                 chat_type=chat_type,
                 user_id=str(user.id) if user else None,
                 user_name=user.full_name if user else None,
    -            thread_id=str(message.message_thread_id) if message.message_thread_id else None,
    +            thread_id=thread_id_str,
    +            chat_topic=chat_topic,
             )
             
             # Extract reply context if this message is a reply
    ```

    ```diff
    @@ -1551,5 +1837,6 @@ def _build_message_event(self, message: Message, msg_type: MessageType) -> Messa
                 message_id=str(message.message_id),
                 reply_to_message_id=reply_to_id,
                 reply_to_text=reply_to_text,
    +            auto_skill=topic_skill,
                 timestamp=message.date,
             )
    ```
  - `gateway/run.py`

    ```diff
    @@ -1957,7 +1957,39 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str):
     
                 session_entry.was_auto_reset = False
                 session_entry.auto_reset_reason = None
    -        
    +
    +        # Auto-load skill for DM topic bindings (e.g., Telegram Private Chat Topics)
    +        # Only inject on NEW sessions — for ongoing conversations the skill content
    +        # is already in the conversation history from the first message.
    +        if _is_new_session and getattr(event, "auto_skill", None):
    +            try:
    +                from agent.skill_commands import _load_skill_payload, _build_skill_message
    +                _skill_name = event.auto_skill
    +                _loaded = _load_skill_payload(_skill_name, task_id=_quick_key)
    +                if _loaded:
    +                    _loaded_skill, _skill_dir, _display_name = _loaded
    +                    _activation_note = (
    +                        f'[SYSTEM: This conversation is in a topic with the "{_display_name}" skill '
    +                        f"auto-loaded. Follow its instructions for the duration of this session.]"
    +                    )
    +                    _skill_msg = _build_skill_message(
    +                        _loaded_skill, _skill_dir, _activation_note,
    +                        user_instruction=event.text,
    +                    )
    +                    if _skill_msg:
    +                        event.text = _skill_msg
    +                        logger.info(
    +                            "[Gateway] Auto-loaded skill '%s' for DM topic session %s",
    +                            _skill_name, session_key,
    +                        )
    +                else:
    +                    logger.warning(
    +                        "[Gateway] DM topic skill '%s' not found in available skills",
    +                        _skill_name,
    +                    )
    +            except Exception as e:
    +                logger.warning("[Gateway] Failed to auto-load topic skill '%s': %s", event.auto_skill, e)
    +
             # Load conversation history from transcript
             history = self.session_store.load_transcript(session_entry.session_id)
             
    ```
  - `tests/gateway/test_dm_topics.py`

    ```diff
    @@ -0,0 +1,484 @@
    +"""Tests for Telegram DM Private Chat Topics (Bot API 9.4).
    +
    +Covers:
    +- _setup_dm_topics: loading persisted thread_ids from config
    +- _setup_dm_topics: creating new topics via API when no thread_id
    +- _persist_dm_topic_thread_id: saving thread_id back to config.yaml
    +- _get_dm_topic_info: looking up topic config by thread_id
    +- _cache_dm_topic_from_message: caching thread_ids from incoming messages
    +- _build_message_event: DM topic resolution in message events
    +"""
    +
    +import asyncio
    +import sys
    +from pathlib import Path
    +from types import SimpleNamespace
    +from unittest.mock import AsyncMock, MagicMock, patch, mock_open
    +
    +import pytest
    +
    +from gateway.config import PlatformConfig
    +
    +
    +def _ensure_telegram_mock():
    +    if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"):
    +        return
    +
    +    telegram_mod = MagicMock()
    +    telegram_mod.ext.ContextTypes.DEFAULT_TYPE = type(None)
    +    telegram_mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2"
    +    telegram_mod.constants.ChatType.GROUP = "group"
    +    telegram_mod.constants.ChatType.SUPERGROUP = "supergroup"
    +    telegram_mod.constants.ChatType.CHANNEL = "channel"
    +    telegram_mod.constants.ChatType.PRIVATE = "private"
    +
    +    for name in ("telegram", "telegram.ext", "telegram.constants"):
    +        sys.modules.setdefault(name, telegram_mod)
    +
    +
    +_ensure_telegram_mock()
    +
    +from gateway.platforms.telegram import TelegramAdapter  # noqa: E402
    +
    +
    +def _make_adapter(dm_topics_config=None):
    +    """Create a TelegramAdapter with optional DM topics config."""
    +    extra = {}
    +    if dm_topics_config is not None:
    +        extra["dm_topics"] = dm_topics_config
    +    config = PlatformConfig(enabled=True, token="***", extra=extra)
    +    adapter = TelegramAdapter(config)
    +    return adapter
    +
    +
    +# ── _setup_dm_topics: load persisted thread_ids ──
    +
    +
    [redacted:email]
    +async def test_setup_dm_topics_loads_persisted_thread_ids():
    +    """Topics with thread_id in config should be loaded into cache, not created."""
    +    adapter = _make_adapter([
    +        {
    +            "chat_id": 111,
    +            "topics": [
    +                {"name": "General", "thread_id": 100},
    +                {"name": "Work", "thread_id": 200},
    +            ],
    +        }
    +    ])
    +    adapter._bot = AsyncMock()
    +
    +    await adapter._setup_dm_topics()
    +
    +    # Both should be in cache
    +    assert adapter._dm_topics["111:General"] == 100
    +    assert adapter._dm_topics["111:Work"] == 200
    +    # create_forum_topic should NOT have been called
    +    adapter._bot.create_forum_topic.assert_not_called()
    +
    +
    [redacted:email]
    +async def test_setup_dm_topics_creates_when_no_thread_id():
    +    """Topics without thread_id should be created via API."""
    +    adapter = _make_adapter([
    +        {
    +            "chat_id": 222,
    +            "topics": [
    +                {"name": "NewTopic", "icon_color": 7322096},
    +            ],
    +        }
    +    ])
    +    adapter._bot = AsyncMock()
    +    mock_topic = SimpleNamespace(message_thread_id=999)
    +    adapter._bot.create_forum_topic.return_value = mock_topic
    +
    +    # Mock the persist method so it doesn't touch the filesystem
    +    adapter._persist_dm_topic_thread_id = MagicMock()
    +
    +    await adapter._setup_dm_topics()
    +
    +    # Should have been created
    +    adapter._bot.create_forum_topic.assert_called_once_with(
    +        chat_id=222, name="NewTopic", icon_color=7322096,
    +    )
    +    # Should be in cache
    +    assert adapter._dm_topics["222:NewTopic"] == 999
    +    # Should persist
    +    adapter._persist_dm_topic_thread_id.assert_called_once_with(222, "NewTopic", 999)
    +
    +
    [redacted:email]
    +async def test_setup_dm_topics_mixed_persisted_and_new():
    +    """Mix of persisted and new topics should work correctly."""
    +    adapter = _make_adapter([
    +        {
    +            "chat_id": 333,
    +            "topics": [
    +                {"name": "Existing", "thread_id": 50},
    +                {"name": "New", "icon_color": 123},
    +            ],
    +        }
    +    ])
    +    adapter._bot = AsyncMock()
    +    mock_topic = SimpleNamespace(message_thread_id=777)
    +    adapter._bot.create_forum_topic.return_value = mock_topic
    +    adapter._persist_dm_topic_thread_id = MagicMock()
    +
    +    await adapter._setup_dm_topics()
    +
    +    # Existing loaded from config
    +    assert adapter._dm_topics["333:Existing"] == 50
    +    # New created via API
    +    assert adapter._dm_topics["333:New"] == 777
    +    # Only one API call (for "New")
    +    adapter._bot.create_forum_topic.assert_called_once()
    +
    +
    [redacted:email]
    +async def test_setup_dm_topics_skips_empty_config():
    +    """Empty dm_topics config should be a no-op."""
    +    adapter = _make_adapter([])
    +    adapter._bot = AsyncMock()
    +
    +    await adapter._setup_dm_topics()
    +
    +    adapter._bot.create_forum_topic.assert_not_called()
    +    assert adapter._dm_topics == {}
    +
    +
    [redacted:email]
    +async def test_setup_dm_topics_no_config():
    +    """No dm_topics in config at all should be a no-op."""
    +    adapter = _make_adapter()
    +    adapter._bot = AsyncMock()
    +
    +    await adapter._setup_dm_topics()
    +
    +    adapter._bot.create_forum_topic.assert_not_called()
    +
    +
    +# ── _create_dm_topic: error handling ──
    +
    +
    [redacted:email]
    +async def test_create_dm_topic_handles_duplicate_error():
    +    """Duplicate topic error should return None gracefully."""
    +    adapter = _make_adapter()
    +    adapter._bot = AsyncMock()
    +    adapter._bot.create_forum_topic.side_effect = Exception("topic_name_duplicate")
    +
    +    result = await adapter._create_dm_topic(chat_id=111, name="General")
    +
    +    assert result is None
    +
    +
    [redacted:email]
    +async def test_create_dm_topic_handles_generic_error():
    +    """Generic error should return None with warning."""
    +    adapter = _make_adapter()
    +    adapter._bot = AsyncMock()
    +    adapter._bot.create_forum_topic.side_effect = Exception("some random error")
    +
    +    result = await adapter._create_dm_topic(chat_id=111, name="General")
    +
    +    assert result is None
    +
    +
    [redacted:email]
    +async def test_create_dm_topic_returns_none_without_bot():
    +    """No bot instance should return None."""
    +    adapter = _make_adapter()
    +    adapter._bot = None
    +
    +    result = await adapter._create_dm_topic(chat_id=111, name="General")
    +
    +    assert result is None
    +
    +
    +# ── _persist_dm_topic_thread_id ──
    +
    +
    +def test_persist_dm_topic_thread_id_writes_config(tmp_path):
    +    """Should write thread_id into the correct topic in config.yaml."""
    +    import yaml
    +
    +    config_data = {
    +        "platforms": {
    +            "telegram": {
    +                "extra": {
    +                    "dm_topics": [
    +                        {
    +                            "chat_id": 111,
    +                            "topics": [
    +                                {"name": "General", "icon_color": 123},
    +                                {"name": "Work", "icon_color": 456},
    +                            ],
    +                        }
    +                    ]
    +                }
    +            }
    +        }
    +    }
    +
    +    config_file = tmp_path / ".hermes" / "config.yaml"
    +    config_file.parent.mkdir(parents=True)
    +    with open(config_file, "w") as f:
    +        yaml.dump(config_data, f)
    +
    +    adapter = _make_adapter()
    +
    +    with patch.object(Path, "home", return_value=tmp_path):
    +        adapter._persist_dm_topic_thread_id(111, "General", 999)
    +
    +    with open(config_file) as f:
    +        result = yaml.safe_load(f)
    +
    +    topics = result["platforms"]["telegram"]["extra"]["dm_topics"][0]["topics"]
    +    assert topics[0]["thread_id"] == 999
    +    assert "thread_id" not in topics[1]  # "Work" should be untouched
    +
    +
    +def test_persist_dm_topic_thread_id_skips_if_already_set(tmp_path):
    +    """Should not overwrite an existing thread_id."""
    +    import yaml
    +
    +    config_data = {
    +        "platforms": {
    +            "telegram": {
    +                "extra": {
    +                    "dm_topics": [
    +                        {
    +                            "chat_id": 111,
    +                            "topics": [
    +                                {"name": "General", "icon_color": 123, "thread_id": 500},
    +                            ],
    +                        }
    +                    ]
    +                }
    +            }
    +        }
    +    }
    +
    +    config_file = tmp_path / ".hermes" / "config.yaml"
    +    config_file.parent.mkdir(parents=True)
    +    with open(config_file, "w") as f:
    +        yaml.dump(config_data, f)
    +
    +    adapter = _make_adapter()
    +
    +    with patch.object(Path, "home", return_value=tmp_path):
    +        adapter._persist_dm_topic_thread_id(111, "General", 999)
    +
    +    with open(config_file) as f:
    +        result = yaml.safe_load(f)
    +
    +    topics = result["platforms"]["telegram"]["extra"]["dm_topics"][0]["topics"]
    +    assert topics[0]["thread_id"] == 500  # unchanged
    +
    +
    +# ── _get_dm_topic_info ──
    +
    +
    +def test_get_dm_topic_info_finds_cached_topic():
    +    """Should return topic config when thread_id is in cache."""
    +    adapter = _make_adapter([
    +        {
    +            "chat_id": 111,
    +            "topics": [
    +                {"name": "General", "skill": "my-skill"},
    +            ],
    +        }
    +    ])
    +    adapter._dm_topics["111:General"] = 100
    +
    +    result = adapter._get_dm_topic_info("111", "100")
    +
    +    assert result is not None
    +    assert result["name"] == "General"
    +    assert result["skill"] == "my-skill"
    +
    +
    +def test_get_dm_topic_info_returns_none_for_unknown():
    +    """Should return None for unknown thread_id."""
    +    adapter = _make_adapter([
    +        {
    +            "chat_id": 111,
    +            "topics": [{"name": "General"}],
    +        }
    +    ])
    +    # Mock reload to avoid filesystem access
    +    adapter._reload_dm_topics_from_config = lambda: None
    +
    +    result = adapter._get_dm_topic_info("111", "999")
    +
    +    assert result is None
    +
    +
    +def test_get_dm_topic_info_returns_none_without_config():
    +    """Should return None if no dm_topics config."""
    +    adapter = _make_adapter()
    +    adapter._reload_dm_topics_from_config = lambda: None
    +
    +    result = adapter._get_dm_topic_info("111", "100")
    +
    +    assert result is None
    +
    +
    +def test_get_dm_topic_info_returns_none_for_none_thread():
    +    """Should return None if thread_id is None."""
    +    adapter = _make_adapter([
    +        {"chat_id": 111, "topics": [{"name": "General"}]}
    +    ])
    +
    +    result = adapter._get_dm_topic_info("111", None)
    +
    +    assert result is None
    +
    +
    +def test_get_dm_topic_info_hot_reloads_from_config(tmp_path):
    +    """Should find a topic added to config after startup (hot-reload)."""
    +    import yaml
    +
    +    # Start with empty topics
    +    adapter = _make_adapter([
    +        {"chat_id": 111, "topics": []}
    +    ])
    +
    +    # Write config with a new topic + thread_id
    +    config_data = {
    +        "platforms": {
    +            "telegram": {
    +                "extra": {
    +                    "dm_topics": [
    +                        {
    +                            "chat_id": 111,
    +                            "topics": [
    +                                {"name": "NewProject", "thread_id": 555},
    +                            ],
    +                        }
    +                    ]
    +                }
    +            }
    +        }
    +    }
    +    config_file = tmp_path / ".hermes" / "config.yaml"
    +    config_file.parent.mkdir(parents=True)
    +    with open(config_file, "w") as f:
    +        yaml.dump(config_data, f)
    +
    +    with patch.object(Path, "home", return_value=tmp_path):
    +        result = adapter._get_dm_topic_info("111", "555")
    +
    +    assert result is not None
    +    assert result["name"] == "NewProject"
    +    # Should now be cached
    +    assert adapter._dm_topics["111:NewProject"] == 555
    +
    +
    +# ── _cache_dm_topic_from_message ──
    +
    +
    +def test_cache_dm_topic_from_message():
    +    """Should cache a new topic mapping."""
    +    adapter = _make_adapter()
    +
    +    adapter._cache_dm_topic_from_message("111", "100", "General")
    +
    +    assert adapter._dm_topics["111:General"] == 100
    +
    +
    +def test_cache_dm_topic_from_message_no_overwrite():
    +    """Should not overwrite an existing cached topic."""
    +    adapter = _make_adapter()
    +    adapter._dm_topics["111:General"] = 100
    +
    +    adapter._cache_dm_topic_from_message("111", "999", "General")
    +
    +    assert adapter._dm_topics["111:General"] == 100  # unchanged
    +
    +
    +# ── _build_message_event: auto_skill binding ──
    +
    +
    +def _make_mock_message(chat_id=111, chat_type="private", text="hello", thread_id=None,
    +                       user_id=42, user_name="Test User", forum_topic_created=None):
    +    """Create a mock Telegram Message for _build_message_event tests."""
    +    chat = SimpleNamespace(
    +        id=chat_id,
    +        type=chat_type,
    +        title=None,
    +    )
    +    # Add full_name attribute for DM chats
    +    if not hasattr(chat, "full_name"):
    +        chat.full_name = user_name
    +
    +    user = SimpleNamespace(
    +        id=user_id,
    +        full_name=user_name,
    +    )
    +
    +    msg = SimpleNamespace(
    +        chat=chat,
    +        from_user=user,
    +        text=text,
    +        message_thread_id=thread_id,
    +        message_id=1001,
    +        reply_to_message=None,
    +        date=None,
    +        forum_topic_created=forum_topic_created,
    +    )
    +    return msg
    +
    +
    +def test_build_message_event_sets_auto_skill():
    +    """When topic has a skill binding, auto_skill should be set on the event."""
    +    from gateway.platforms.base import MessageType
    +
    +    adapter = _make_adapter([
    +        {
    +            "chat_id": 111,
    +            "topics": [
    +                {"name": "My Project", "skill": "accessibility-auditor", "thread_id": 100},
    +            ],
    +        }
    +    ])
    +    adapter._dm_topics["111:My Project"] = 100
    +
    +    msg = _make_mock_message(chat_id=111, thread_id=100, text="check this page")
    +    event = adapter._build_message_event(msg, MessageType.TEXT)
    +
    +    assert event.auto_skill == "accessibility-auditor"
    +    # chat_topic should be the clean topic name, no [skill: ...] suffix
    +    assert event.source.chat_topic == "My Project"
    +
    +
    +def test_build_message_event_no_auto_skill_without_binding():
    +    """Topics without skill binding should have auto_skill=None."""
    +    from gateway.platforms.base import MessageType
    +
    +    adapter = _make_adapter([
    +        {
    +            "chat_id": 111,
    +            "topics": [
    +                {"name": "General", "thread_id": 200},
    +            ],
    +        }
    +    ])
    +    adapter._dm_topics["111:General"] = 200
    +
    +    msg = _make_mock_message(chat_id=111, thread_id=200)
    +    event = adapter._build_message_event(msg, MessageType.TEXT)
    +
    +    assert event.auto_skill is None
    +    assert event.source.chat_topic == "General"
    +
    +
    +def test_build_message_event_no_auto_skill_without_thread():
    +    """Regular DM messages (no thread_id) should have auto_skill=None."""
    +    from gateway.platforms.base import MessageType
    +
    +    adapter = _make_adapter()
    +    msg = _make_mock_message(chat_id=111, thread_id=None)
    +    event = adapter._build_message_event(msg, MessageType.TEXT)
    +
    +    assert event.auto_skill is None
    ```
  - `website/docs/user-guide/messaging/telegram.md`

    ```diff
    @@ -165,8 +165,70 @@ Hermes Agent works in Telegram group chats with a few considerations:
     - When privacy mode is off (or bot is admin), the bot sees all messages and can participate naturally
     - `TELEGRAM_ALLOWED_USERS` still applies — only authorized users can trigger the bot, even in groups
     
    -## Recent Bot API Features (2024–2025)
    +## Private Chat Topics (Bot API 9.4)
     
    +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.
    +
    +### Use case
    +
    +If you work on several long-running projects, topics keep their context separate:
    +
    +- **Topic "Website"** — work on your production web service
    +- **Topic "Research"** — literature review and paper exploration
    +- **Topic "General"** — miscellaneous tasks and quick questions
    +
    +Each topic gets its own conversation session, history, and context — completely isolated from the others.
    +
    +### Configuration
    +
    +Add topics under `platforms.telegram.extra.dm_topics` in `~/.hermes/config.yaml`:
    +
    +```yaml
    +platforms:
    +  telegram:
    +    extra:
    +      dm_topics:
    +      - chat_id: 123456789        # Your Telegram user ID
    +        topics:
    +        - name: General
    +          icon_color: 7322096
    +        - name: Website
    +          icon_color: 9367192
    +        - name: Research
    +          icon_color: 16766590
    +          skill: arxiv              # Auto-load a skill in this topic
    +```
    +
    +**Fields:**
    +
    +| Field | Required | Description |
    +|-------|----------|-------------|
    +| `name` | Yes | Topic display name |
    +| `icon_color` | No | Telegram icon color code (integer) |
    +| `icon_custom_emoji_id` | No | Custom emoji ID for the topic icon |
    +| `skill` | No | Skill to auto-load on new sessions in this topic |
    +| `thread_id` | No | Auto-populated after topic creation — don't set manually |
    +
    +### How it works
    +
    +1. On gateway startup, Hermes calls `createForumTopic` for each topic that doesn't have a `thread_id` yet
    +2. The `thread_id` is saved back to `config.yaml` automatically — subsequent restarts skip the API call
    +3. Each topic maps to an isolated session key: `agent:main:telegram:dm:{chat_id}:{thread_id}`
    +4. Messages in each topic have their own conversation history, memory flush, and context window
    +
    +### Skill binding
    +
    +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.
    +
    +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`).
    +
    +:::tip
    +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.
    +:::
    +
    +## Recent Bot API Features
    +
    +- **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.
     - **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.
     - **Message streaming:** Bot API 9.x added support for streaming long responses, which can improve perceived latency for lengthy agent replies.
     
    ```
- **#3005: feat(telegram): Private Chat Topics support (Bot API 9.4)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#3163`

## Fix status

- Merged to main in PR #3163 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-03-26`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
- Subsystem: `gateway`
