Merge PR #1605: fix(feishu): smart message format selection
This commit is contained in:
@@ -472,6 +472,121 @@ class FeishuChannel(BaseChannel):
|
|||||||
|
|
||||||
return elements or [{"tag": "markdown", "content": content}]
|
return elements or [{"tag": "markdown", "content": content}]
|
||||||
|
|
||||||
|
# ── Smart format detection ──────────────────────────────────────────
|
||||||
|
# Patterns that indicate "complex" markdown needing card rendering
|
||||||
|
_COMPLEX_MD_RE = re.compile(
|
||||||
|
r"```" # fenced code block
|
||||||
|
r"|^\|.+\|.*\n\s*\|[-:\s|]+\|" # markdown table (header + separator)
|
||||||
|
r"|^#{1,6}\s+" # headings
|
||||||
|
, re.MULTILINE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Simple markdown patterns (bold, italic, strikethrough)
|
||||||
|
_SIMPLE_MD_RE = re.compile(
|
||||||
|
r"\*\*.+?\*\*" # **bold**
|
||||||
|
r"|__.+?__" # __bold__
|
||||||
|
r"|(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)" # *italic* (single *)
|
||||||
|
r"|~~.+?~~" # ~~strikethrough~~
|
||||||
|
, re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Markdown link: [text](url)
|
||||||
|
_MD_LINK_RE = re.compile(r"\[([^\]]+)\]\((https?://[^\)]+)\)")
|
||||||
|
|
||||||
|
# Unordered list items
|
||||||
|
_LIST_RE = re.compile(r"^[\s]*[-*+]\s+", re.MULTILINE)
|
||||||
|
|
||||||
|
# Ordered list items
|
||||||
|
_OLIST_RE = re.compile(r"^[\s]*\d+\.\s+", re.MULTILINE)
|
||||||
|
|
||||||
|
# Max length for plain text format
|
||||||
|
_TEXT_MAX_LEN = 200
|
||||||
|
|
||||||
|
# Max length for post (rich text) format; beyond this, use card
|
||||||
|
_POST_MAX_LEN = 2000
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _detect_msg_format(cls, content: str) -> str:
|
||||||
|
"""Determine the optimal Feishu message format for *content*.
|
||||||
|
|
||||||
|
Returns one of:
|
||||||
|
- ``"text"`` – plain text, short and no markdown
|
||||||
|
- ``"post"`` – rich text (links only, moderate length)
|
||||||
|
- ``"interactive"`` – card with full markdown rendering
|
||||||
|
"""
|
||||||
|
stripped = content.strip()
|
||||||
|
|
||||||
|
# Complex markdown (code blocks, tables, headings) → always card
|
||||||
|
if cls._COMPLEX_MD_RE.search(stripped):
|
||||||
|
return "interactive"
|
||||||
|
|
||||||
|
# Long content → card (better readability with card layout)
|
||||||
|
if len(stripped) > cls._POST_MAX_LEN:
|
||||||
|
return "interactive"
|
||||||
|
|
||||||
|
# Has bold/italic/strikethrough → card (post format can't render these)
|
||||||
|
if cls._SIMPLE_MD_RE.search(stripped):
|
||||||
|
return "interactive"
|
||||||
|
|
||||||
|
# Has list items → card (post format can't render list bullets well)
|
||||||
|
if cls._LIST_RE.search(stripped) or cls._OLIST_RE.search(stripped):
|
||||||
|
return "interactive"
|
||||||
|
|
||||||
|
# Has links → post format (supports <a> tags)
|
||||||
|
if cls._MD_LINK_RE.search(stripped):
|
||||||
|
return "post"
|
||||||
|
|
||||||
|
# Short plain text → text format
|
||||||
|
if len(stripped) <= cls._TEXT_MAX_LEN:
|
||||||
|
return "text"
|
||||||
|
|
||||||
|
# Medium plain text without any formatting → post format
|
||||||
|
return "post"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _markdown_to_post(cls, content: str) -> str:
|
||||||
|
"""Convert markdown content to Feishu post message JSON.
|
||||||
|
|
||||||
|
Handles links ``[text](url)`` as ``a`` tags; everything else as ``text`` tags.
|
||||||
|
Each line becomes a paragraph (row) in the post body.
|
||||||
|
"""
|
||||||
|
lines = content.strip().split("\n")
|
||||||
|
paragraphs: list[list[dict]] = []
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
elements: list[dict] = []
|
||||||
|
last_end = 0
|
||||||
|
|
||||||
|
for m in cls._MD_LINK_RE.finditer(line):
|
||||||
|
# Text before this link
|
||||||
|
before = line[last_end:m.start()]
|
||||||
|
if before:
|
||||||
|
elements.append({"tag": "text", "text": before})
|
||||||
|
elements.append({
|
||||||
|
"tag": "a",
|
||||||
|
"text": m.group(1),
|
||||||
|
"href": m.group(2),
|
||||||
|
})
|
||||||
|
last_end = m.end()
|
||||||
|
|
||||||
|
# Remaining text after last link
|
||||||
|
remaining = line[last_end:]
|
||||||
|
if remaining:
|
||||||
|
elements.append({"tag": "text", "text": remaining})
|
||||||
|
|
||||||
|
# Empty line → empty paragraph for spacing
|
||||||
|
if not elements:
|
||||||
|
elements.append({"tag": "text", "text": ""})
|
||||||
|
|
||||||
|
paragraphs.append(elements)
|
||||||
|
|
||||||
|
post_body = {
|
||||||
|
"zh_cn": {
|
||||||
|
"content": paragraphs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json.dumps(post_body, ensure_ascii=False)
|
||||||
|
|
||||||
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".tiff", ".tif"}
|
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".tiff", ".tif"}
|
||||||
_AUDIO_EXTS = {".opus"}
|
_AUDIO_EXTS = {".opus"}
|
||||||
_VIDEO_EXTS = {".mp4", ".mov", ".avi"}
|
_VIDEO_EXTS = {".mp4", ".mov", ".avi"}
|
||||||
@@ -695,14 +810,34 @@ class FeishuChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if msg.content and msg.content.strip():
|
if msg.content and msg.content.strip():
|
||||||
elements = self._build_card_elements(msg.content)
|
fmt = self._detect_msg_format(msg.content)
|
||||||
for chunk in self._split_elements_by_table_limit(elements):
|
|
||||||
card = {"config": {"wide_screen_mode": True}, "elements": chunk}
|
if fmt == "text":
|
||||||
|
# Short plain text – send as simple text message
|
||||||
|
text_body = json.dumps({"text": msg.content.strip()}, ensure_ascii=False)
|
||||||
await loop.run_in_executor(
|
await loop.run_in_executor(
|
||||||
None, self._send_message_sync,
|
None, self._send_message_sync,
|
||||||
receive_id_type, msg.chat_id, "interactive", json.dumps(card, ensure_ascii=False),
|
receive_id_type, msg.chat_id, "text", text_body,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
elif fmt == "post":
|
||||||
|
# Medium content with links – send as rich-text post
|
||||||
|
post_body = self._markdown_to_post(msg.content)
|
||||||
|
await loop.run_in_executor(
|
||||||
|
None, self._send_message_sync,
|
||||||
|
receive_id_type, msg.chat_id, "post", post_body,
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Complex / long content – send as interactive card
|
||||||
|
elements = self._build_card_elements(msg.content)
|
||||||
|
for chunk in self._split_elements_by_table_limit(elements):
|
||||||
|
card = {"config": {"wide_screen_mode": True}, "elements": chunk}
|
||||||
|
await loop.run_in_executor(
|
||||||
|
None, self._send_message_sync,
|
||||||
|
receive_id_type, msg.chat_id, "interactive", json.dumps(card, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error sending Feishu message: {}", e)
|
logger.error("Error sending Feishu message: {}", e)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user