mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 16:07:01 +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,
|
title=title,
|
||||||
text=text,
|
text=text,
|
||||||
buttons=buttons,
|
buttons=buttons,
|
||||||
|
# 编辑失败回退发新消息时保留原消息上下文,
|
||||||
|
# 保证飞书等渠道能回复到原会话(如群聊),而不是发给私聊。
|
||||||
|
original_message_id=original_message_id,
|
||||||
|
original_chat_id=original_chat_id,
|
||||||
save_history=False,
|
save_history=False,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -54,11 +54,17 @@ class FeishuModule(_ModuleBase, _MessageBase[Feishu]):
|
|||||||
def _resolve_message_target(
|
def _resolve_message_target(
|
||||||
message: Notification,
|
message: Notification,
|
||||||
) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
) -> 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
|
userid = str(message.userid).strip() if message.userid else None
|
||||||
chat_id = None
|
chat_id = None
|
||||||
receive_id_type = "open_id" if userid else 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 {}
|
targets = message.targets or {}
|
||||||
if not userid and targets:
|
if not userid and targets:
|
||||||
open_id = str(targets.get("feishu_openid") or "").strip() or None
|
open_id = str(targets.get("feishu_openid") or "").strip() or None
|
||||||
|
|||||||
@@ -737,8 +737,8 @@ class Feishu:
|
|||||||
userid: Optional[str] = None,
|
userid: Optional[str] = None,
|
||||||
chat_id: Optional[str] = None,
|
chat_id: Optional[str] = None,
|
||||||
receive_id_type: 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_userid = (userid or "").strip() or None
|
||||||
resolved_chat_id = (chat_id or "").strip() or None
|
resolved_chat_id = (chat_id or "").strip() or None
|
||||||
normalized_receive_id_type = (receive_id_type 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"
|
return resolved_userid, remembered_type or "open_id"
|
||||||
if resolved_chat_id:
|
if resolved_chat_id:
|
||||||
return resolved_chat_id, "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
|
@staticmethod
|
||||||
def _escape_card_text(text: Optional[str]) -> str:
|
def _escape_card_text(text: Optional[str]) -> str:
|
||||||
@@ -1184,17 +1232,13 @@ class Feishu:
|
|||||||
content={"type": "card", "data": {"card_id": card_id}},
|
content={"type": "card", "data": {"card_id": card_id}},
|
||||||
)
|
)
|
||||||
else:
|
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,
|
userid=userid,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
receive_id_type=receive_id_type,
|
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:
|
if not result:
|
||||||
return None
|
return None
|
||||||
result["metadata"] = {
|
result["metadata"] = {
|
||||||
@@ -1235,17 +1279,13 @@ class Feishu:
|
|||||||
image_key=image_key,
|
image_key=image_key,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
receive_id, resolved_receive_id_type = self._resolve_target(
|
self._send_with_fallback_broadcast(
|
||||||
|
"interactive",
|
||||||
|
payload,
|
||||||
userid=userid,
|
userid=userid,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
receive_id_type=receive_id_type,
|
receive_id_type=receive_id_type,
|
||||||
)
|
)
|
||||||
self._send_message(
|
|
||||||
receive_id,
|
|
||||||
resolved_receive_id_type,
|
|
||||||
"interactive",
|
|
||||||
payload,
|
|
||||||
)
|
|
||||||
sent_images.append(image_url)
|
sent_images.append(image_url)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
logger.error(f"飞书 Agent 图片消息发送失败:{err}")
|
logger.error(f"飞书 Agent 图片消息发送失败:{err}")
|
||||||
@@ -1527,17 +1567,13 @@ class Feishu:
|
|||||||
content={"text": text},
|
content={"text": text},
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
receive_id, resolved_receive_id_type = self._resolve_target(
|
result = self._send_with_fallback_broadcast(
|
||||||
|
"text",
|
||||||
|
{"text": text},
|
||||||
userid=userid,
|
userid=userid,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
receive_id_type=receive_id_type,
|
receive_id_type=receive_id_type,
|
||||||
)
|
)
|
||||||
result = self._send_message(
|
|
||||||
receive_id,
|
|
||||||
resolved_receive_id_type,
|
|
||||||
"text",
|
|
||||||
{"text": text},
|
|
||||||
)
|
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
logger.error(f"飞书文本消息发送失败:{err}")
|
logger.error(f"飞书文本消息发送失败:{err}")
|
||||||
return {"success": False}
|
return {"success": False}
|
||||||
@@ -1586,17 +1622,13 @@ class Feishu:
|
|||||||
content=payload,
|
content=payload,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
receive_id, resolved_receive_id_type = self._resolve_target(
|
result = self._send_with_fallback_broadcast(
|
||||||
|
"interactive",
|
||||||
|
payload,
|
||||||
userid=userid,
|
userid=userid,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
receive_id_type=receive_id_type,
|
receive_id_type=receive_id_type,
|
||||||
)
|
)
|
||||||
result = self._send_message(
|
|
||||||
receive_id,
|
|
||||||
resolved_receive_id_type,
|
|
||||||
"interactive",
|
|
||||||
payload,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
file_key = self._upload_file(local_file, file_name=file_name)
|
file_key = self._upload_file(local_file, file_name=file_name)
|
||||||
if not file_key:
|
if not file_key:
|
||||||
@@ -1608,17 +1640,13 @@ class Feishu:
|
|||||||
content={"file_key": file_key},
|
content={"file_key": file_key},
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
receive_id, resolved_receive_id_type = self._resolve_target(
|
result = self._send_with_fallback_broadcast(
|
||||||
|
"file",
|
||||||
|
{"file_key": file_key},
|
||||||
userid=userid,
|
userid=userid,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
receive_id_type=receive_id_type,
|
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:
|
if result and (title or text) and not is_image:
|
||||||
self.send_text(
|
self.send_text(
|
||||||
self._build_message_text(title=title, text=text),
|
self._build_message_text(title=title, text=text),
|
||||||
@@ -1663,17 +1691,13 @@ class Feishu:
|
|||||||
content={"file_key": file_key},
|
content={"file_key": file_key},
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
receive_id, resolved_receive_id_type = self._resolve_target(
|
result = self._send_with_fallback_broadcast(
|
||||||
|
"audio",
|
||||||
|
{"file_key": file_key},
|
||||||
userid=userid,
|
userid=userid,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
receive_id_type=receive_id_type,
|
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:
|
if result and caption:
|
||||||
self.send_text(
|
self.send_text(
|
||||||
caption,
|
caption,
|
||||||
@@ -1754,17 +1778,13 @@ class Feishu:
|
|||||||
content=payload,
|
content=payload,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
receive_id, resolved_receive_id_type = self._resolve_target(
|
result = self._send_with_fallback_broadcast(
|
||||||
|
"interactive",
|
||||||
|
payload,
|
||||||
userid=userid,
|
userid=userid,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
receive_id_type=receive_id_type,
|
receive_id_type=receive_id_type,
|
||||||
)
|
)
|
||||||
result = self._send_message(
|
|
||||||
receive_id,
|
|
||||||
resolved_receive_id_type,
|
|
||||||
"interactive",
|
|
||||||
payload,
|
|
||||||
)
|
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
logger.error(f"飞书通知发送失败:{err}")
|
logger.error(f"飞书通知发送失败:{err}")
|
||||||
return {"success": False}
|
return {"success": False}
|
||||||
|
|||||||
@@ -1490,3 +1490,93 @@ class TestFeishu(unittest.TestCase):
|
|||||||
client.send_notification.call_args.kwargs["original_message_id"],
|
client.send_notification.call_args.kwargs["original_message_id"],
|
||||||
"om_source",
|
"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
|
import unittest
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from app.testing.bootstrap import ensure_optional_stub
|
from app.testing.bootstrap import ensure_optional_stub
|
||||||
|
|
||||||
@@ -237,3 +237,31 @@ class TestSlashCommandInteractions(unittest.TestCase):
|
|||||||
"| 12 | Example Show | 电视剧 | 2024 | 第1季 [7/10] | 订阅中 |",
|
"| 12 | Example Show | 电视剧 | 2024 | 第1季 [7/10] | 订阅中 |",
|
||||||
notification.text,
|
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