mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-19 21:07:28 +08:00
feat(feishu): render markdown replies as cards
This commit is contained in:
@@ -28,6 +28,7 @@ from bridge.context import ContextType
|
|||||||
from bridge.reply import Reply, ReplyType
|
from bridge.reply import Reply, ReplyType
|
||||||
from channel.chat_channel import ChatChannel, check_prefix
|
from channel.chat_channel import ChatChannel, check_prefix
|
||||||
from channel.feishu.feishu_message import FeishuMessage
|
from channel.feishu.feishu_message import FeishuMessage
|
||||||
|
from channel.feishu.feishu_static_card import build_text_delivery
|
||||||
from common import utils
|
from common import utils
|
||||||
from common.expired_dict import ExpiredDict
|
from common.expired_dict import ExpiredDict
|
||||||
from common.log import logger
|
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]}...")
|
logger.debug(f"[FeiShu] sending reply, type={context.type}, content={reply.content[:100]}...")
|
||||||
reply_content = reply.content
|
reply_content = reply.content
|
||||||
content_key = "text"
|
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)
|
reply_content = self._upload_image_url(reply.content, access_token)
|
||||||
if not reply_content:
|
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
|
can_reply = is_group and msg and hasattr(msg, 'msg_id') and msg.msg_id
|
||||||
|
|
||||||
# Build content JSON
|
# 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]}")
|
logger.debug(f"[FeiShu] Sending message: msg_type={msg_type}, content={content_json[:200]}")
|
||||||
|
|
||||||
if can_reply:
|
if can_reply:
|
||||||
@@ -714,6 +725,39 @@ class FeiShuChanel(ChatChannel):
|
|||||||
res = res.json()
|
res = res.json()
|
||||||
if res.get("code") == 0:
|
if res.get("code") == 0:
|
||||||
logger.info(f"[FeiShu] send message success")
|
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:
|
else:
|
||||||
logger.error(f"[FeiShu] send message failed, code={res.get('code')}, msg={res.get('msg')}")
|
logger.error(f"[FeiShu] send message failed, code={res.get('code')}, msg={res.get('msg')}")
|
||||||
|
|
||||||
|
|||||||
46
channel/feishu/feishu_static_card.py
Normal file
46
channel/feishu/feishu_static_card.py
Normal file
@@ -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)
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
"feishu_app_id": "",
|
"feishu_app_id": "",
|
||||||
"feishu_app_secret": "",
|
"feishu_app_secret": "",
|
||||||
"feishu_stream_reply": true,
|
"feishu_stream_reply": true,
|
||||||
|
"feishu_markdown_card": true,
|
||||||
"dingtalk_client_id": "",
|
"dingtalk_client_id": "",
|
||||||
"dingtalk_client_secret": "",
|
"dingtalk_client_secret": "",
|
||||||
"wecom_bot_id": "",
|
"wecom_bot_id": "",
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ available_setting = {
|
|||||||
"feishu_event_mode": "websocket", # Feishu event mode: webhook(HTTP server) or websocket(long connection)
|
"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 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_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 config
|
||||||
"dingtalk_client_id": "", # DingTalk bot Client ID
|
"dingtalk_client_id": "", # DingTalk bot Client ID
|
||||||
"dingtalk_client_secret": "", # DingTalk bot Client Secret
|
"dingtalk_client_secret": "", # DingTalk bot Client Secret
|
||||||
|
|||||||
@@ -64,7 +64,8 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
"channel_type": "feishu",
|
"channel_type": "feishu",
|
||||||
"feishu_app_id": "YOUR_APP_ID",
|
"feishu_app_id": "YOUR_APP_ID",
|
||||||
"feishu_app_secret": "YOUR_APP_SECRET",
|
"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_id` | Feishu app App ID | - |
|
||||||
| `feishu_app_secret` | Feishu app App Secret | - |
|
| `feishu_app_secret` | Feishu app App Secret | - |
|
||||||
| `feishu_stream_reply` | Enable streaming typewriter reply | `true` |
|
| `feishu_stream_reply` | Enable streaming typewriter reply | `true` |
|
||||||
|
| `feishu_markdown_card` | Render non-streaming Markdown and scheduled text as Card 2.0 | `true` |
|
||||||
</Tab>
|
</Tab>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
@@ -98,6 +100,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
| Image messages | ✅ send/receive |
|
| Image messages | ✅ send/receive |
|
||||||
| Voice messages | ✅ send/receive |
|
| Voice messages | ✅ send/receive |
|
||||||
| Streaming reply | ✅ (powered by Feishu cardkit streaming card) |
|
| Streaming reply | ✅ (powered by Feishu cardkit streaming card) |
|
||||||
|
| Markdown card | ✅ for non-streaming and scheduled replies |
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
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.
|
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.
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
"channel_type": "feishu",
|
"channel_type": "feishu",
|
||||||
"feishu_app_id": "YOUR_APP_ID",
|
"feishu_app_id": "YOUR_APP_ID",
|
||||||
"feishu_app_secret": "YOUR_APP_SECRET",
|
"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_id` | Feishu アプリの App ID | - |
|
||||||
| `feishu_app_secret` | Feishu アプリの App Secret | - |
|
| `feishu_app_secret` | Feishu アプリの App Secret | - |
|
||||||
| `feishu_stream_reply` | ストリーミングタイプライター応答を有効化 | `true` |
|
| `feishu_stream_reply` | ストリーミングタイプライター応答を有効化 | `true` |
|
||||||
|
| `feishu_markdown_card` | 非ストリーミング Markdown と定期配信を Card 2.0 で表示 | `true` |
|
||||||
</Tab>
|
</Tab>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
@@ -95,6 +97,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
| 画像メッセージ | ✅ 送受信 |
|
| 画像メッセージ | ✅ 送受信 |
|
||||||
| 音声メッセージ | ✅ 送受信 |
|
| 音声メッセージ | ✅ 送受信 |
|
||||||
| ストリーミング応答 | ✅(Feishu cardkit ストリーミングカードベース) |
|
| ストリーミング応答 | ✅(Feishu cardkit ストリーミングカードベース) |
|
||||||
|
| Markdown カード | ✅ 非ストリーミング応答と定期配信 |
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
ストリーミング応答には `cardkit:card:write` 権限(ワンクリック作成では自動付与)と Feishu クライアント 7.20 以上が必要です。古いクライアントではアップグレード案内が表示され、権限/バージョン未充足時は通常テキスト応答に自動フォールバックします。
|
ストリーミング応答には `cardkit:card:write` 権限(ワンクリック作成では自動付与)と Feishu クライアント 7.20 以上が必要です。古いクライアントではアップグレード案内が表示され、権限/バージョン未充足時は通常テキスト応答に自動フォールバックします。
|
||||||
|
|||||||
@@ -65,7 +65,8 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
"channel_type": "feishu",
|
"channel_type": "feishu",
|
||||||
"feishu_app_id": "YOUR_APP_ID",
|
"feishu_app_id": "YOUR_APP_ID",
|
||||||
"feishu_app_secret": "YOUR_APP_SECRET",
|
"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_id` | 飞书应用 App ID | - |
|
||||||
| `feishu_app_secret` | 飞书应用 App Secret | - |
|
| `feishu_app_secret` | 飞书应用 App Secret | - |
|
||||||
| `feishu_stream_reply` | 是否开启流式打字机回复 | `true` |
|
| `feishu_stream_reply` | 是否开启流式打字机回复 | `true` |
|
||||||
|
| `feishu_markdown_card` | 将非流式 Markdown 与定时推送渲染为 Card 2.0 | `true` |
|
||||||
</Tab>
|
</Tab>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
@@ -99,6 +101,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
| 图片消息 | ✅ 收发 |
|
| 图片消息 | ✅ 收发 |
|
||||||
| 语音消息 | ✅ 收发 |
|
| 语音消息 | ✅ 收发 |
|
||||||
| 流式回复 | ✅(通过 `feishu_stream_reply` 配置控制,默认开启) |
|
| 流式回复 | ✅(通过 `feishu_stream_reply` 配置控制,默认开启) |
|
||||||
|
| Markdown 卡片 | ✅ 非流式回复与定时推送 |
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
流式回复需要机器人具备 `cardkit:card:write` 权限(一键创建已默认开通),且接收方飞书客户端版本 ≥ 7.20。低版本客户端会显示升级提示,权限或版本不满足时自动降级为普通文本回复。
|
流式回复需要机器人具备 `cardkit:card:write` 权限(一键创建已默认开通),且接收方飞书客户端版本 ≥ 7.20。低版本客户端会显示升级提示,权限或版本不满足时自动降级为普通文本回复。
|
||||||
|
|||||||
34
tests/test_feishu_static_card.py
Normal file
34
tests/test_feishu_static_card.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user