mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-14 02:05:13 +08:00
fix(feishu): 修复群聊@回复发私聊及无目标通知报错 (#6262)
- 回复类消息携带 original_chat_id 时优先发回原会话,避免群聊@机器人的回复错误发送到私聊窗口 - update_or_post_message 编辑失败回退发新消息时保留原消息/会话上下文 - 无显式目标且未配置默认目标时,回退向最近互动过的会话广播发送(与企业微信策略一致) - 仍无可用目标时报出含配置指引的明确错误(FEISHU_OPEN_ID/FEISHU_CHAT_ID) Closes #6262
This commit is contained in:
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user