{"repo":"NousResearch/hermes-agent","public_id":"46298","display_label":"Telegram connect timeout and gateway token fixes","summary":"These members address connection timeout issues in the Telegram gateway adapter caused by post-connect housekeeping blocking the connect path, alongside a related fix for primary gateway bot token resolution through profile secret scope.","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":56257,"merged_at":"2026-07-01T10:18:58+00:00"},"resolution":{"state":"resolved","by":"pr-56257"},"freshness":{"state":"verified","last_verified_at":"2026-07-01T10:18:58+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":46298,"pr_number":56257,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-01T10:18:58+00:00"}],"members":[{"kind":"issue","number":46298,"title":"Telegram gateway adapter times out connecting for valid bot tokens (Lynn/Stephen profiles)","url":null,"state":"closed"},{"kind":"pr","number":46404,"title":"fix(telegram): avoid connect timeout on post-connect tasks","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3362bdb4e","number":0},{"kind":"pr","value":"#56257","number":56257}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":56257,"title":"fix(telegram): defer post-connect housekeeping off the connect path (#46404)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["plugins/platforms/telegram/adapter.py","tests/gateway/test_telegram_conflict.py"],"fix_hunks":[{"file":"plugins/platforms/telegram/adapter.py","hunks":["@@ -499,6 +499,11 @@ def __init__(self, config: PlatformConfig):\n         # Tracks status bubbles owned by this adapter so subsequent calls with the\n         # same key edit the same message instead of appending new ones (#30045).\n         self._status_message_ids: Dict[tuple, str] = {}\n+        # Background task that runs post-connect housekeeping (command-menu\n+        # registration + DM-topic setup) off the connect path so a slow Bot\n+        # API call (e.g. a set_my_commands stall for certain tokens) cannot\n+        # blow the gateway's connect timeout (#46298).\n+        self._post_connect_task: Optional[asyncio.Task] = None\n \n     def _mark_connected(self) -> None:\n         self._drop_delayed_deliveries = False","@@ -2540,6 +2545,95 @@ async def _setup_dm_topics(self) -> None:\n                             self.name, topic_name, seed_err,\n                         )\n \n+    def _start_post_connect_housekeeping(self) -> None:\n+        \"\"\"Kick off deferred post-connect housekeeping in the background.\n+\n+        Idempotent: if a previous housekeeping task is still running (e.g. a\n+        rapid reconnect), it is left in place rather than double-scheduled.\n+        \"\"\"\n+        task = self._post_connect_task\n+        if task and not task.done():\n+            return\n+        self._post_connect_task = asyncio.ensure_future(\n+            self._run_post_connect_housekeeping()\n+        )\n+\n+    async def _run_post_connect_housekeeping(self) -> None:\n+        \"\"\"Register the command menu, surface the status indicator, and set up\n+        DM topics — all off the connect path so a slow Bot API call cannot blow\n+        the gateway connect timeout (#46298). Every step is non-fatal.\"\"\"\n+        try:\n+            # Register bot commands so Telegram shows a hint menu when users type /\n+            # List is derived from the central COMMAND_REGISTRY — adding a new\n+            # gateway command there automatically adds it to the Telegram menu.\n+            try:\n+                from telegram import (\n+                    BotCommand,\n+                    BotCommandScopeAllPrivateChats,\n+                    BotCommandScopeAllGroupChats,\n+                    BotCommandScopeDefault,\n+                )\n+                from hermes_cli.commands import telegram_menu_commands, telegram_menu_max_commands\n+                if not self._bot:\n+                    return\n+                # Telegram allows up to 100 commands but has an undocumented\n+                # payload size limit (~4KB total).  Hermes defaults to 60 to\n+                # keep built-ins plus common skill commands visible while\n+                # staying under the threshold; users can tune the cap via\n+                # platforms.telegram.extra.command_menu.\n+                max_commands = telegram_menu_max_commands()\n+                menu_commands, hidden_count = telegram_menu_commands(max_commands=max_commands)\n+                bot_commands = [BotCommand(name, desc) for name, desc in menu_commands]\n+                # Register for all scopes independently — Telegram picks the\n+                # narrowest matching scope per chat type (forum topics fall\n+                # through to AllGroupChats or Default).\n+                for scope_cls in (BotCommandScopeDefault, BotCommandScopeAllPrivateChats, BotCommandScopeAllGroupChats):\n+                    scope_name = getattr(scope_cls, \"__name__\", str(scope_cls))\n+                    try:\n+                        await self._bot.set_my_commands(bot_commands, scope=scope_cls())\n+                        logger.info(\"[%s] set_my_commands OK for scope %s (%d cmds)\", self.name, scope_name, len(bot_commands))\n+                    except Exception as scope_err:\n+                        logger.warning(\"[%s] set_my_commands FAILED for scope %s: %s\", self.name, scope_name, scope_err)\n+                # Forum topics don't inherit AllGroupChats — Telegram resolves\n+                # commands via BotCommandScopeChat(chat_id) for forum groups.\n+                # Lazy registration happens in _ensure_forum_commands on first\n+                # message from a forum topic (see _handle_text_message).\n+                if hidden_count:\n+                    logger.info(\n+                        \"[%s] Telegram menu: %d commands registered, %d hidden (over %d limit). Use /commands for full list.\",\n+                        self.name, len(menu_commands), hidden_count, max_commands,\n+                    )\n+            except Exception as e:\n+                logger.warning(\n+                    \"[%s] Could not register Telegram command menu: %s\",\n+                    self.name,\n+                    e,\n+                    exc_info=True,\n+                )\n+\n+            # Surface the gateway as \"Online\" in the bot's short description\n+            # (opt-in via extra.status_indicator). Non-fatal.\n+            try:\n+                await self._set_status_indicator(online=True)\n+            except Exception:\n+                pass\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+        except asyncio.CancelledError:\n+            raise\n+        finally:\n+            if self._post_connect_task is asyncio.current_task():\n+                self._post_connect_task = None\n+\n     async def connect(self, *, is_reconnect: bool = False) -> bool:\n         \"\"\"Connect to Telegram via polling or webhook.\n ","@@ -2844,52 +2938,6 @@ def _polling_error_callback(error: Exception) -> None:\n                     error_callback=_polling_error_callback,\n                 )\n             \n-            # Register bot commands so Telegram shows a hint menu when users type /\n-            # List is derived from the central COMMAND_REGISTRY — adding a new\n-            # gateway command there automatically adds it to the Telegram menu.\n-            try:\n-                from telegram import (\n-                    BotCommand,\n-                    BotCommandScopeAllPrivateChats,\n-                    BotCommandScopeAllGroupChats,\n-                    BotCommandScopeDefault,\n-                )\n-                from hermes_cli.commands import telegram_menu_commands, telegram_menu_max_commands\n-                # Telegram allows up to 100 commands but has an undocumented\n-                # payload size limit (~4KB total).  Hermes defaults to 60 to\n-                # keep built-ins plus common skill commands visible while\n-                # staying under the threshold; users can tune the cap via\n-                # platforms.telegram.extra.command_menu.\n-                max_commands = telegram_menu_max_commands()\n-                menu_commands, hidden_count = telegram_menu_commands(max_commands=max_commands)\n-                bot_commands = [BotCommand(name, desc) for name, desc in menu_commands]\n-                # Register for all scopes independently — Telegram picks the\n-                # narrowest matching scope per chat type (forum topics fall\n-                # through to AllGroupChats or Default).\n-                for scope_cls in (BotCommandScopeDefault, BotCommandScopeAllPrivateChats, BotCommandScopeAllGroupChats):\n-                    scope_name = scope_cls.__name__\n-                    try:\n-                        await self._bot.set_my_commands(bot_commands, scope=scope_cls())\n-                        logger.info(\"[%s] set_my_commands OK for scope %s (%d cmds)\", self.name, scope_name, len(bot_commands))\n-                    except Exception as scope_err:\n-                        logger.warning(\"[%s] set_my_commands FAILED for scope %s: %s\", self.name, scope_name, scope_err)\n-                # Forum topics don't inherit AllGroupChats — Telegram resolves\n-                # commands via BotCommandScopeChat(chat_id) for forum groups.\n-                # Lazy registration happens in _ensure_forum_commands on first\n-                # message from a forum topic (see _handle_text_message).\n-                if hidden_count:\n-                    logger.info(\n-                        \"[%s] Telegram menu: %d commands registered, %d hidden (over %d limit). Use /commands for full list.\",\n-                        self.name, len(menu_commands), hidden_count, max_commands,\n-                    )\n-            except Exception as e:\n-                logger.warning(\n-                    \"[%s] Could not register Telegram command menu: %s\",\n-                    self.name,\n-                    e,\n-                    exc_info=True,\n-                )\n-            \n             self._mark_connected()\n             mode = \"webhook\" if self._webhook_mode else \"polling\"\n             logger.info(\"[%s] Connected to Telegram (%s mode)\", self.name, mode)","@@ -2904,23 +2952,15 @@ def _polling_error_callback(error: Exception) -> None:\n                     self._polling_heartbeat_loop()\n                 )\n \n-            # Surface the gateway as \"Online\" in the bot's short description\n-            # (opt-in via extra.status_indicator). Non-fatal.\n-            try:\n-                await self._set_status_indicator(online=True)\n-            except Exception:\n-                pass\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+            # Command-menu registration, DM-topic setup, and the status\n+            # indicator each make Bot API calls that can stall for certain\n+            # tokens. Running them here — inside the connect() coroutine that\n+            # the gateway wraps in a connect timeout — means one slow call\n+            # blows the whole connect and the adapter never comes up, even\n+            # though polling/webhook is already live (#46298). Defer them to a\n+            # cancellable background task so connect() returns as soon as the\n+            # transport is up.\n+            self._start_post_connect_housekeeping()\n \n             return True\n             ","@@ -3014,6 +3054,16 @@ async def disconnect(self) -> None:\n         # from being scheduled by late update handlers.\n         self._mark_disconnected()\n \n+        # Cancel deferred post-connect housekeeping (command-menu / DM-topic /\n+        # status-indicator Bot API calls) so it cannot fire into a half-torn-down\n+        # bot client (#46298). getattr guards the object.__new__ test pattern\n+        # where __init__ (which sets this attr) is never called.\n+        post_connect_task = getattr(self, \"_post_connect_task\", None)\n+        if post_connect_task and not post_connect_task.done():\n+            post_connect_task.cancel()\n+            await asyncio.gather(post_connect_task, return_exceptions=True)\n+        self._post_connect_task = None\n+\n         # Cancel the heartbeat before tearing down the app so the probe task\n         # cannot fire get_me() into a half-shutdown bot client.\n         if self._polling_heartbeat_task and not self._polling_heartbeat_task.done():"]},{"file":"tests/gateway/test_telegram_conflict.py","hunks":["@@ -313,6 +313,77 @@ async def test_connect_clears_webhook_before_polling(monkeypatch):\n     await _cancel_heartbeat(adapter)\n \n \n[redacted:email]\n+async def test_connect_does_not_block_on_post_connect_housekeeping(monkeypatch):\n+    \"\"\"Regression for #46298.\n+\n+    Command-menu registration and DM-topic setup make Bot API calls that can\n+    stall for certain tokens. If they run inside connect() (which the gateway\n+    wraps in a connect timeout), one slow call blows the whole connect and the\n+    adapter never comes up. connect() must return as soon as polling/webhook is\n+    live and defer that housekeeping to a cancellable background task.\n+    \"\"\"\n+    adapter = TelegramAdapter(PlatformConfig(enabled=True, token=\"***\"))\n+\n+    monkeypatch.setattr(\n+        \"gateway.status.acquire_scoped_lock\",\n+        lambda scope, identity, metadata=None: (True, None),\n+    )\n+    monkeypatch.setattr(\n+        \"gateway.status.release_scoped_lock\",\n+        lambda scope, identity: None,\n+    )\n+\n+    async def _hang_forever(*args, **kwargs):\n+        await asyncio.Future()\n+\n+    # Make the entire housekeeping coroutine hang. connect() must still return\n+    # promptly and expose the still-running task; disconnect() must cancel it.\n+    monkeypatch.setattr(adapter, \"_run_post_connect_housekeeping\", _hang_forever)\n+\n+    updater = SimpleNamespace(\n+        start_polling=AsyncMock(),\n+        stop=AsyncMock(),\n+        running=True,\n+    )\n+    bot = SimpleNamespace(\n+        delete_webhook=AsyncMock(),\n+        set_my_commands=AsyncMock(),\n+    )\n+    app = SimpleNamespace(\n+        bot=bot,\n+        updater=updater,\n+        add_handler=MagicMock(),\n+        initialize=AsyncMock(),\n+        start=AsyncMock(),\n+        running=True,\n+        stop=AsyncMock(),\n+        shutdown=AsyncMock(),\n+    )\n+    builder = MagicMock()\n+    builder.token.return_value = builder\n+    builder.request.return_value = builder\n+    builder.get_updates_request.return_value = builder\n+    builder.build.return_value = app\n+    monkeypatch.setattr(\n+        \"plugins.platforms.telegram.adapter.Application\",\n+        SimpleNamespace(builder=MagicMock(return_value=builder)),\n+    )\n+\n+    # A tight timeout: if connect() awaited the hanging set_my_commands this\n+    # would raise TimeoutError instead of returning.\n+    ok = await asyncio.wait_for(adapter.connect(), timeout=0.5)\n+\n+    assert ok is True\n+    assert adapter._post_connect_task is not None\n+    assert not adapter._post_connect_task.done()\n+\n+    # disconnect() must cancel the still-hanging housekeeping task cleanly.\n+    await adapter.disconnect()\n+    assert adapter._post_connect_task is None\n+    await _cancel_heartbeat(adapter)\n+\n+\n @pytest.mark.asyncio\n async def test_disconnect_skips_inactive_updater_and_app(monkeypatch):\n     adapter = TelegramAdapter(PlatformConfig(enabled=True, token=\"***\"))\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}