diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py
index e6f0049..c405493 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"}
_FILE_TYPE_MAP = {
@@ -689,14 +804,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)
diff --git a/pr-description.md b/pr-description.md
new file mode 100644
index 0000000..dacab5c
--- /dev/null
+++ b/pr-description.md
@@ -0,0 +1,47 @@
+## fix(feishu): smart message format selection (fixes #1548)
+
+### Problem
+
+Currently, the Feishu channel sends **all** messages as interactive cards (`msg_type: "interactive"`). This is overkill for short, simple replies like "OK" or "收到" — they look heavy and unnatural compared to normal chat messages.
+
+### Solution
+
+Implement smart message format selection that picks the most appropriate Feishu message type based on content analysis:
+
+| Content Type | Format | `msg_type` |
+|---|---|---|
+| Short plain text (≤ 200 chars, no markdown) | Text | `text` |
+| Medium text with links (≤ 2000 chars, no complex formatting) | Rich Text Post | `post` |
+| Long text, code blocks, tables, headings, bold/italic, lists | Interactive Card | `interactive` |
+
+### How it works
+
+1. **`_detect_msg_format(content)`** — Analyzes the message content and returns the optimal format:
+ - Checks for complex markdown (code blocks, tables, headings) → `interactive`
+ - Checks for simple markdown (bold, italic, lists) → `interactive`
+ - Checks for links → `post` (Feishu post format supports `` tags natively)
+ - Short plain text → `text`
+ - Medium plain text → `post`
+
+2. **`_markdown_to_post(content)`** — Converts markdown links `[text](url)` to Feishu post format with proper `a` tags. Each line becomes a paragraph in the post body.
+
+3. **Modified `send()` method** — Uses `_detect_msg_format()` to choose the right format, then dispatches to the appropriate sending logic.
+
+### Design decisions
+
+- **Post format for links only**: Feishu's post format (`[[{"tag":"text",...}]]`) doesn't support bold/italic rendering, so we only use it for messages containing links (where the `a` tag adds real value). Messages with bold/italic/lists still use cards which render markdown properly.
+- **Conservative thresholds**: 200 chars for text, 2000 chars for post — these keep the UX natural without being too aggressive.
+- **Backward compatible**: The card rendering path is completely unchanged. Only the routing logic is new.
+
+### Testing
+
+Format detection tested against 13 cases covering all content types:
+- ✅ Plain text → `text`
+- ✅ Links → `post`
+- ✅ Bold/italic/code/tables/headings/lists → `interactive`
+- ✅ Long content → `interactive`
+- ✅ Post format generates valid Feishu post JSON with proper `a` tags
+
+### Changes
+
+- `nanobot/channels/feishu.py`: Added `_detect_msg_format()`, `_markdown_to_post()`, and updated `send()` method