diff --git a/channel/feishu/feishu_channel.py b/channel/feishu/feishu_channel.py index 9a9f3307..7063150f 100644 --- a/channel/feishu/feishu_channel.py +++ b/channel/feishu/feishu_channel.py @@ -28,6 +28,7 @@ from bridge.context import ContextType from bridge.reply import Reply, ReplyType from channel.chat_channel import ChatChannel, check_prefix from channel.feishu.feishu_message import FeishuMessage +from channel.feishu.feishu_static_card import build_text_delivery from common import utils from common.expired_dict import ExpiredDict from common.log import logger @@ -628,7 +629,13 @@ class FeiShuChanel(ChatChannel): logger.debug(f"[FeiShu] sending reply, type={context.type}, content={reply.content[:100]}...") reply_content = reply.content content_key = "text" - if reply.type == ReplyType.IMAGE_URL: + prepared_content_json = None + if reply.type == ReplyType.TEXT: + msg_type, prepared_content_json = build_text_delivery( + reply.content, + enabled=conf().get("feishu_markdown_card", True), + ) + elif reply.type == ReplyType.IMAGE_URL: # 图片上传 reply_content = self._upload_image_url(reply.content, access_token) if not reply_content: @@ -690,7 +697,11 @@ class FeiShuChanel(ChatChannel): can_reply = is_group and msg and hasattr(msg, 'msg_id') and msg.msg_id # Build content JSON - content_json = json.dumps(reply_content, ensure_ascii=False) if content_key is None else json.dumps({content_key: reply_content}, ensure_ascii=False) + content_json = prepared_content_json or ( + json.dumps(reply_content, ensure_ascii=False) + if content_key is None + else json.dumps({content_key: reply_content}, ensure_ascii=False) + ) logger.debug(f"[FeiShu] Sending message: msg_type={msg_type}, content={content_json[:200]}") if can_reply: @@ -714,6 +725,39 @@ class FeiShuChanel(ChatChannel): res = res.json() if res.get("code") == 0: logger.info(f"[FeiShu] send message success") + elif msg_type == "interactive" and reply.type == ReplyType.TEXT: + logger.warning( + "[FeiShu] Markdown card failed, falling back to text, " + f"code={res.get('code')}, msg={res.get('msg')}" + ) + fallback_data = { + "msg_type": "text", + "content": json.dumps({"text": reply.content}, ensure_ascii=False), + } + if can_reply: + fallback_res = requests.post( + url=url, + headers=headers, + json=fallback_data, + timeout=(5, 10), + ) + else: + fallback_data["receive_id"] = context.get("receiver") + fallback_res = requests.post( + url=url, + headers=headers, + params=params, + json=fallback_data, + timeout=(5, 10), + ) + fallback_body = fallback_res.json() + if fallback_body.get("code") == 0: + logger.info("[FeiShu] text fallback sent successfully") + else: + logger.error( + "[FeiShu] text fallback failed, " + f"code={fallback_body.get('code')}, msg={fallback_body.get('msg')}" + ) else: logger.error(f"[FeiShu] send message failed, code={res.get('code')}, msg={res.get('msg')}") diff --git a/channel/feishu/feishu_static_card.py b/channel/feishu/feishu_static_card.py new file mode 100644 index 00000000..19de65b4 --- /dev/null +++ b/channel/feishu/feishu_static_card.py @@ -0,0 +1,46 @@ +"""Helpers for choosing the native Feishu delivery format for text replies.""" + +import json +import re +from typing import Tuple + + +_BLOCK_MARKDOWN = re.compile( + r"(?m)^\s{0,3}(?:#{1,6}\s|>\s|[-*+]\s|\d+[.)]\s|```|~~~)" +) +_INLINE_MARKDOWN = re.compile(r"(`[^`\n]+`|\*\*[^*\n]+\*\*|\[[^]\n]+\]\([^)\n]+\))") +_TABLE_SEPARATOR = re.compile(r"(?m)^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$") + + +def contains_markdown(text: str) -> bool: + """Return whether *text* contains syntax that benefits from card Markdown.""" + if not text: + return False + return bool( + _BLOCK_MARKDOWN.search(text) + or _INLINE_MARKDOWN.search(text) + or _TABLE_SEPARATOR.search(text) + ) + + +def build_markdown_card(text: str) -> dict: + """Build an inline Card 2.0 payload with one Markdown element.""" + return { + "schema": "2.0", + "config": {}, + "body": { + "elements": [ + { + "tag": "markdown", + "content": text, + } + ] + }, + } + + +def build_text_delivery(text: str, enabled: bool = True) -> Tuple[str, str]: + """Return the Feishu ``msg_type`` and serialized content for a text reply.""" + if enabled and contains_markdown(text): + return "interactive", json.dumps(build_markdown_card(text), ensure_ascii=False) + return "text", json.dumps({"text": text}, ensure_ascii=False) diff --git a/config-template.json b/config-template.json index ad3583bf..bd24bbb5 100644 --- a/config-template.json +++ b/config-template.json @@ -28,6 +28,7 @@ "feishu_app_id": "", "feishu_app_secret": "", "feishu_stream_reply": true, + "feishu_markdown_card": true, "dingtalk_client_id": "", "dingtalk_client_secret": "", "wecom_bot_id": "", diff --git a/config.py b/config.py index 6d02e86f..5e25e7ee 100644 --- a/config.py +++ b/config.py @@ -178,6 +178,7 @@ available_setting = { "feishu_event_mode": "websocket", # Feishu event mode: webhook(HTTP server) or websocket(long connection) # Feishu streaming reply (based on the official cardkit streaming-card API; requires the cardkit:card:write permission and Feishu client 7.20+) "feishu_stream_reply": True, # whether to enable streaming reply (typewriter effect); auto-downgrades to non-streaming or shows an upgrade prompt on failure/old clients + "feishu_markdown_card": True, # render non-streaming Markdown replies (including scheduled messages) as Card 2.0; falls back to text on failure # DingTalk config "dingtalk_client_id": "", # DingTalk bot Client ID "dingtalk_client_secret": "", # DingTalk bot Client Secret diff --git a/docs/channels/feishu.mdx b/docs/channels/feishu.mdx index 1283d0c1..d14adb8a 100644 --- a/docs/channels/feishu.mdx +++ b/docs/channels/feishu.mdx @@ -64,7 +64,8 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p "channel_type": "feishu", "feishu_app_id": "YOUR_APP_ID", "feishu_app_secret": "YOUR_APP_SECRET", - "feishu_stream_reply": true + "feishu_stream_reply": true, + "feishu_markdown_card": true } ``` @@ -73,6 +74,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p | `feishu_app_id` | Feishu app App ID | - | | `feishu_app_secret` | Feishu app App Secret | - | | `feishu_stream_reply` | Enable streaming typewriter reply | `true` | + | `feishu_markdown_card` | Render non-streaming Markdown and scheduled text as Card 2.0 | `true` | @@ -98,6 +100,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p | Image messages | ✅ send/receive | | Voice messages | ✅ send/receive | | Streaming reply | ✅ (powered by Feishu cardkit streaming card) | +| Markdown card | ✅ for non-streaming and scheduled replies | Streaming reply requires the `cardkit:card:write` permission (already enabled by one-click creation) and Feishu client version ≥ 7.20. Older clients see an upgrade prompt; if the permission or version is not satisfied, replies fall back to plain text automatically. diff --git a/docs/ja/channels/feishu.mdx b/docs/ja/channels/feishu.mdx index d703954a..5695d3ec 100644 --- a/docs/ja/channels/feishu.mdx +++ b/docs/ja/channels/feishu.mdx @@ -61,7 +61,8 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p "channel_type": "feishu", "feishu_app_id": "YOUR_APP_ID", "feishu_app_secret": "YOUR_APP_SECRET", - "feishu_stream_reply": true + "feishu_stream_reply": true, + "feishu_markdown_card": true } ``` @@ -70,6 +71,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p | `feishu_app_id` | Feishu アプリの App ID | - | | `feishu_app_secret` | Feishu アプリの App Secret | - | | `feishu_stream_reply` | ストリーミングタイプライター応答を有効化 | `true` | + | `feishu_markdown_card` | 非ストリーミング Markdown と定期配信を Card 2.0 で表示 | `true` | @@ -95,6 +97,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p | 画像メッセージ | ✅ 送受信 | | 音声メッセージ | ✅ 送受信 | | ストリーミング応答 | ✅(Feishu cardkit ストリーミングカードベース) | +| Markdown カード | ✅ 非ストリーミング応答と定期配信 | ストリーミング応答には `cardkit:card:write` 権限(ワンクリック作成では自動付与)と Feishu クライアント 7.20 以上が必要です。古いクライアントではアップグレード案内が表示され、権限/バージョン未充足時は通常テキスト応答に自動フォールバックします。 diff --git a/docs/zh/channels/feishu.mdx b/docs/zh/channels/feishu.mdx index 5cb8fe80..bf8c5cd8 100644 --- a/docs/zh/channels/feishu.mdx +++ b/docs/zh/channels/feishu.mdx @@ -65,7 +65,8 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p "channel_type": "feishu", "feishu_app_id": "YOUR_APP_ID", "feishu_app_secret": "YOUR_APP_SECRET", - "feishu_stream_reply": true + "feishu_stream_reply": true, + "feishu_markdown_card": true } ``` @@ -74,6 +75,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p | `feishu_app_id` | 飞书应用 App ID | - | | `feishu_app_secret` | 飞书应用 App Secret | - | | `feishu_stream_reply` | 是否开启流式打字机回复 | `true` | + | `feishu_markdown_card` | 将非流式 Markdown 与定时推送渲染为 Card 2.0 | `true` | @@ -99,6 +101,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p | 图片消息 | ✅ 收发 | | 语音消息 | ✅ 收发 | | 流式回复 | ✅(通过 `feishu_stream_reply` 配置控制,默认开启) | +| Markdown 卡片 | ✅ 非流式回复与定时推送 | 流式回复需要机器人具备 `cardkit:card:write` 权限(一键创建已默认开通),且接收方飞书客户端版本 ≥ 7.20。低版本客户端会显示升级提示,权限或版本不满足时自动降级为普通文本回复。 diff --git a/tests/test_feishu_static_card.py b/tests/test_feishu_static_card.py new file mode 100644 index 00000000..a0c58b71 --- /dev/null +++ b/tests/test_feishu_static_card.py @@ -0,0 +1,34 @@ +import json + +from channel.feishu.feishu_static_card import build_text_delivery, contains_markdown + + +def test_plain_text_keeps_native_feishu_text_message(): + msg_type, content = build_text_delivery("hello from CowAgent") + + assert msg_type == "text" + assert json.loads(content) == {"text": "hello from CowAgent"} + + +def test_markdown_reply_uses_card_2_markdown_element(): + msg_type, content = build_text_delivery("**Build complete**\n\n- tests passed") + + card = json.loads(content) + assert msg_type == "interactive" + assert card["schema"] == "2.0" + assert card["body"]["elements"] == [ + {"tag": "markdown", "content": "**Build complete**\n\n- tests passed"} + ] + + +def test_markdown_cards_can_be_disabled(): + msg_type, content = build_text_delivery("# Status", enabled=False) + + assert msg_type == "text" + assert json.loads(content) == {"text": "# Status"} + + +def test_markdown_detection_avoids_common_plain_text_punctuation(): + assert contains_markdown("release 1.2.0 - all checks passed") is False + assert contains_markdown("Use `cow --help` for details") is True + assert contains_markdown("| Item | State |\n| --- | --- |\n| API | ready |") is True