diff --git a/plugins/platforms/feishu/adapter.py b/plugins/platforms/feishu/adapter.py index c3ce2c29431fd..41f6562734c8c 100644 --- a/plugins/platforms/feishu/adapter.py +++ b/plugins/platforms/feishu/adapter.py @@ -151,12 +151,9 @@ # --------------------------------------------------------------------------- _MARKDOWN_HINT_RE = re.compile( - r"(^#{1,6}\s)|(^\s*[-*]\s)|(^\s*\d+\.\s)|(^\s*---+\s*$)|(```)|(`[^`\n]+`)|(\*\*[^*\n].+?\*\*)|(~~[^~\n].+?~~)|(.+?)|(\*[^*\n]+\*)|(\[[^\]]+\]\([^)]+\))|(^>\s)", + r"(^#{1,6}\s)|(^\s*[-*]\s)|(^\s*\d+\.\s)|(^\s*---+\s*$)|(```)|(`[^`\n]+`)|(\*\*[^*\n].+?\*\*)|(~~[^~\n].+?~~)|(.+?)|(\*[^*\n]+\*)|(\[[^\]]+\]\([^)]+\))|(^>\s)|(^\|.*\|\n\|[-|: ]+\|)", re.MULTILINE, ) -# Detect markdown tables: a line starting with | followed by a separator line. -# Feishu post-type 'md' elements do not render tables, so we force text mode. -_MARKDOWN_TABLE_RE = re.compile(r"^\|.*\|\n\|[-|: ]+\|", re.MULTILINE) _MARKDOWN_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)") _MARKDOWN_FENCE_OPEN_RE = re.compile(r"^```([^\n`]*)\s*$") _MARKDOWN_FENCE_CLOSE_RE = re.compile(r"^```\s*$") @@ -1857,6 +1854,22 @@ async def edit_message( fallback_request = self._build_update_message_request(message_id=message_id, request_body=fallback_body) fallback_response = await asyncio.to_thread(self._client.im.v1.message.update, fallback_request) result = self._finalize_send_result(fallback_response, "update failed") + if not result.success and msg_type == "text": + # Text edit can fail when the original streaming chunk was sent + # as 'post' and the final content now routes to 'text'. Feishu + # does not allow changing msg_type on update, so retry as post + # with markdown rendering to keep the message readable. + logger.warning( + "[Feishu] Plain-text update rejected (likely msg_type mismatch on " + "streaming edit); falling back to post markdown" + ) + fallback_body = self._build_update_message_body( + msg_type="post", + content=_build_markdown_post_payload(content), + ) + fallback_request = self._build_update_message_request(message_id=message_id, request_body=fallback_body) + fallback_response = await asyncio.to_thread(self._client.im.v1.message.update, fallback_request) + result = self._finalize_send_result(fallback_response, "update failed") if result.success: result.message_id = message_id return result @@ -4375,12 +4388,9 @@ def _is_duplicate(self, message_id: str) -> bool: # ========================================================================= def _build_outbound_payload(self, content: str) -> tuple[str, str]: - # Feishu post-type 'md' elements do not render markdown tables; sending - # table content as post causes the message to appear blank on the client. - # Force plain text for anything that looks like a markdown table. - if _MARKDOWN_TABLE_RE.search(content): - text_payload = {"text": content} - return "text", json.dumps(text_payload, ensure_ascii=False) + # Feishu post + tag:md renders the full GFM surface (tables, bold, + # headings, code blocks, lists, quotes). Route any markdown-shaped + # content through the post payload. if _MARKDOWN_HINT_RE.search(content): return "post", _build_markdown_post_payload(content) text_payload = {"text": content} diff --git a/tests/gateway/test_feishu.py b/tests/gateway/test_feishu.py index bb97c7e72be17..41e0d06b59bbe 100644 --- a/tests/gateway/test_feishu.py +++ b/tests/gateway/test_feishu.py @@ -2841,6 +2841,101 @@ async def _direct(func, *args, **kwargs): [[{"tag": "md", "text": "---\n1. 第一项\n下划线\n~~删除线~~"}]], ) + @patch.dict(os.environ, {}, clear=True) + def test_send_uses_post_for_markdown_tables(self): + """Markdown tables must route through post+tag:md, not be downgraded to text. + + Regression for the historic _MARKDOWN_TABLE_RE workaround that force-downgraded + the entire message to msg_type: text whenever a GFM table appeared. Feishu's + post + tag:md element renders GFM tables natively now. + """ + from gateway.config import PlatformConfig + from plugins.platforms.feishu.adapter import FeishuAdapter + + adapter = FeishuAdapter(PlatformConfig()) + captured = {} + + class _MessageAPI: + def create(self, request): + captured["request"] = request + return SimpleNamespace( + success=lambda: True, + data=SimpleNamespace(message_id="om_table"), + ) + + adapter._client = SimpleNamespace( + im=SimpleNamespace( + v1=SimpleNamespace( + message=_MessageAPI(), + ) + ) + ) + + async def _direct(func, *args, **kwargs): + return func(*args, **kwargs) + + content = "| 名称 | 重要度 |\n| --- | --- |\n| RAG | 极高 |" + + with patch("plugins.platforms.feishu.adapter.asyncio.to_thread", side_effect=_direct): + result = asyncio.run( + adapter.send( + chat_id="oc_chat", + content=content, + ) + ) + + self.assertTrue(result.success) + self.assertEqual(captured["request"].request_body.msg_type, "post") + payload = json.loads(captured["request"].request_body.content) + elements = payload["zh_cn"]["content"][0] + self.assertEqual(elements, [{"tag": "md", "text": content}]) + + @patch.dict(os.environ, {}, clear=True) + def test_edit_message_falls_back_to_post_when_text_update_rejected(self): + """Text edit can fail when streaming sent the first chunk as 'post' but + the final content now routes to 'text'. Feishu does not allow changing + msg_type on update, so we retry as post with markdown rendering.""" + from gateway.config import PlatformConfig + from plugins.platforms.feishu.adapter import FeishuAdapter + + adapter = FeishuAdapter(PlatformConfig()) + captured = {"calls": []} + + class _MessageAPI: + def update(self, request): + captured["calls"].append(request) + # First call (text update) fails — simulates msg_type mismatch + if len(captured["calls"]) == 1: + return SimpleNamespace(success=lambda: False, code=230002, msg="msg type not match") + # Second call (post fallback) succeeds + return SimpleNamespace(success=lambda: True) + + 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.edit_message( + chat_id="oc_chat", + message_id="om_progress", + content="plain text content without markdown", + ) + ) + + self.assertTrue(result.success) + # First attempt: text + self.assertEqual(captured["calls"][0].request_body.msg_type, "text") + # Fallback: post + self.assertEqual(captured["calls"][1].request_body.msg_type, "post") + @unittest.skipUnless(_HAS_LARK_OAPI, "lark-oapi not installed") class TestHydrateBotIdentity(unittest.TestCase):