diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 3847ac1..8f69c09 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -472,6 +472,121 @@ class FeishuChannel(BaseChannel): 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"|(? 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 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"} _AUDIO_EXTS = {".opus"} _VIDEO_EXTS = {".mp4", ".mov", ".avi"} @@ -695,14 +810,34 @@ class FeishuChannel(BaseChannel): ) if msg.content and msg.content.strip(): - 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} + fmt = self._detect_msg_format(msg.content) + + 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( 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: logger.error("Error sending Feishu message: {}", e)