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):