Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 20 additions & 10 deletions plugins/platforms/feishu/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,12 +151,9 @@
# ---------------------------------------------------------------------------

_MARKDOWN_HINT_RE = re.compile(
r"(^#{1,6}\s)|(^\s*[-*]\s)|(^\s*\d+\.\s)|(^\s*---+\s*$)|(```)|(`[^`\n]+`)|(\*\*[^*\n].+?\*\*)|(~~[^~\n].+?~~)|(<u>.+?</u>)|(\*[^*\n]+\*)|(\[[^\]]+\]\([^)]+\))|(^>\s)",
r"(^#{1,6}\s)|(^\s*[-*]\s)|(^\s*\d+\.\s)|(^\s*---+\s*$)|(```)|(`[^`\n]+`)|(\*\*[^*\n].+?\*\*)|(~~[^~\n].+?~~)|(<u>.+?</u>)|(\*[^*\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*$")
Expand Down Expand Up @@ -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":

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guard covers a post-origin message becoming plain text, but the PR description’s failure is the reverse: a text streaming message whose final table is classified as post. That attempted update has msg_type == "post", so this fallback is skipped; please handle or re-scope that actual transition.

# 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
Expand Down Expand Up @@ -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}
Expand Down
95 changes: 95 additions & 0 deletions tests/gateway/test_feishu.py
Original file line number Diff line number Diff line change
Expand Up @@ -2841,6 +2841,101 @@ async def _direct(func, *args, **kwargs):
[[{"tag": "md", "text": "---\n1. 第一项\n<u>下划线</u>\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):
Expand Down