NousResearch/hermes-agent

Telegram private chat topics feature development

Cluster 2598 · gateway. Data as of

Nothing open on GitHub. Fix merged in #3163 on 26 Mar 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #3163. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #3163. That is separate from the fix event.
Still open
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #3163 feat(telegram): Private Chat Topics with functional skill binding (#2598)

    merged to main

    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

    Show the diff
    • gateway/platforms/base.py
      @@ -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
      @@ -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:
      @@ -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:
      @@ -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:
      @@ -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
      @@ -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),
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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.
       

1 PR closed without merging

From the close comments: merged via another change 1

  • #3005 feat(telegram): Private Chat Topics support (Bot API 9.4) · merged via another change → #3163
Closed issues · 1
  • Closed#2598Feature: Telegram Private Chat Topics support for project-based conversations
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
gateway
Merged PRs
#3163
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
26 Mar 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.