# NousResearch/hermes-agent · Telegram forum topic send_message delivery errors and thread-not-found retry

## Open now

- Nothing open on GitHub.

The members concern send_message not delivering to Telegram forum topics (addressed as telegram:chat_id:thread_id), along with two identically titled items proposing a thread-not-found retry for those sends. Titles alone do not show whether the retry works or whether the problem is closed out.

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

- #27012: send_message fails to deliver to Telegram forum topics (telegram:chat_id:thread_id)

## Prior & competing PRs

- **#28514: fix(send_message): add thread-not-found retry for Telegram forum topic sends (#27012)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `scripts/release.py`, `tests/tools/test_send_message_tool.py`, `tools/send_message_tool.py`
  - fix hunks:
  - `scripts/release.py`

    ```diff
    @@ -176,6 +176,7 @@
         "197455947+samahn0601@users.noreply.github.com": "samahn0601",  # PR #27887 salvage (retry wrapped connect timeouts)
         "[redacted:email]": "aqilaziz",  # PR #26406 salvage (preserve native audio outside Telegram)
         "karthikeyann@users.noreply.github.com": "karthikeyann",  # PR #26609 salvage (DM-topic routing pin)
    +    "[redacted:email]": "kunci115",  # PR #27098 salvage (thread-not-found retry)
         "282919977+eliteworkstation94-ai@users.noreply.github.com": "eliteworkstation94-ai",  # PR #28157 salvage (group reply session splits)
         "[redacted:email]": "hllqkb",
         "25840394+Bongulielmi@users.noreply.github.com": "Bongulielmi",
    ```
  - `tests/tools/test_send_message_tool.py`

    ```diff
    @@ -23,6 +23,7 @@ def _reset_signal_scheduler():
     from gateway.config import Platform
     from tools.send_message_tool import (
         _derive_forum_thread_name,
    +    _is_telegram_thread_not_found,
         _parse_target_ref,
         _send_discord,
         _send_matrix_via_adapter,
    ```

    ```diff
    @@ -876,6 +877,59 @@ def test_general_topic_thread_id_int_input_also_dropped(self, monkeypatch):
             kwargs = bot.send_message.await_args.kwargs
             assert "message_thread_id" not in kwargs
     
    +    def test_thread_not_found_retries_without_message_thread_id(self, monkeypatch):
    +        """When send_message raises "thread not found", retry without thread_id (#27012)."""
    +        bot = self._make_bot()
    +        _install_telegram_mock(monkeypatch, bot)
    +
    +        # First call raises thread-not-found, second succeeds
    +        bot.send_message = AsyncMock(side_effect=[
    +            Exception("Bad Request: message thread not found"),
    +            SimpleNamespace(message_id=2),
    +        ])
    +
    +        asyncio.run(
    +            _send_telegram("tok", "-1001234567890", "hello", thread_id="17585")
    +        )
    +
    +        assert bot.send_message.await_count == 2
    +        # First call: should include message_thread_id=17585
    +        call1_kwargs = bot.send_message.await_args_list[0].kwargs
    +        assert call1_kwargs["message_thread_id"] == 17585
    +        # Second call (retry): should NOT include message_thread_id
    +        call2_kwargs = bot.send_message.await_args_list[1].kwargs
    +        assert "message_thread_id" not in call2_kwargs
    +
    +    def test_thread_not_found_for_media_retries_without_message_thread_id(self, monkeypatch, tmp_path):
    +        """Media send with stale thread_id retries without it (#27012)."""
    +        bot = self._make_bot()
    +        # Mock send_document to fail with thread-not-found, then succeed
    +        bot.send_document = AsyncMock(side_effect=[
    +            Exception("Bad Request: message thread not found"),
    +            SimpleNamespace(message_id=3),
    +        ])
    +        _install_telegram_mock(monkeypatch, bot)
    +
    +        # Create a test file
    +        test_file = tmp_path / "doc.txt"
    +        test_file.write_text("test content")
    +
    +        asyncio.run(
    +            _send_telegram(
    +                "tok", "-1001234567890", "",
    +                media_files=[(str(test_file), False)],
    +                thread_id="17585",
    +            )
    +        )
    +
    +        assert bot.send_document.await_count == 2
    +        # First call: should include message_thread_id=17585
    +        call1_kwargs = bot.send_document.await_args_list[0].kwargs
    +        assert call1_kwargs["message_thread_id"] == 17585
    +        # Second call (retry): should NOT include message_thread_id
    +        call2_kwargs = bot.send_document.await_args_list[1].kwargs
    +        assert "message_thread_id" not in call2_kwargs
    +
     
     # ---------------------------------------------------------------------------
     # Tests for Discord thread_id support
    ```

    ```diff
    @@ -2425,3 +2479,94 @@ def test_gateway_status_import_error_is_swallowed(self, monkeypatch):
                  patch("gateway.status.is_gateway_running",
                        side_effect=ImportError("simulated")):
                 assert _check_send_message() is False
    +
    +
    +class TestSendTelegramThreadNotFoundRetry:
    +    """Tests for thread-not-found retry behaviour in _send_telegram (#27012)."""
    +
    +    def test_is_thread_not_found_matches_expected_errors(self):
    +        """_is_telegram_thread_not_found should detect thread-not-found errors."""
    +        class FakeError(Exception):
    +            pass
    +
    +        assert _is_telegram_thread_not_found(FakeError("message thread not found")) is True
    +        assert _is_telegram_thread_not_found(FakeError("THREAD NOT FOUND")) is True
    +        assert _is_telegram_thread_not_found(FakeError("Bad Request: thread not found")) is True
    +        assert _is_telegram_thread_not_found(FakeError("chat not found")) is False
    +        assert _is_telegram_thread_not_found(FakeError("parse error")) is False
    +        assert _is_telegram_thread_not_found(FakeError("")) is False
    +
    +    def test_text_send_retries_without_thread_id_on_thread_not_found(self):
    +        """When thread is not found, the text send should retry without
    +        message_thread_id."""
    +        call_args = []
    +
    +        async def fake_retry(bot, *, chat_id, text, parse_mode, **kwargs):
    +            call_args.append(dict(kwargs, chat_id=chat_id, text=text))
    +            if len(call_args) == 1:
    +                raise Exception("Bad Request: message thread not found")
    +            return SimpleNamespace(message_id=42)
    +
    +        async def run_test():
    +            with patch(
    +                "tools.send_message_tool._send_telegram_message_with_retry",
    +                fake_retry,
    +            ):
    +                # _send_telegram imports Bot locally; we only need to mock
    +                # the send path, not Bot itself (Bot import falls through
    +                # normally since python-telegram-bot is installed).
    +                return await _send_telegram(
    +                    "fake-token", "-100123", "hello from topic 17585",
    +                    thread_id="17585",
    +                )
    +
    +        result = asyncio.run(run_test())
    +        assert result["success"] is True
    +        assert result["message_id"] == "42"
    +        assert len(call_args) == 2, f"expected 2 calls, got {len(call_args)}"
    +        # First call should have message_thread_id
    +        assert call_args[0].get("message_thread_id") is not None
    +        # Second call (retry) should NOT have message_thread_id
    +        assert "message_thread_id" not in call_args[1], \
    +            "retry should drop message_thread_id after thread-not-found"
    +
    +    def test_disable_web_page_preview_not_leaked_to_media_sends(self):
    +        """disable_web_page_preview should only appear in text send, not media sends."""
    +        text_kwargs_seen = []
    +        media_kwargs_seen = []
    +
    +        class FakeBot:
    +            async def send_message(self, **kwargs):
    +                text_kwargs_seen.append(kwargs)
    +                return SimpleNamespace(message_id=1)
    +
    +            async def send_document(self, **kwargs):
    +                media_kwargs_seen.append(kwargs)
    +                return SimpleNamespace(message_id=2)
    +
    +        import tempfile
    +        media_path = None
    +        try:
    +            with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tf:
    +                tf.write(b"%PDF-1.4 test content")
    +                media_path = tf.name
    +
    +            async def run_test():
    +                with patch("telegram.Bot", return_value=FakeBot()):
    +                    return await _send_telegram(
    +                        "fake-token", "-100123", "check preview",
    +                        media_files=[(media_path, False)],
    +                        disable_link_previews=True,
    +                    )
    +
    +            result = asyncio.run(run_test())
    +            assert result["success"] is True
    +            # Text send should have disable_web_page_preview
    +            assert text_kwargs_seen[0].get("disable_web_page_preview") is True
    +            # Media send should NOT have disable_web_page_preview
    +            assert "disable_web_page_preview" not in media_kwargs_seen[0], \
    +                "disable_web_page_preview leaked into send_document kwargs"
    +        finally:
    +            if media_path and os.path.exists(media_path):
    +                os.unlink(media_path)
    +
    ```
  - `tools/send_message_tool.py`

    ```diff
    @@ -786,6 +786,15 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
         return last_result
     
     
    +def _is_telegram_thread_not_found(error: Exception) -> bool:
    +    """Check if a Telegram error is a thread-not-found failure.
    +
    +    Matches the gateway adapter's ``_is_thread_not_found_error`` for
    +    the standalone ``_send_telegram`` path (issue #27012).
    +    """
    +    return "thread not found" in str(error).lower()
    +
    +
     async def _send_telegram(token, chat_id, message, media_files=None, thread_id=None, disable_link_previews=False, force_document=False):
         """Send via Telegram Bot API (one-shot, no polling needed).
     
    ```

    ```diff
    @@ -873,8 +882,12 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No
                     )
                 if effective_thread_id is not None:
                     thread_kwargs["message_thread_id"] = effective_thread_id
    +        # disable_web_page_preview is only valid for send_message, not
    +        # send_photo/send_video/etc.  Keep it separate so media sends
    +        # don't inherit an invalid parameter (issue #27012).
    +        text_kwargs = dict(thread_kwargs)
             if disable_link_previews:
    -            thread_kwargs["disable_web_page_preview"] = True
    +            text_kwargs["disable_web_page_preview"] = True
     
             last_msg = None
             warnings = []
    ```

    ```diff
    @@ -884,11 +897,24 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No
                     last_msg = await _send_telegram_message_with_retry(
                         bot,
                         chat_id=int_chat_id, text=formatted,
    -                    parse_mode=send_parse_mode, entities=_entities, **thread_kwargs
    +                    parse_mode=send_parse_mode, entities=_entities, **text_kwargs
                     )
                 except Exception as md_error:
    -                # Parse failed, fall back to plain text
    -                if "parse" in str(md_error).lower() or "markdown" in str(md_error).lower() or "html" in str(md_error).lower():
    +                # Thread not found — retry without message_thread_id so the
    +                # message still delivers (matching the gateway adapter's
    +                # fallback behaviour, issue #27012).
    +                if _is_telegram_thread_not_found(md_error) and thread_kwargs:
    +                    logger.warning(
    +                        "Thread %s not found in _send_telegram, retrying without message_thread_id",
    +                        thread_kwargs.get("message_thread_id"),
    +                    )
    +                    text_kwargs.pop("message_thread_id", None)
    +                    last_msg = await _send_telegram_message_with_retry(
    +                        bot,
    +                        chat_id=int_chat_id, text=formatted,
    +                        parse_mode=send_parse_mode, **text_kwargs
    +                    )
    +                elif "parse" in str(md_error).lower() or "markdown" in str(md_error).lower() or "html" in str(md_error).lower():
                         logger.warning(
                             "Parse mode %s failed in _send_telegram, falling back to plain text: %s",
                             send_parse_mode,
    ```

    ```diff
    @@ -905,7 +931,7 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No
                         last_msg = await _send_telegram_message_with_retry(
                             bot,
                             chat_id=int_chat_id, text=plain,
    -                        parse_mode=None, entities=_entities, **thread_kwargs
    +                        parse_mode=None, entities=_entities, **text_kwargs
                         )
                     else:
                         raise
    ```

    ```diff
    @@ -920,26 +946,61 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No
                 ext = os.path.splitext(media_path)[1].lower()
                 try:
                     with open(media_path, "rb") as f:
    -                    if ext in _IMAGE_EXTS and not force_document:
    -                        last_msg = await bot.send_photo(
    -                            chat_id=int_chat_id, photo=f, **thread_kwargs
    -                        )
    -                    elif ext in _VIDEO_EXTS:
    -                        last_msg = await bot.send_video(
    -                            chat_id=int_chat_id, video=f, **thread_kwargs
    -                        )
    -                    elif ext in _VOICE_EXTS and is_voice:
    -                        last_msg = await bot.send_voice(
    -                            chat_id=int_chat_id, voice=f, **thread_kwargs
    -                        )
    -                    elif ext in _TELEGRAM_SEND_AUDIO_EXTS:
    -                        last_msg = await bot.send_audio(
    -                            chat_id=int_chat_id, audio=f, **thread_kwargs
    -                        )
    -                    else:
    -                        last_msg = await bot.send_document(
    -                            chat_id=int_chat_id, document=f, **thread_kwargs
    -                        )
    +                    media_kwargs = dict(thread_kwargs)
    +                    try:
    +                        if ext in _IMAGE_EXTS and not force_document:
    +                            last_msg = await bot.send_photo(
    +                                chat_id=int_chat_id, photo=f, **media_kwargs
    +                            )
    +                        elif ext in _VIDEO_EXTS:
    +                            last_msg = await bot.send_video(
    +                                chat_id=int_chat_id, video=f, **media_kwargs
    +                            )
    +                        elif ext in _VOICE_EXTS and is_voice:
    +                            last_msg = await bot.send_voice(
    +                                chat_id=int_chat_id, voice=f, **media_kwargs
    +                            )
    +                        elif ext in _TELEGRAM_SEND_AUDIO_EXTS:
    +                            last_msg = await bot.send_audio(
    +                                chat_id=int_chat_id, audio=f, **media_kwargs
    +                            )
    +                        else:
    +                            last_msg = await bot.send_document(
    +                                chat_id=int_chat_id, document=f, **media_kwargs
    +                            )
    +                    except Exception as media_err:
    +                        if _is_telegram_thread_not_found(media_err) and media_kwargs.get("message_thread_id"):
    +                            # Thread not found for media — retry without
    +                            # message_thread_id (issue #27012).
    +                            logger.warning(
    +                                "Thread %s not found for media send, retrying without message_thread_id",
    +                                media_kwargs["message_thread_id"],
    +                            )
    +                            # Re-seek the file since the first attempt consumed it
    +                            f.seek(0)
    +                            media_kwargs.pop("message_thread_id", None)
    +                            if ext in _IMAGE_EXTS and not force_document:
    +                                last_msg = await bot.send_photo(
    +                                    chat_id=int_chat_id, photo=f, **media_kwargs
    +                                )
    +                            elif ext in _VIDEO_EXTS:
    +                                last_msg = await bot.send_video(
    +                                    chat_id=int_chat_id, video=f, **media_kwargs
    +                                )
    +                            elif ext in _VOICE_EXTS and is_voice:
    +                                last_msg = await bot.send_voice(
    +                                    chat_id=int_chat_id, voice=f, **media_kwargs
    +                                )
    +                            elif ext in _TELEGRAM_SEND_AUDIO_EXTS:
    +                                last_msg = await bot.send_audio(
    +                                    chat_id=int_chat_id, audio=f, **media_kwargs
    +                                )
    +                            else:
    +                                last_msg = await bot.send_document(
    +                                    chat_id=int_chat_id, document=f, **media_kwargs
    +                                )
    +                        else:
    +                            raise
                 except Exception as e:
                     warning = _sanitize_error_text(f"Failed to send media {media_path}: {e}")
                     logger.error(warning)
    ```
- **#27098: fix(send_message): add thread-not-found retry for Telegram forum topic sends (#27012)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#28514`

## Fix status

- Merged to main in PR #28514 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-19`.
