{"repo":"NousResearch/hermes-agent","public_id":"27012","display_label":"Telegram forum topic send_message delivery errors and thread-not-found retry","summary":"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.","subsystem":null,"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":28514,"merged_at":"2026-05-19T05:32:01+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-05-19T05:32:01+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":27012,"pr_number":28514,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-05-19T05:32:01+00:00"}],"members":[{"kind":"issue","number":27012,"title":"send_message fails to deliver to Telegram forum topics (telegram:chat_id:thread_id)","url":null,"state":"closed"},{"kind":"pr","number":27098,"title":"fix(send_message): add thread-not-found retry for Telegram forum topic sends (#27012)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#28514","number":28514}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":28514,"title":"fix(send_message): add thread-not-found retry for Telegram forum topic sends (#27012)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["scripts/release.py","tests/tools/test_send_message_tool.py","tools/send_message_tool.py"],"fix_hunks":[{"file":"scripts/release.py","hunks":["@@ -176,6 +176,7 @@\n     \"197455947+samahn0601@users.noreply.github.com\": \"samahn0601\",  # PR #27887 salvage (retry wrapped connect timeouts)\n     \"[redacted:email]\": \"aqilaziz\",  # PR #26406 salvage (preserve native audio outside Telegram)\n     \"karthikeyann@users.noreply.github.com\": \"karthikeyann\",  # PR #26609 salvage (DM-topic routing pin)\n+    \"[redacted:email]\": \"kunci115\",  # PR #27098 salvage (thread-not-found retry)\n     \"282919977+eliteworkstation94-ai@users.noreply.github.com\": \"eliteworkstation94-ai\",  # PR #28157 salvage (group reply session splits)\n     \"[redacted:email]\": \"hllqkb\",\n     \"25840394+Bongulielmi@users.noreply.github.com\": \"Bongulielmi\","]},{"file":"tests/tools/test_send_message_tool.py","hunks":["@@ -23,6 +23,7 @@ def _reset_signal_scheduler():\n from gateway.config import Platform\n from tools.send_message_tool import (\n     _derive_forum_thread_name,\n+    _is_telegram_thread_not_found,\n     _parse_target_ref,\n     _send_discord,\n     _send_matrix_via_adapter,","@@ -876,6 +877,59 @@ def test_general_topic_thread_id_int_input_also_dropped(self, monkeypatch):\n         kwargs = bot.send_message.await_args.kwargs\n         assert \"message_thread_id\" not in kwargs\n \n+    def test_thread_not_found_retries_without_message_thread_id(self, monkeypatch):\n+        \"\"\"When send_message raises \"thread not found\", retry without thread_id (#27012).\"\"\"\n+        bot = self._make_bot()\n+        _install_telegram_mock(monkeypatch, bot)\n+\n+        # First call raises thread-not-found, second succeeds\n+        bot.send_message = AsyncMock(side_effect=[\n+            Exception(\"Bad Request: message thread not found\"),\n+            SimpleNamespace(message_id=2),\n+        ])\n+\n+        asyncio.run(\n+            _send_telegram(\"tok\", \"-1001234567890\", \"hello\", thread_id=\"17585\")\n+        )\n+\n+        assert bot.send_message.await_count == 2\n+        # First call: should include message_thread_id=17585\n+        call1_kwargs = bot.send_message.await_args_list[0].kwargs\n+        assert call1_kwargs[\"message_thread_id\"] == 17585\n+        # Second call (retry): should NOT include message_thread_id\n+        call2_kwargs = bot.send_message.await_args_list[1].kwargs\n+        assert \"message_thread_id\" not in call2_kwargs\n+\n+    def test_thread_not_found_for_media_retries_without_message_thread_id(self, monkeypatch, tmp_path):\n+        \"\"\"Media send with stale thread_id retries without it (#27012).\"\"\"\n+        bot = self._make_bot()\n+        # Mock send_document to fail with thread-not-found, then succeed\n+        bot.send_document = AsyncMock(side_effect=[\n+            Exception(\"Bad Request: message thread not found\"),\n+            SimpleNamespace(message_id=3),\n+        ])\n+        _install_telegram_mock(monkeypatch, bot)\n+\n+        # Create a test file\n+        test_file = tmp_path / \"doc.txt\"\n+        test_file.write_text(\"test content\")\n+\n+        asyncio.run(\n+            _send_telegram(\n+                \"tok\", \"-1001234567890\", \"\",\n+                media_files=[(str(test_file), False)],\n+                thread_id=\"17585\",\n+            )\n+        )\n+\n+        assert bot.send_document.await_count == 2\n+        # First call: should include message_thread_id=17585\n+        call1_kwargs = bot.send_document.await_args_list[0].kwargs\n+        assert call1_kwargs[\"message_thread_id\"] == 17585\n+        # Second call (retry): should NOT include message_thread_id\n+        call2_kwargs = bot.send_document.await_args_list[1].kwargs\n+        assert \"message_thread_id\" not in call2_kwargs\n+\n \n # ---------------------------------------------------------------------------\n # Tests for Discord thread_id support","@@ -2425,3 +2479,94 @@ def test_gateway_status_import_error_is_swallowed(self, monkeypatch):\n              patch(\"gateway.status.is_gateway_running\",\n                    side_effect=ImportError(\"simulated\")):\n             assert _check_send_message() is False\n+\n+\n+class TestSendTelegramThreadNotFoundRetry:\n+    \"\"\"Tests for thread-not-found retry behaviour in _send_telegram (#27012).\"\"\"\n+\n+    def test_is_thread_not_found_matches_expected_errors(self):\n+        \"\"\"_is_telegram_thread_not_found should detect thread-not-found errors.\"\"\"\n+        class FakeError(Exception):\n+            pass\n+\n+        assert _is_telegram_thread_not_found(FakeError(\"message thread not found\")) is True\n+        assert _is_telegram_thread_not_found(FakeError(\"THREAD NOT FOUND\")) is True\n+        assert _is_telegram_thread_not_found(FakeError(\"Bad Request: thread not found\")) is True\n+        assert _is_telegram_thread_not_found(FakeError(\"chat not found\")) is False\n+        assert _is_telegram_thread_not_found(FakeError(\"parse error\")) is False\n+        assert _is_telegram_thread_not_found(FakeError(\"\")) is False\n+\n+    def test_text_send_retries_without_thread_id_on_thread_not_found(self):\n+        \"\"\"When thread is not found, the text send should retry without\n+        message_thread_id.\"\"\"\n+        call_args = []\n+\n+        async def fake_retry(bot, *, chat_id, text, parse_mode, **kwargs):\n+            call_args.append(dict(kwargs, chat_id=chat_id, text=text))\n+            if len(call_args) == 1:\n+                raise Exception(\"Bad Request: message thread not found\")\n+            return SimpleNamespace(message_id=42)\n+\n+        async def run_test():\n+            with patch(\n+                \"tools.send_message_tool._send_telegram_message_with_retry\",\n+                fake_retry,\n+            ):\n+                # _send_telegram imports Bot locally; we only need to mock\n+                # the send path, not Bot itself (Bot import falls through\n+                # normally since python-telegram-bot is installed).\n+                return await _send_telegram(\n+                    \"fake-token\", \"-100123\", \"hello from topic 17585\",\n+                    thread_id=\"17585\",\n+                )\n+\n+        result = asyncio.run(run_test())\n+        assert result[\"success\"] is True\n+        assert result[\"message_id\"] == \"42\"\n+        assert len(call_args) == 2, f\"expected 2 calls, got {len(call_args)}\"\n+        # First call should have message_thread_id\n+        assert call_args[0].get(\"message_thread_id\") is not None\n+        # Second call (retry) should NOT have message_thread_id\n+        assert \"message_thread_id\" not in call_args[1], \\\n+            \"retry should drop message_thread_id after thread-not-found\"\n+\n+    def test_disable_web_page_preview_not_leaked_to_media_sends(self):\n+        \"\"\"disable_web_page_preview should only appear in text send, not media sends.\"\"\"\n+        text_kwargs_seen = []\n+        media_kwargs_seen = []\n+\n+        class FakeBot:\n+            async def send_message(self, **kwargs):\n+                text_kwargs_seen.append(kwargs)\n+                return SimpleNamespace(message_id=1)\n+\n+            async def send_document(self, **kwargs):\n+                media_kwargs_seen.append(kwargs)\n+                return SimpleNamespace(message_id=2)\n+\n+        import tempfile\n+        media_path = None\n+        try:\n+            with tempfile.NamedTemporaryFile(suffix=\".pdf\", delete=False) as tf:\n+                tf.write(b\"%PDF-1.4 test content\")\n+                media_path = tf.name\n+\n+            async def run_test():\n+                with patch(\"telegram.Bot\", return_value=FakeBot()):\n+                    return await _send_telegram(\n+                        \"fake-token\", \"-100123\", \"check preview\",\n+                        media_files=[(media_path, False)],\n+                        disable_link_previews=True,\n+                    )\n+\n+            result = asyncio.run(run_test())\n+            assert result[\"success\"] is True\n+            # Text send should have disable_web_page_preview\n+            assert text_kwargs_seen[0].get(\"disable_web_page_preview\") is True\n+            # Media send should NOT have disable_web_page_preview\n+            assert \"disable_web_page_preview\" not in media_kwargs_seen[0], \\\n+                \"disable_web_page_preview leaked into send_document kwargs\"\n+        finally:\n+            if media_path and os.path.exists(media_path):\n+                os.unlink(media_path)\n+"]},{"file":"tools/send_message_tool.py","hunks":["@@ -786,6 +786,15 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,\n     return last_result\n \n \n+def _is_telegram_thread_not_found(error: Exception) -> bool:\n+    \"\"\"Check if a Telegram error is a thread-not-found failure.\n+\n+    Matches the gateway adapter's ``_is_thread_not_found_error`` for\n+    the standalone ``_send_telegram`` path (issue #27012).\n+    \"\"\"\n+    return \"thread not found\" in str(error).lower()\n+\n+\n async def _send_telegram(token, chat_id, message, media_files=None, thread_id=None, disable_link_previews=False, force_document=False):\n     \"\"\"Send via Telegram Bot API (one-shot, no polling needed).\n ","@@ -873,8 +882,12 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No\n                 )\n             if effective_thread_id is not None:\n                 thread_kwargs[\"message_thread_id\"] = effective_thread_id\n+        # disable_web_page_preview is only valid for send_message, not\n+        # send_photo/send_video/etc.  Keep it separate so media sends\n+        # don't inherit an invalid parameter (issue #27012).\n+        text_kwargs = dict(thread_kwargs)\n         if disable_link_previews:\n-            thread_kwargs[\"disable_web_page_preview\"] = True\n+            text_kwargs[\"disable_web_page_preview\"] = True\n \n         last_msg = None\n         warnings = []","@@ -884,11 +897,24 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No\n                 last_msg = await _send_telegram_message_with_retry(\n                     bot,\n                     chat_id=int_chat_id, text=formatted,\n-                    parse_mode=send_parse_mode, entities=_entities, **thread_kwargs\n+                    parse_mode=send_parse_mode, entities=_entities, **text_kwargs\n                 )\n             except Exception as md_error:\n-                # Parse failed, fall back to plain text\n-                if \"parse\" in str(md_error).lower() or \"markdown\" in str(md_error).lower() or \"html\" in str(md_error).lower():\n+                # Thread not found — retry without message_thread_id so the\n+                # message still delivers (matching the gateway adapter's\n+                # fallback behaviour, issue #27012).\n+                if _is_telegram_thread_not_found(md_error) and thread_kwargs:\n+                    logger.warning(\n+                        \"Thread %s not found in _send_telegram, retrying without message_thread_id\",\n+                        thread_kwargs.get(\"message_thread_id\"),\n+                    )\n+                    text_kwargs.pop(\"message_thread_id\", None)\n+                    last_msg = await _send_telegram_message_with_retry(\n+                        bot,\n+                        chat_id=int_chat_id, text=formatted,\n+                        parse_mode=send_parse_mode, **text_kwargs\n+                    )\n+                elif \"parse\" in str(md_error).lower() or \"markdown\" in str(md_error).lower() or \"html\" in str(md_error).lower():\n                     logger.warning(\n                         \"Parse mode %s failed in _send_telegram, falling back to plain text: %s\",\n                         send_parse_mode,","@@ -905,7 +931,7 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No\n                     last_msg = await _send_telegram_message_with_retry(\n                         bot,\n                         chat_id=int_chat_id, text=plain,\n-                        parse_mode=None, entities=_entities, **thread_kwargs\n+                        parse_mode=None, entities=_entities, **text_kwargs\n                     )\n                 else:\n                     raise","@@ -920,26 +946,61 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No\n             ext = os.path.splitext(media_path)[1].lower()\n             try:\n                 with open(media_path, \"rb\") as f:\n-                    if ext in _IMAGE_EXTS and not force_document:\n-                        last_msg = await bot.send_photo(\n-                            chat_id=int_chat_id, photo=f, **thread_kwargs\n-                        )\n-                    elif ext in _VIDEO_EXTS:\n-                        last_msg = await bot.send_video(\n-                            chat_id=int_chat_id, video=f, **thread_kwargs\n-                        )\n-                    elif ext in _VOICE_EXTS and is_voice:\n-                        last_msg = await bot.send_voice(\n-                            chat_id=int_chat_id, voice=f, **thread_kwargs\n-                        )\n-                    elif ext in _TELEGRAM_SEND_AUDIO_EXTS:\n-                        last_msg = await bot.send_audio(\n-                            chat_id=int_chat_id, audio=f, **thread_kwargs\n-                        )\n-                    else:\n-                        last_msg = await bot.send_document(\n-                            chat_id=int_chat_id, document=f, **thread_kwargs\n-                        )\n+                    media_kwargs = dict(thread_kwargs)\n+                    try:\n+                        if ext in _IMAGE_EXTS and not force_document:\n+                            last_msg = await bot.send_photo(\n+                                chat_id=int_chat_id, photo=f, **media_kwargs\n+                            )\n+                        elif ext in _VIDEO_EXTS:\n+                            last_msg = await bot.send_video(\n+                                chat_id=int_chat_id, video=f, **media_kwargs\n+                            )\n+                        elif ext in _VOICE_EXTS and is_voice:\n+                            last_msg = await bot.send_voice(\n+                                chat_id=int_chat_id, voice=f, **media_kwargs\n+                            )\n+                        elif ext in _TELEGRAM_SEND_AUDIO_EXTS:\n+                            last_msg = await bot.send_audio(\n+                                chat_id=int_chat_id, audio=f, **media_kwargs\n+                            )\n+                        else:\n+                            last_msg = await bot.send_document(\n+                                chat_id=int_chat_id, document=f, **media_kwargs\n+                            )\n+                    except Exception as media_err:\n+                        if _is_telegram_thread_not_found(media_err) and media_kwargs.get(\"message_thread_id\"):\n+                            # Thread not found for media — retry without\n+                            # message_thread_id (issue #27012).\n+                            logger.warning(\n+                                \"Thread %s not found for media send, retrying without message_thread_id\",\n+                                media_kwargs[\"message_thread_id\"],\n+                            )\n+                            # Re-seek the file since the first attempt consumed it\n+                            f.seek(0)\n+                            media_kwargs.pop(\"message_thread_id\", None)\n+                            if ext in _IMAGE_EXTS and not force_document:\n+                                last_msg = await bot.send_photo(\n+                                    chat_id=int_chat_id, photo=f, **media_kwargs\n+                                )\n+                            elif ext in _VIDEO_EXTS:\n+                                last_msg = await bot.send_video(\n+                                    chat_id=int_chat_id, video=f, **media_kwargs\n+                                )\n+                            elif ext in _VOICE_EXTS and is_voice:\n+                                last_msg = await bot.send_voice(\n+                                    chat_id=int_chat_id, voice=f, **media_kwargs\n+                                )\n+                            elif ext in _TELEGRAM_SEND_AUDIO_EXTS:\n+                                last_msg = await bot.send_audio(\n+                                    chat_id=int_chat_id, audio=f, **media_kwargs\n+                                )\n+                            else:\n+                                last_msg = await bot.send_document(\n+                                    chat_id=int_chat_id, document=f, **media_kwargs\n+                                )\n+                        else:\n+                            raise\n             except Exception as e:\n                 warning = _sanitize_error_text(f\"Failed to send media {media_path}: {e}\")\n                 logger.error(warning)\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}