mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-19 21:07:28 +08:00
feat: include Feishu quoted message context
This commit is contained in:
@@ -713,7 +713,7 @@ class FeiShuChanel(ChatChannel):
|
||||
|
||||
context = self._compose_context(
|
||||
feishu_msg.ctype,
|
||||
feishu_msg.content,
|
||||
feishu_msg.content_with_quote(),
|
||||
isgroup=is_group,
|
||||
msg=feishu_msg,
|
||||
receive_id_type=receive_id_type,
|
||||
|
||||
@@ -19,6 +19,7 @@ class FeishuMessage(ChatMessage):
|
||||
self.msg_id = msg.get("message_id")
|
||||
self.create_time = msg.get("create_time")
|
||||
self.is_group = is_group
|
||||
self.quoted_content = ""
|
||||
msg_type = msg.get("message_type")
|
||||
|
||||
if msg_type == "text":
|
||||
@@ -208,6 +209,9 @@ class FeishuMessage(ChatMessage):
|
||||
else:
|
||||
raise NotImplementedError("Unsupported message type: Type:{} ".format(msg_type))
|
||||
|
||||
if self.ctype == ContextType.TEXT:
|
||||
self.quoted_content = self._fetch_quoted_content(msg.get("parent_id"))
|
||||
|
||||
self.from_user_id = sender.get("sender_id").get("open_id")
|
||||
self.to_user_id = event.get("app_id")
|
||||
if is_group:
|
||||
@@ -220,3 +224,99 @@ class FeishuMessage(ChatMessage):
|
||||
# 私聊
|
||||
self.other_user_id = self.from_user_id
|
||||
self.actual_user_id = self.from_user_id
|
||||
|
||||
def content_with_quote(self) -> str:
|
||||
"""Return user text with optional quoted-message context for the agent."""
|
||||
if not self.quoted_content:
|
||||
return self.content
|
||||
return (
|
||||
"[Quoted message]\n{}\n[/Quoted message]\n\n{}".format(
|
||||
self.quoted_content,
|
||||
self.content,
|
||||
)
|
||||
)
|
||||
|
||||
def _fetch_quoted_content(self, parent_id: str) -> str:
|
||||
"""Fetch one parent message, degrading to an empty quote on failure."""
|
||||
if not parent_id or not self.access_token:
|
||||
return ""
|
||||
|
||||
url = "https://open.feishu.cn/open-apis/im/v1/messages/{}".format(parent_id)
|
||||
headers = {"Authorization": "Bearer " + self.access_token}
|
||||
try:
|
||||
response = requests.get(
|
||||
url=url,
|
||||
headers=headers,
|
||||
params={"card_msg_content_type": "raw_card_content"},
|
||||
timeout=(5, 10),
|
||||
)
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
"[FeiShu] quoted message fetch failed, parent_id=%s, status=%s",
|
||||
parent_id,
|
||||
response.status_code,
|
||||
)
|
||||
return ""
|
||||
body = response.json()
|
||||
items = (body.get("data") or {}).get("items") or []
|
||||
if body.get("code") != 0 or not items:
|
||||
return ""
|
||||
return self._extract_quoted_text(items[0])
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[FeiShu] quoted message fetch error, parent_id=%s: %s",
|
||||
parent_id,
|
||||
exc,
|
||||
)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _extract_quoted_text(item: dict) -> str:
|
||||
msg_type = item.get("msg_type")
|
||||
raw_content = (item.get("body") or {}).get("content") or ""
|
||||
try:
|
||||
content = json.loads(raw_content)
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
|
||||
if msg_type == "text":
|
||||
return str(content.get("text") or "").strip()
|
||||
if msg_type == "post":
|
||||
# Some message-history payloads wrap post content in a locale key.
|
||||
if "content" not in content:
|
||||
localized = next(
|
||||
(value for value in content.values() if isinstance(value, dict)),
|
||||
None,
|
||||
)
|
||||
if localized:
|
||||
content = localized
|
||||
|
||||
parts = []
|
||||
title = str(content.get("title") or "").strip()
|
||||
if title:
|
||||
parts.append(title)
|
||||
for block in content.get("content") or []:
|
||||
if not isinstance(block, list):
|
||||
continue
|
||||
for element in block:
|
||||
if not isinstance(element, dict):
|
||||
continue
|
||||
tag = element.get("tag")
|
||||
text = str(element.get("text") or "").strip()
|
||||
if tag == "text" and text:
|
||||
parts.append(text)
|
||||
elif tag == "a" and text:
|
||||
href = str(element.get("href") or "").strip()
|
||||
parts.append("{} ({})".format(text, href) if href else text)
|
||||
elif tag == "img":
|
||||
parts.append("[Image]")
|
||||
return "\n".join(parts).strip()
|
||||
if msg_type == "image":
|
||||
return "[Image]"
|
||||
if msg_type == "file":
|
||||
return "[File: {}]".format(content.get("file_name") or "file")
|
||||
if msg_type == "audio":
|
||||
return "[Audio]"
|
||||
if msg_type == "media":
|
||||
return "[Video: {}]".format(content.get("file_name") or "video")
|
||||
return ""
|
||||
|
||||
@@ -102,6 +102,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
||||
| Text messages | ✅ send/receive |
|
||||
| Image messages | ✅ send/receive |
|
||||
| Voice messages | ✅ send/receive |
|
||||
| Quoted replies | ✅ quoted text and rich-post context |
|
||||
| Streaming reply | ✅ (powered by Feishu cardkit streaming card) |
|
||||
| Markdown card | ✅ for non-streaming and scheduled replies |
|
||||
| Rich progress card | ✅ status header, reasoning/tool panels and elapsed time (enable with `feishu_progress_card: true`, off by default) |
|
||||
|
||||
@@ -99,6 +99,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
||||
| テキストメッセージ | ✅ 送受信 |
|
||||
| 画像メッセージ | ✅ 送受信 |
|
||||
| 音声メッセージ | ✅ 送受信 |
|
||||
| 引用返信 | ✅ 引用テキストとリッチテキストのコンテキスト |
|
||||
| ストリーミング応答 | ✅(Feishu cardkit ストリーミングカードベース) |
|
||||
| Markdown カード | ✅ 非ストリーミング応答と定期配信 |
|
||||
| リッチ進捗カード | ✅ ステータス、推論/ツールパネル、経過時間(`feishu_progress_card: true` で有効化、デフォルト無効) |
|
||||
|
||||
@@ -103,6 +103,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
||||
| 文本消息 | ✅ 收发 |
|
||||
| 图片消息 | ✅ 收发 |
|
||||
| 语音消息 | ✅ 收发 |
|
||||
| 引用回复 | ✅ 引用文本与富文本上下文 |
|
||||
| 流式回复 | ✅(通过 `feishu_stream_reply` 配置控制,默认开启) |
|
||||
| Markdown 卡片 | ✅ 非流式回复与定时推送 |
|
||||
| 富进度卡片 | ✅ 状态头、思考/工具面板与耗时(需配置 `feishu_progress_card: true` 开启,默认关闭) |
|
||||
|
||||
105
tests/test_feishu_quoted_context.py
Normal file
105
tests/test_feishu_quoted_context.py
Normal file
@@ -0,0 +1,105 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
from channel.feishu.feishu_message import FeishuMessage
|
||||
|
||||
|
||||
def _event(parent_id="om_parent", text="What does this mean?"):
|
||||
return {
|
||||
"app_id": "cli_bot",
|
||||
"sender": {"sender_id": {"open_id": "ou_user"}},
|
||||
"message": {
|
||||
"message_id": "om_child",
|
||||
"parent_id": parent_id,
|
||||
"chat_id": "oc_chat",
|
||||
"message_type": "text",
|
||||
"content": json.dumps({"text": text}),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _response(body, status_code=200):
|
||||
return SimpleNamespace(status_code=status_code, json=lambda: body)
|
||||
|
||||
|
||||
def test_text_reply_fetches_parent_without_replacing_user_message(monkeypatch):
|
||||
response = _response(
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"msg_type": "text",
|
||||
"body": {"content": json.dumps({"text": "Original answer"})},
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"channel.feishu.feishu_message.requests.get", lambda **kwargs: response
|
||||
)
|
||||
|
||||
message = FeishuMessage(_event(), access_token="tenant-token")
|
||||
|
||||
assert message.content == "What does this mean?"
|
||||
assert message.quoted_content == "Original answer"
|
||||
assert message.content_with_quote() == (
|
||||
"[Quoted message]\nOriginal answer\n[/Quoted message]\n\n"
|
||||
"What does this mean?"
|
||||
)
|
||||
|
||||
|
||||
def test_post_reply_extracts_title_and_text(monkeypatch):
|
||||
post = {
|
||||
"title": "Release note",
|
||||
"content": [
|
||||
[
|
||||
{"tag": "text", "text": "Fixed the scheduler."},
|
||||
{"tag": "a", "text": "Details", "href": "https://example.com"},
|
||||
]
|
||||
],
|
||||
}
|
||||
response = _response(
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"items": [
|
||||
{"msg_type": "post", "body": {"content": json.dumps(post)}}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"channel.feishu.feishu_message.requests.get", lambda **kwargs: response
|
||||
)
|
||||
|
||||
message = FeishuMessage(_event(), access_token="tenant-token")
|
||||
|
||||
assert message.quoted_content == (
|
||||
"Release note\nFixed the scheduler.\nDetails (https://example.com)"
|
||||
)
|
||||
|
||||
|
||||
def test_quote_fetch_failure_keeps_current_message_usable(monkeypatch):
|
||||
def fail(**kwargs):
|
||||
raise TimeoutError("network timeout")
|
||||
|
||||
monkeypatch.setattr("channel.feishu.feishu_message.requests.get", fail)
|
||||
|
||||
message = FeishuMessage(_event(), access_token="tenant-token")
|
||||
|
||||
assert message.quoted_content == ""
|
||||
assert message.content_with_quote() == "What does this mean?"
|
||||
|
||||
|
||||
def test_message_without_parent_does_not_fetch(monkeypatch):
|
||||
def unexpected(**kwargs):
|
||||
raise AssertionError("quote API should not be called")
|
||||
|
||||
monkeypatch.setattr("channel.feishu.feishu_message.requests.get", unexpected)
|
||||
|
||||
message = FeishuMessage(_event(parent_id=""), access_token="tenant-token")
|
||||
|
||||
assert message.quoted_content == ""
|
||||
assert message.content_with_quote() == "What does this mean?"
|
||||
Reference in New Issue
Block a user