# NousResearch/hermes-agent · Telegram connect timeout and gateway token fixes

## Open now

- Nothing open on GitHub.

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.

## 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 #56257. Verify it addresses your case.
- **Recommended:** read PR #56257 (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-56257`. 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

- #46298: Telegram gateway adapter times out connecting for valid bot tokens (Lynn/Stephen profiles)

## Prior & competing PRs

- **#56257: fix(telegram): defer post-connect housekeeping off the connect path (#46404)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `plugins/platforms/telegram/adapter.py`, `tests/gateway/test_telegram_conflict.py`
  - fix hunks:
  - `plugins/platforms/telegram/adapter.py`

    ```diff
    @@ -499,6 +499,11 @@ def __init__(self, config: PlatformConfig):
             # Tracks status bubbles owned by this adapter so subsequent calls with the
             # same key edit the same message instead of appending new ones (#30045).
             self._status_message_ids: Dict[tuple, str] = {}
    +        # Background task that runs post-connect housekeeping (command-menu
    +        # registration + DM-topic setup) off the connect path so a slow Bot
    +        # API call (e.g. a set_my_commands stall for certain tokens) cannot
    +        # blow the gateway's connect timeout (#46298).
    +        self._post_connect_task: Optional[asyncio.Task] = None
     
         def _mark_connected(self) -> None:
             self._drop_delayed_deliveries = False
    ```

    ```diff
    @@ -2540,6 +2545,95 @@ async def _setup_dm_topics(self) -> None:
                                 self.name, topic_name, seed_err,
                             )
     
    +    def _start_post_connect_housekeeping(self) -> None:
    +        """Kick off deferred post-connect housekeeping in the background.
    +
    +        Idempotent: if a previous housekeeping task is still running (e.g. a
    +        rapid reconnect), it is left in place rather than double-scheduled.
    +        """
    +        task = self._post_connect_task
    +        if task and not task.done():
    +            return
    +        self._post_connect_task = asyncio.ensure_future(
    +            self._run_post_connect_housekeeping()
    +        )
    +
    +    async def _run_post_connect_housekeeping(self) -> None:
    +        """Register the command menu, surface the status indicator, and set up
    +        DM topics — all off the connect path so a slow Bot API call cannot blow
    +        the gateway connect timeout (#46298). Every step is non-fatal."""
    +        try:
    +            # Register bot commands so Telegram shows a hint menu when users type /
    +            # List is derived from the central COMMAND_REGISTRY — adding a new
    +            # gateway command there automatically adds it to the Telegram menu.
    +            try:
    +                from telegram import (
    +                    BotCommand,
    +                    BotCommandScopeAllPrivateChats,
    +                    BotCommandScopeAllGroupChats,
    +                    BotCommandScopeDefault,
    +                )
    +                from hermes_cli.commands import telegram_menu_commands, telegram_menu_max_commands
    +                if not self._bot:
    +                    return
    +                # Telegram allows up to 100 commands but has an undocumented
    +                # payload size limit (~4KB total).  Hermes defaults to 60 to
    +                # keep built-ins plus common skill commands visible while
    +                # staying under the threshold; users can tune the cap via
    +                # platforms.telegram.extra.command_menu.
    +                max_commands = telegram_menu_max_commands()
    +                menu_commands, hidden_count = telegram_menu_commands(max_commands=max_commands)
    +                bot_commands = [BotCommand(name, desc) for name, desc in menu_commands]
    +                # Register for all scopes independently — Telegram picks the
    +                # narrowest matching scope per chat type (forum topics fall
    +                # through to AllGroupChats or Default).
    +                for scope_cls in (BotCommandScopeDefault, BotCommandScopeAllPrivateChats, BotCommandScopeAllGroupChats):
    +                    scope_name = getattr(scope_cls, "__name__", str(scope_cls))
    +                    try:
    +                        await self._bot.set_my_commands(bot_commands, scope=scope_cls())
    +                        logger.info("[%s] set_my_commands OK for scope %s (%d cmds)", self.name, scope_name, len(bot_commands))
    +                    except Exception as scope_err:
    +                        logger.warning("[%s] set_my_commands FAILED for scope %s: %s", self.name, scope_name, scope_err)
    +                # Forum topics don't inherit AllGroupChats — Telegram resolves
    +                # commands via BotCommandScopeChat(chat_id) for forum groups.
    +                # Lazy registration happens in _ensure_forum_commands on first
    +                # message from a forum topic (see _handle_text_message).
    +                if hidden_count:
    +                    logger.info(
    +                        "[%s] Telegram menu: %d commands registered, %d hidden (over %d limit). Use /commands for full list.",
    +                        self.name, len(menu_commands), hidden_count, max_commands,
    +                    )
    +            except Exception as e:
    +                logger.warning(
    +                    "[%s] Could not register Telegram command menu: %s",
    +                    self.name,
    +                    e,
    +                    exc_info=True,
    +                )
    +
    +            # Surface the gateway as "Online" in the bot's short description
    +            # (opt-in via extra.status_indicator). Non-fatal.
    +            try:
    +                await self._set_status_indicator(online=True)
    +            except Exception:
    +                pass
    +
    +            # 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,
    +                )
    +        except asyncio.CancelledError:
    +            raise
    +        finally:
    +            if self._post_connect_task is asyncio.current_task():
    +                self._post_connect_task = None
    +
         async def connect(self, *, is_reconnect: bool = False) -> bool:
             """Connect to Telegram via polling or webhook.
     
    ```

    ```diff
    @@ -2844,52 +2938,6 @@ def _polling_error_callback(error: Exception) -> None:
                         error_callback=_polling_error_callback,
                     )
                 
    -            # Register bot commands so Telegram shows a hint menu when users type /
    -            # List is derived from the central COMMAND_REGISTRY — adding a new
    -            # gateway command there automatically adds it to the Telegram menu.
    -            try:
    -                from telegram import (
    -                    BotCommand,
    -                    BotCommandScopeAllPrivateChats,
    -                    BotCommandScopeAllGroupChats,
    -                    BotCommandScopeDefault,
    -                )
    -                from hermes_cli.commands import telegram_menu_commands, telegram_menu_max_commands
    -                # Telegram allows up to 100 commands but has an undocumented
    -                # payload size limit (~4KB total).  Hermes defaults to 60 to
    -                # keep built-ins plus common skill commands visible while
    -                # staying under the threshold; users can tune the cap via
    -                # platforms.telegram.extra.command_menu.
    -                max_commands = telegram_menu_max_commands()
    -                menu_commands, hidden_count = telegram_menu_commands(max_commands=max_commands)
    -                bot_commands = [BotCommand(name, desc) for name, desc in menu_commands]
    -                # Register for all scopes independently — Telegram picks the
    -                # narrowest matching scope per chat type (forum topics fall
    -                # through to AllGroupChats or Default).
    -                for scope_cls in (BotCommandScopeDefault, BotCommandScopeAllPrivateChats, BotCommandScopeAllGroupChats):
    -                    scope_name = scope_cls.__name__
    -                    try:
    -                        await self._bot.set_my_commands(bot_commands, scope=scope_cls())
    -                        logger.info("[%s] set_my_commands OK for scope %s (%d cmds)", self.name, scope_name, len(bot_commands))
    -                    except Exception as scope_err:
    -                        logger.warning("[%s] set_my_commands FAILED for scope %s: %s", self.name, scope_name, scope_err)
    -                # Forum topics don't inherit AllGroupChats — Telegram resolves
    -                # commands via BotCommandScopeChat(chat_id) for forum groups.
    -                # Lazy registration happens in _ensure_forum_commands on first
    -                # message from a forum topic (see _handle_text_message).
    -                if hidden_count:
    -                    logger.info(
    -                        "[%s] Telegram menu: %d commands registered, %d hidden (over %d limit). Use /commands for full list.",
    -                        self.name, len(menu_commands), hidden_count, max_commands,
    -                    )
    -            except Exception as e:
    -                logger.warning(
    -                    "[%s] Could not register Telegram command menu: %s",
    -                    self.name,
    -                    e,
    -                    exc_info=True,
    -                )
    -            
                 self._mark_connected()
                 mode = "webhook" if self._webhook_mode else "polling"
                 logger.info("[%s] Connected to Telegram (%s mode)", self.name, mode)
    ```

    ```diff
    @@ -2904,23 +2952,15 @@ def _polling_error_callback(error: Exception) -> None:
                         self._polling_heartbeat_loop()
                     )
     
    -            # Surface the gateway as "Online" in the bot's short description
    -            # (opt-in via extra.status_indicator). Non-fatal.
    -            try:
    -                await self._set_status_indicator(online=True)
    -            except Exception:
    -                pass
    -
    -            # 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,
    -                )
    +            # Command-menu registration, DM-topic setup, and the status
    +            # indicator each make Bot API calls that can stall for certain
    +            # tokens. Running them here — inside the connect() coroutine that
    +            # the gateway wraps in a connect timeout — means one slow call
    +            # blows the whole connect and the adapter never comes up, even
    +            # though polling/webhook is already live (#46298). Defer them to a
    +            # cancellable background task so connect() returns as soon as the
    +            # transport is up.
    +            self._start_post_connect_housekeeping()
     
                 return True
                 
    ```

    ```diff
    @@ -3014,6 +3054,16 @@ async def disconnect(self) -> None:
             # from being scheduled by late update handlers.
             self._mark_disconnected()
     
    +        # Cancel deferred post-connect housekeeping (command-menu / DM-topic /
    +        # status-indicator Bot API calls) so it cannot fire into a half-torn-down
    +        # bot client (#46298). getattr guards the object.__new__ test pattern
    +        # where __init__ (which sets this attr) is never called.
    +        post_connect_task = getattr(self, "_post_connect_task", None)
    +        if post_connect_task and not post_connect_task.done():
    +            post_connect_task.cancel()
    +            await asyncio.gather(post_connect_task, return_exceptions=True)
    +        self._post_connect_task = None
    +
             # Cancel the heartbeat before tearing down the app so the probe task
             # cannot fire get_me() into a half-shutdown bot client.
             if self._polling_heartbeat_task and not self._polling_heartbeat_task.done():
    ```
  - `tests/gateway/test_telegram_conflict.py`

    ```diff
    @@ -313,6 +313,77 @@ async def test_connect_clears_webhook_before_polling(monkeypatch):
         await _cancel_heartbeat(adapter)
     
     
    [redacted:email]
    +async def test_connect_does_not_block_on_post_connect_housekeeping(monkeypatch):
    +    """Regression for #46298.
    +
    +    Command-menu registration and DM-topic setup make Bot API calls that can
    +    stall for certain tokens. If they run inside connect() (which the gateway
    +    wraps in a connect timeout), one slow call blows the whole connect and the
    +    adapter never comes up. connect() must return as soon as polling/webhook is
    +    live and defer that housekeeping to a cancellable background task.
    +    """
    +    adapter = TelegramAdapter(PlatformConfig(enabled=True, token="***"))
    +
    +    monkeypatch.setattr(
    +        "gateway.status.acquire_scoped_lock",
    +        lambda scope, identity, metadata=None: (True, None),
    +    )
    +    monkeypatch.setattr(
    +        "gateway.status.release_scoped_lock",
    +        lambda scope, identity: None,
    +    )
    +
    +    async def _hang_forever(*args, **kwargs):
    +        await asyncio.Future()
    +
    +    # Make the entire housekeeping coroutine hang. connect() must still return
    +    # promptly and expose the still-running task; disconnect() must cancel it.
    +    monkeypatch.setattr(adapter, "_run_post_connect_housekeeping", _hang_forever)
    +
    +    updater = SimpleNamespace(
    +        start_polling=AsyncMock(),
    +        stop=AsyncMock(),
    +        running=True,
    +    )
    +    bot = SimpleNamespace(
    +        delete_webhook=AsyncMock(),
    +        set_my_commands=AsyncMock(),
    +    )
    +    app = SimpleNamespace(
    +        bot=bot,
    +        updater=updater,
    +        add_handler=MagicMock(),
    +        initialize=AsyncMock(),
    +        start=AsyncMock(),
    +        running=True,
    +        stop=AsyncMock(),
    +        shutdown=AsyncMock(),
    +    )
    +    builder = MagicMock()
    +    builder.token.return_value = builder
    +    builder.request.return_value = builder
    +    builder.get_updates_request.return_value = builder
    +    builder.build.return_value = app
    +    monkeypatch.setattr(
    +        "plugins.platforms.telegram.adapter.Application",
    +        SimpleNamespace(builder=MagicMock(return_value=builder)),
    +    )
    +
    +    # A tight timeout: if connect() awaited the hanging set_my_commands this
    +    # would raise TimeoutError instead of returning.
    +    ok = await asyncio.wait_for(adapter.connect(), timeout=0.5)
    +
    +    assert ok is True
    +    assert adapter._post_connect_task is not None
    +    assert not adapter._post_connect_task.done()
    +
    +    # disconnect() must cancel the still-hanging housekeeping task cleanly.
    +    await adapter.disconnect()
    +    assert adapter._post_connect_task is None
    +    await _cancel_heartbeat(adapter)
    +
    +
     @pytest.mark.asyncio
     async def test_disconnect_skips_inactive_updater_and_app(monkeypatch):
         adapter = TelegramAdapter(PlatformConfig(enabled=True, token="***"))
    ```
- **#46404: fix(telegram): avoid connect timeout on post-connect tasks**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `3362bdb4e`, `#56257`

## Fix status

- Merged to main in PR #56257 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-01`.
- Subsystem: `gateway`
