From 9403b4f8ba983fb2c634ff128786ee9b71428fae Mon Sep 17 00:00:00 2001 From: xxxigm Date: Mon, 20 Jul 2026 08:51:43 -0700 Subject: [PATCH] fix(feishu): keep msg_type=post consistent across every chunk of a long markdown reply (#26841) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transplant of PR #26848 onto the plugin adapter path (plugins/platforms/feishu/adapter.py — the original PR targeted the since-removed gateway/platforms/feishu.py). ``send`` classifies each chunk independently, so chunk 1 of a long markdown reply (often plain prose) went out as msg_type=text while later chunks rendered as post — literal **bold**/## heading markers in the Feishu client. Lock the decision at the whole-message level: compute prefer_post once from the full formatted message and pass it to _build_outbound_payload per chunk. The original PR's per-chunk table exemption is intentionally dropped: tables now route through post/md (issue #52786 cluster fix), so the exemption would reintroduce the raw-table downgrade. Co-authored-by: Hermes Agent --- plugins/platforms/feishu/adapter.py | 23 +++++- tests/gateway/test_feishu.py | 104 ++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 3 deletions(-) diff --git a/plugins/platforms/feishu/adapter.py b/plugins/platforms/feishu/adapter.py index 02b2b378adbb..4668a7372750 100644 --- a/plugins/platforms/feishu/adapter.py +++ b/plugins/platforms/feishu/adapter.py @@ -1910,11 +1910,21 @@ class FeishuAdapter(BasePlatformAdapter): formatted = self.format_message(content) chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH) + # When chunking splits a long markdown response, an individual chunk + # can end up as plain prose that doesn't match the per-chunk hint + # regex — so it would be sent as ``msg_type=text`` and the user would + # see literal ``**bold``/``## heading``/code fences in the Feishu + # client while other chunks render correctly. Lock the markdown + # decision at the whole-message level so every chunk consistently + # uses ``post``. See #26841. + prefer_post = bool(_MARKDOWN_HINT_RE.search(formatted)) last_response = None try: for chunk in chunks: - msg_type, payload = self._build_outbound_payload(chunk) + msg_type, payload = self._build_outbound_payload( + chunk, prefer_post=prefer_post, + ) try: response = await self._feishu_send_with_retry( chat_id=chat_id, @@ -4545,14 +4555,21 @@ class FeishuAdapter(BasePlatformAdapter): # Outbound payload construction and send pipeline # ========================================================================= - def _build_outbound_payload(self, content: str) -> tuple[str, str]: + def _build_outbound_payload( + self, content: str, *, prefer_post: bool = False, + ) -> tuple[str, str]: # Empirically (issue #52786), current Feishu clients render markdown # tables inside ``post``-type ``md`` elements natively. The previous # table-downgrade branch forced any table-containing message to # ``text``, which left Feishu readers seeing the raw pipe-and-dash # source instead of a rendered table. Trust the common markdown path # for table content too. - if _MARKDOWN_HINT_RE.search(content): + # + # ``prefer_post`` lets ``send`` treat the chunk as part of a larger + # markdown document: when a long markdown reply is split at + # MAX_MESSAGE_LENGTH, the per-chunk regex would otherwise + # mis-classify a plain-prose chunk as ``text``. See #26841. + if prefer_post or _MARKDOWN_HINT_RE.search(content): return "post", _build_markdown_post_payload(content) text_payload = {"text": content} return "text", json.dumps(text_payload, ensure_ascii=False) diff --git a/tests/gateway/test_feishu.py b/tests/gateway/test_feishu.py index c76979d67d72..bf972f7ada9f 100644 --- a/tests/gateway/test_feishu.py +++ b/tests/gateway/test_feishu.py @@ -2707,6 +2707,110 @@ class TestAdapterBehavior(unittest.TestCase): [[{"tag": "md", "text": content}]], ) + @patch.dict(os.environ, {}, clear=True) + def test_send_uses_post_for_every_chunk_of_multi_chunk_markdown(self): + """Regression for #26841: when a long Markdown message is split + across multiple chunks, every chunk must go out as + ``msg_type=post`` — including chunk 1. The bug was that the + first chunk often had only plain prose (the per-chunk regex + didn't match) and was sent as ``text``, so users saw literal + ``**bold``/``## heading``/code fences while later chunks + rendered correctly. + """ + from gateway.config import PlatformConfig + from plugins.platforms.feishu.adapter import FeishuAdapter + + adapter = FeishuAdapter(PlatformConfig()) + captured = [] + + class _MessageAPI: + def create(self, request): + captured.append(request) + return SimpleNamespace( + success=lambda: True, + data=SimpleNamespace( + message_id=f"om_chunk_{len(captured)}", + ), + ) + + adapter._client = SimpleNamespace( + im=SimpleNamespace( + v1=SimpleNamespace( + message=_MessageAPI(), + ) + ) + ) + + async def _direct(func, *args, **kwargs): + return func(*args, **kwargs) + + # Force a deterministic split so the test doesn't depend on the + # exact 8000-char limit. Chunk 1 is plain prose; chunk 2 has + # the markdown markers. Without the fix, chunk 1 went out as + # ``msg_type=text``. + first_chunk = "Here is a short intro that has no markdown markers at all." + second_chunk = "## Heading\nAnd then some **bold** text." + + with patch.object( + adapter, "truncate_message", return_value=[first_chunk, second_chunk], + ), patch("plugins.platforms.feishu.adapter.asyncio.to_thread", side_effect=_direct): + result = asyncio.run( + adapter.send( + chat_id="oc_chat", + content=first_chunk + "\n" + second_chunk, + ) + ) + + self.assertTrue(result.success) + self.assertEqual(len(captured), 2) + msg_types = [r.request_body.msg_type for r in captured] + self.assertEqual(msg_types, ["post", "post"]) + + @patch.dict(os.environ, {}, clear=True) + def test_send_plain_text_message_not_upgraded_by_prefer_post(self): + """A message with no markdown at all must still go out as plain + ``msg_type=text`` — the whole-message ``prefer_post`` decision + only flips on when the formatted message matches the hint regex. + """ + from gateway.config import PlatformConfig + from plugins.platforms.feishu.adapter import FeishuAdapter + + adapter = FeishuAdapter(PlatformConfig()) + captured = [] + + class _MessageAPI: + def create(self, request): + captured.append(request) + return SimpleNamespace( + success=lambda: True, + data=SimpleNamespace( + message_id=f"om_chunk_{len(captured)}", + ), + ) + + adapter._client = SimpleNamespace( + im=SimpleNamespace( + v1=SimpleNamespace( + message=_MessageAPI(), + ) + ) + ) + + async def _direct(func, *args, **kwargs): + return func(*args, **kwargs) + + with patch("plugins.platforms.feishu.adapter.asyncio.to_thread", side_effect=_direct): + result = asyncio.run( + adapter.send( + chat_id="oc_chat", + content="just a plain sentence", + ) + ) + + self.assertTrue(result.success) + self.assertEqual(len(captured), 1) + self.assertEqual(captured[0].request_body.msg_type, "text") + @patch.dict(os.environ, {}, clear=True) def test_send_uses_post_for_inline_markdown(self): from gateway.config import PlatformConfig