fix(feishu): 修复群聊@回复发私聊及无目标通知报错 (#6262)

- 回复类消息携带 original_chat_id 时优先发回原会话,避免群聊@机器人的回复错误发送到私聊窗口
- update_or_post_message 编辑失败回退发新消息时保留原消息/会话上下文
- 无显式目标且未配置默认目标时,回退向最近互动过的会话广播发送(与企业微信策略一致)
- 仍无可用目标时报出含配置指引的明确错误(FEISHU_OPEN_ID/FEISHU_CHAT_ID)

Closes #6262
This commit is contained in:
jxxghp
2026-08-10 18:18:35 +08:00
parent 16840c6767
commit 85a7f98a94
5 changed files with 202 additions and 54 deletions

View File

@@ -222,6 +222,10 @@ def update_or_post_message(
title=title,
text=text,
buttons=buttons,
# 编辑失败回退发新消息时保留原消息上下文,
# 保证飞书等渠道能回复到原会话(如群聊),而不是发给私聊。
original_message_id=original_message_id,
original_chat_id=original_chat_id,
save_history=False,
)
)

View File

@@ -54,11 +54,17 @@ class FeishuModule(_ModuleBase, _MessageBase[Feishu]):
def _resolve_message_target(
message: Notification,
) -> Tuple[Optional[str], Optional[str], Optional[str]]:
"""优先使用 open_id其次回退 user_id 或 chat_id。"""
"""解析发送目标:交互式回复优先回到原会话(群聊@回复必须回原群),其次 open_id最后回退 user_id 或 chat_id。"""
userid = str(message.userid).strip() if message.userid else None
chat_id = None
receive_id_type = "open_id" if userid else None
# 回复类消息携带原会话 ID 时,必须发回原会话,
# 否则群聊 @ 机器人的回复会错误地发送到机器人与用户的私聊窗口。
original_chat_id = str(message.original_chat_id or "").strip() or None
if original_chat_id:
return None, original_chat_id, "chat_id"
targets = message.targets or {}
if not userid and targets:
open_id = str(targets.get("feishu_openid") or "").strip() or None

View File

@@ -737,8 +737,8 @@ class Feishu:
userid: Optional[str] = None,
chat_id: Optional[str] = None,
receive_id_type: Optional[str] = None,
) -> Tuple[str, str]:
"""解析飞书发送目标,优先走显式用户,其次回退默认配置。"""
) -> Optional[Tuple[str, str]]:
"""解析飞书发送目标,优先走显式用户,其次回退默认配置;无可用目标时返回 None"""
resolved_userid = (userid or "").strip() or None
resolved_chat_id = (chat_id or "").strip() or None
normalized_receive_id_type = (receive_id_type or "").strip() or None
@@ -756,7 +756,55 @@ class Feishu:
return resolved_userid, remembered_type or "open_id"
if resolved_chat_id:
return resolved_chat_id, "chat_id"
raise ValueError("未找到可发送的飞书目标")
return None
def _remembered_broadcast_targets(self) -> List[Tuple[str, str]]:
"""无显式目标且未配置默认目标时,回退到最近互动过的会话逐一发送(与企业微信广播策略一致)。"""
seen = set()
targets = []
for remembered_chat_id in self._user_chat_mapping.values():
normalized = (remembered_chat_id or "").strip()
if not normalized or normalized in seen:
continue
seen.add(normalized)
targets.append((normalized, "chat_id"))
return targets
def _send_with_fallback_broadcast(
self,
msg_type: str,
content: dict,
userid: Optional[str] = None,
chat_id: Optional[str] = None,
receive_id_type: Optional[str] = None,
) -> Optional[dict]:
"""按解析目标发送消息;无可用目标时回退向已互动会话广播,仍无目标则报明确错误。"""
resolved = self._resolve_target(
userid=userid,
chat_id=chat_id,
receive_id_type=receive_id_type,
)
targets = [resolved] if resolved else self._remembered_broadcast_targets()
if not targets:
raise ValueError(
"未找到可发送的飞书目标请在飞书通知配置中设置默认接收人FEISHU_OPEN_ID或默认会话FEISHU_CHAT_ID"
)
result = None
for receive_id, resolved_receive_id_type in targets:
# 广播场景下单个会话发送失败不应阻断其余会话。
try:
sent = self._send_message(
receive_id,
resolved_receive_id_type,
msg_type,
content,
)
except Exception as err:
logger.error(f"飞书消息发送失败receive_id={receive_id}, err={err}")
sent = None
if sent:
result = result or sent
return result
@staticmethod
def _escape_card_text(text: Optional[str]) -> str:
@@ -1184,17 +1232,13 @@ class Feishu:
content={"type": "card", "data": {"card_id": card_id}},
)
else:
receive_id, resolved_receive_id_type = self._resolve_target(
result = self._send_with_fallback_broadcast(
"interactive",
{"type": "card", "data": {"card_id": card_id}},
userid=userid,
chat_id=chat_id,
receive_id_type=receive_id_type,
)
result = self._send_message(
receive_id,
resolved_receive_id_type,
"interactive",
{"type": "card", "data": {"card_id": card_id}},
)
if not result:
return None
result["metadata"] = {
@@ -1235,17 +1279,13 @@ class Feishu:
image_key=image_key,
)
try:
receive_id, resolved_receive_id_type = self._resolve_target(
self._send_with_fallback_broadcast(
"interactive",
payload,
userid=userid,
chat_id=chat_id,
receive_id_type=receive_id_type,
)
self._send_message(
receive_id,
resolved_receive_id_type,
"interactive",
payload,
)
sent_images.append(image_url)
except Exception as err:
logger.error(f"飞书 Agent 图片消息发送失败:{err}")
@@ -1527,17 +1567,13 @@ class Feishu:
content={"text": text},
)
else:
receive_id, resolved_receive_id_type = self._resolve_target(
result = self._send_with_fallback_broadcast(
"text",
{"text": text},
userid=userid,
chat_id=chat_id,
receive_id_type=receive_id_type,
)
result = self._send_message(
receive_id,
resolved_receive_id_type,
"text",
{"text": text},
)
except Exception as err:
logger.error(f"飞书文本消息发送失败:{err}")
return {"success": False}
@@ -1586,17 +1622,13 @@ class Feishu:
content=payload,
)
else:
receive_id, resolved_receive_id_type = self._resolve_target(
result = self._send_with_fallback_broadcast(
"interactive",
payload,
userid=userid,
chat_id=chat_id,
receive_id_type=receive_id_type,
)
result = self._send_message(
receive_id,
resolved_receive_id_type,
"interactive",
payload,
)
else:
file_key = self._upload_file(local_file, file_name=file_name)
if not file_key:
@@ -1608,17 +1640,13 @@ class Feishu:
content={"file_key": file_key},
)
else:
receive_id, resolved_receive_id_type = self._resolve_target(
result = self._send_with_fallback_broadcast(
"file",
{"file_key": file_key},
userid=userid,
chat_id=chat_id,
receive_id_type=receive_id_type,
)
result = self._send_message(
receive_id,
resolved_receive_id_type,
"file",
{"file_key": file_key},
)
if result and (title or text) and not is_image:
self.send_text(
self._build_message_text(title=title, text=text),
@@ -1663,17 +1691,13 @@ class Feishu:
content={"file_key": file_key},
)
else:
receive_id, resolved_receive_id_type = self._resolve_target(
result = self._send_with_fallback_broadcast(
"audio",
{"file_key": file_key},
userid=userid,
chat_id=chat_id,
receive_id_type=receive_id_type,
)
result = self._send_message(
receive_id,
resolved_receive_id_type,
"audio",
{"file_key": file_key},
)
if result and caption:
self.send_text(
caption,
@@ -1754,17 +1778,13 @@ class Feishu:
content=payload,
)
else:
receive_id, resolved_receive_id_type = self._resolve_target(
result = self._send_with_fallback_broadcast(
"interactive",
payload,
userid=userid,
chat_id=chat_id,
receive_id_type=receive_id_type,
)
result = self._send_message(
receive_id,
resolved_receive_id_type,
"interactive",
payload,
)
except Exception as err:
logger.error(f"飞书通知发送失败:{err}")
return {"success": False}

View File

@@ -1490,3 +1490,93 @@ class TestFeishu(unittest.TestCase):
client.send_notification.call_args.kwargs["original_message_id"],
"om_source",
)
def test_module_resolve_message_target_prefers_original_chat_id(self):
"""群聊@回复必须优先发回原会话,而不是按 open_id 发到私聊。"""
userid, chat_id, receive_id_type = FeishuModule._resolve_message_target(
Notification(userid="ou_user", original_chat_id="oc_group")
)
self.assertIsNone(userid)
self.assertEqual(chat_id, "oc_group")
self.assertEqual(receive_id_type, "chat_id")
# 非回复类消息仍按原有逻辑优先 open_id。
userid, chat_id, receive_id_type = FeishuModule._resolve_message_target(
Notification(userid="ou_user")
)
self.assertEqual(userid, "ou_user")
self.assertIsNone(chat_id)
self.assertEqual(receive_id_type, "open_id")
# 无用户ID时回退 targets 中的飞书字段。
userid, chat_id, receive_id_type = FeishuModule._resolve_message_target(
Notification(targets={"feishu_chat_id": "oc_config"})
)
self.assertIsNone(userid)
self.assertEqual(chat_id, "oc_config")
def test_module_post_message_replies_to_original_chat_for_group_message(self):
"""携带原会话上下文的回复应定向到原会话(群聊)。"""
module = FeishuModule()
conf = SimpleNamespace(name="feishu-main")
client = MagicMock()
with (
patch.object(module, "get_configs", return_value={"feishu-main": conf}),
patch.object(module, "check_message", return_value=True),
patch.object(module, "get_instance", return_value=client),
):
module.post_message(
Notification(
title="标题",
text="正文",
userid="ou_user",
original_chat_id="oc_group",
)
)
client.send_notification.assert_called_once_with(
message=ANY,
userid=None,
chat_id="oc_group",
receive_id_type="chat_id",
original_message_id=None,
)
def test_send_notification_broadcasts_to_remembered_chats_without_target(self):
"""无显式目标且未配置默认目标时,应回退向最近互动过的会话发送。"""
client = self._build_client()
client._api_client, message_api = self._build_message_api(
create_response=self._success_response()
)
client._user_chat_mapping = {
"ou_user_a": "oc_group_a",
"ou_user_b": "oc_group_a",
"ou_user_c": "oc_p2p_c",
}
result = client.send_notification(
Notification(title="插件通知", text="无目标通知")
)
self.assertTrue(result["success"])
self.assertEqual(message_api.create.call_count, 2)
receive_ids = [
call.args[0].request_body.receive_id
for call in message_api.create.call_args_list
]
self.assertEqual(sorted(receive_ids), ["oc_group_a", "oc_p2p_c"])
for call in message_api.create.call_args_list:
self.assertEqual(call.args[0].receive_id_type, "chat_id")
def test_send_without_target_raises_config_hint_when_nothing_available(self):
"""既无目标又无历史互动时,应报出含配置指引的明确错误。"""
client = self._build_client()
client._api_client, _ = self._build_message_api(
create_response=self._success_response()
)
with self.assertRaises(ValueError) as ctx:
client._send_with_fallback_broadcast("text", {"text": "hello"})
self.assertIn("FEISHU_OPEN_ID", str(ctx.exception))
self.assertIn("FEISHU_CHAT_ID", str(ctx.exception))

View File

@@ -1,6 +1,6 @@
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from unittest.mock import MagicMock, patch
from app.testing.bootstrap import ensure_optional_stub
@@ -237,3 +237,31 @@ class TestSlashCommandInteractions(unittest.TestCase):
"| 12 | Example Show | 电视剧 | 2024 | 第1季 [7/10] | 订阅中 |",
notification.text,
)
class TestUpdateOrPostMessage(unittest.TestCase):
def test_fallback_post_keeps_original_message_context(self):
"""编辑失败回退发新消息时,必须保留原消息/会话上下文,供渠道回复到原会话。"""
from app.helper.interaction import update_or_post_message
chain = SimpleNamespace(
edit_message=MagicMock(return_value=False),
post_message=MagicMock(),
)
update_or_post_message(
chain=chain,
channel=MessageChannel.Feishu,
source="feishu-main",
userid="ou_user",
username="tester",
title="标题",
text="正文",
original_message_id="om_origin",
original_chat_id="oc_group",
)
chain.post_message.assert_called_once()
notification = chain.post_message.call_args[0][0]
self.assertEqual(notification.original_message_id, "om_origin")
self.assertEqual(notification.original_chat_id, "oc_group")