Merge pull request #2966 from AaronZ345/agent/feishu-quoted-context

feat: include quoted Feishu messages in agent context
This commit is contained in:
zhayujie
2026-07-19 11:23:53 +08:00
committed by GitHub
6 changed files with 209 additions and 1 deletions

View File

@@ -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,

View File

@@ -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 ""

View File

@@ -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) |

View File

@@ -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` で有効化、デフォルト無効) |

View File

@@ -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` 开启,默认关闭) |

View 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?"