From 85a7f98a9402c98654a0ff71ad84e944f7e5b559 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Mon, 10 Aug 2026 18:18:35 +0800 Subject: [PATCH] =?UTF-8?q?fix(feishu):=20=E4=BF=AE=E5=A4=8D=E7=BE=A4?= =?UTF-8?q?=E8=81=8A@=E5=9B=9E=E5=A4=8D=E5=8F=91=E7=A7=81=E8=81=8A?= =?UTF-8?q?=E5=8F=8A=E6=97=A0=E7=9B=AE=E6=A0=87=E9=80=9A=E7=9F=A5=E6=8A=A5?= =?UTF-8?q?=E9=94=99=20(#6262)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 回复类消息携带 original_chat_id 时优先发回原会话,避免群聊@机器人的回复错误发送到私聊窗口 - update_or_post_message 编辑失败回退发新消息时保留原消息/会话上下文 - 无显式目标且未配置默认目标时,回退向最近互动过的会话广播发送(与企业微信策略一致) - 仍无可用目标时报出含配置指引的明确错误(FEISHU_OPEN_ID/FEISHU_CHAT_ID) Closes #6262 --- app/helper/interaction.py | 4 + app/modules/feishu/__init__.py | 8 +- app/modules/feishu/feishu.py | 124 +++++++++++++---------- tests/test_feishu.py | 90 ++++++++++++++++ tests/test_slash_command_interactions.py | 30 +++++- 5 files changed, 202 insertions(+), 54 deletions(-) diff --git a/app/helper/interaction.py b/app/helper/interaction.py index 0bb079915..7203f7be9 100644 --- a/app/helper/interaction.py +++ b/app/helper/interaction.py @@ -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, ) ) diff --git a/app/modules/feishu/__init__.py b/app/modules/feishu/__init__.py index 8a0d8fa3c..9448e4fad 100644 --- a/app/modules/feishu/__init__.py +++ b/app/modules/feishu/__init__.py @@ -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 diff --git a/app/modules/feishu/feishu.py b/app/modules/feishu/feishu.py index 5bdbda1d0..07004368f 100644 --- a/app/modules/feishu/feishu.py +++ b/app/modules/feishu/feishu.py @@ -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} diff --git a/tests/test_feishu.py b/tests/test_feishu.py index 48c5dd2ac..c2b9bd470 100644 --- a/tests/test_feishu.py +++ b/tests/test_feishu.py @@ -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)) diff --git a/tests/test_slash_command_interactions.py b/tests/test_slash_command_interactions.py index 423982ba0..16b5160b6 100644 --- a/tests/test_slash_command_interactions.py +++ b/tests/test_slash_command_interactions.py @@ -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")