From 2635c12a7ff78c7f5c6b41c602859af9c9077ee5 Mon Sep 17 00:00:00 2001 From: AaronZ345 <34849476+AaronZ345@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:09:48 +0800 Subject: [PATCH] feat: include Feishu quoted message context --- channel/feishu/feishu_channel.py | 2 +- channel/feishu/feishu_message.py | 100 ++++++++++++++++++++++++++ docs/channels/feishu.mdx | 1 + docs/ja/channels/feishu.mdx | 1 + docs/zh/channels/feishu.mdx | 1 + tests/test_feishu_quoted_context.py | 105 ++++++++++++++++++++++++++++ 6 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 tests/test_feishu_quoted_context.py diff --git a/channel/feishu/feishu_channel.py b/channel/feishu/feishu_channel.py index 97b6a9a2..ed34a725 100644 --- a/channel/feishu/feishu_channel.py +++ b/channel/feishu/feishu_channel.py @@ -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, diff --git a/channel/feishu/feishu_message.py b/channel/feishu/feishu_message.py index ea6ba271..ef49680e 100644 --- a/channel/feishu/feishu_message.py +++ b/channel/feishu/feishu_message.py @@ -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 "" diff --git a/docs/channels/feishu.mdx b/docs/channels/feishu.mdx index cbd83618..3a5ed74c 100644 --- a/docs/channels/feishu.mdx +++ b/docs/channels/feishu.mdx @@ -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) | diff --git a/docs/ja/channels/feishu.mdx b/docs/ja/channels/feishu.mdx index 67cadedd..cb3555b3 100644 --- a/docs/ja/channels/feishu.mdx +++ b/docs/ja/channels/feishu.mdx @@ -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` で有効化、デフォルト無効) | diff --git a/docs/zh/channels/feishu.mdx b/docs/zh/channels/feishu.mdx index 386a1f32..0e3b09cc 100644 --- a/docs/zh/channels/feishu.mdx +++ b/docs/zh/channels/feishu.mdx @@ -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` 开启,默认关闭) | diff --git a/tests/test_feishu_quoted_context.py b/tests/test_feishu_quoted_context.py new file mode 100644 index 00000000..f34cdcac --- /dev/null +++ b/tests/test_feishu_quoted_context.py @@ -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?"