mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-20 21:57:14 +08:00
Merge pull request #2966 from AaronZ345/agent/feishu-quoted-context
feat: include quoted Feishu messages in agent context
This commit is contained in:
@@ -713,7 +713,7 @@ class FeiShuChanel(ChatChannel):
|
|||||||
|
|
||||||
context = self._compose_context(
|
context = self._compose_context(
|
||||||
feishu_msg.ctype,
|
feishu_msg.ctype,
|
||||||
feishu_msg.content,
|
feishu_msg.content_with_quote(),
|
||||||
isgroup=is_group,
|
isgroup=is_group,
|
||||||
msg=feishu_msg,
|
msg=feishu_msg,
|
||||||
receive_id_type=receive_id_type,
|
receive_id_type=receive_id_type,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class FeishuMessage(ChatMessage):
|
|||||||
self.msg_id = msg.get("message_id")
|
self.msg_id = msg.get("message_id")
|
||||||
self.create_time = msg.get("create_time")
|
self.create_time = msg.get("create_time")
|
||||||
self.is_group = is_group
|
self.is_group = is_group
|
||||||
|
self.quoted_content = ""
|
||||||
msg_type = msg.get("message_type")
|
msg_type = msg.get("message_type")
|
||||||
|
|
||||||
if msg_type == "text":
|
if msg_type == "text":
|
||||||
@@ -208,6 +209,9 @@ class FeishuMessage(ChatMessage):
|
|||||||
else:
|
else:
|
||||||
raise NotImplementedError("Unsupported message type: Type:{} ".format(msg_type))
|
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.from_user_id = sender.get("sender_id").get("open_id")
|
||||||
self.to_user_id = event.get("app_id")
|
self.to_user_id = event.get("app_id")
|
||||||
if is_group:
|
if is_group:
|
||||||
@@ -220,3 +224,99 @@ class FeishuMessage(ChatMessage):
|
|||||||
# 私聊
|
# 私聊
|
||||||
self.other_user_id = self.from_user_id
|
self.other_user_id = self.from_user_id
|
||||||
self.actual_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 |
|
| Text messages | ✅ send/receive |
|
||||||
| Image messages | ✅ send/receive |
|
| Image messages | ✅ send/receive |
|
||||||
| Voice messages | ✅ send/receive |
|
| Voice messages | ✅ send/receive |
|
||||||
|
| Quoted replies | ✅ quoted text and rich-post context |
|
||||||
| Streaming reply | ✅ (powered by Feishu cardkit streaming card) |
|
| Streaming reply | ✅ (powered by Feishu cardkit streaming card) |
|
||||||
| Markdown card | ✅ for non-streaming and scheduled replies |
|
| 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) |
|
| 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 ストリーミングカードベース) |
|
| ストリーミング応答 | ✅(Feishu cardkit ストリーミングカードベース) |
|
||||||
| Markdown カード | ✅ 非ストリーミング応答と定期配信 |
|
| Markdown カード | ✅ 非ストリーミング応答と定期配信 |
|
||||||
| リッチ進捗カード | ✅ ステータス、推論/ツールパネル、経過時間(`feishu_progress_card: true` で有効化、デフォルト無効) |
|
| リッチ進捗カード | ✅ ステータス、推論/ツールパネル、経過時間(`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` 配置控制,默认开启) |
|
| 流式回复 | ✅(通过 `feishu_stream_reply` 配置控制,默认开启) |
|
||||||
| Markdown 卡片 | ✅ 非流式回复与定时推送 |
|
| Markdown 卡片 | ✅ 非流式回复与定时推送 |
|
||||||
| 富进度卡片 | ✅ 状态头、思考/工具面板与耗时(需配置 `feishu_progress_card: true` 开启,默认关闭) |
|
| 富进度卡片 | ✅ 状态头、思考/工具面板与耗时(需配置 `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