diff --git a/app/modules/feishu/__init__.py b/app/modules/feishu/__init__.py index 9c356620..3793a0aa 100644 --- a/app/modules/feishu/__init__.py +++ b/app/modules/feishu/__init__.py @@ -1,7 +1,4 @@ -import asyncio -import json -import threading -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import Any, List, Optional, Tuple, Union from app.core.context import Context, MediaInfo from app.log import logger @@ -54,8 +51,31 @@ class FeishuModule(_ModuleBase, _MessageBase[Feishu]): """通知模块通过系统通知配置控制实例化,这里不额外设置环境开关。""" return None + @staticmethod + def _resolve_message_target( + message: Notification, + ) -> Tuple[Optional[str], Optional[str], Optional[str]]: + """优先使用 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 + + targets = message.targets or {} + if not userid and targets: + open_id = str(targets.get("feishu_openid") or "").strip() or None + user_id = str(targets.get("feishu_userid") or "").strip() or None + chat_id = str(targets.get("feishu_chat_id") or "").strip() or None + if open_id: + userid = open_id + receive_id_type = "open_id" + elif user_id: + userid = user_id + receive_id_type = "user_id" + + return userid, chat_id, receive_id_type + def message_parser( - self, source: str, body: Any, form: Any, args: Any + self, source: str, body: Any, form: Any, args: Any ) -> Optional[CommingMessage]: client_config = self.get_config(source) if not client_config: @@ -69,45 +89,55 @@ class FeishuModule(_ModuleBase, _MessageBase[Feishu]): for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue - targets = message.targets - userid = message.userid - chat_id = None - if not userid and targets is not None: - userid = targets.get("feishu_userid") or targets.get("feishu_openid") - chat_id = targets.get("feishu_chat_id") + userid, chat_id, receive_id_type = self._resolve_message_target(message) client: Feishu = self.get_instance(conf.name) if client: client.send_notification( message=message, - userid=str(userid).strip() if userid else None, - chat_id=str(chat_id).strip() if chat_id else None, + userid=userid, + chat_id=chat_id, + receive_id_type=receive_id_type, ) def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None: for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue + userid, chat_id, receive_id_type = self._resolve_message_target(message) client: Feishu = self.get_instance(conf.name) if client: - client.send_medias_message(message=message, medias=medias) + client.send_medias_message( + message=message, + medias=medias, + userid=userid, + chat_id=chat_id, + receive_id_type=receive_id_type, + ) def post_torrents_message(self, message: Notification, torrents: List[Context]) -> None: for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue + userid, chat_id, receive_id_type = self._resolve_message_target(message) client: Feishu = self.get_instance(conf.name) if client: - client.send_torrents_message(message=message, torrents=torrents) + client.send_torrents_message( + message=message, + torrents=torrents, + userid=userid, + chat_id=chat_id, + receive_id_type=receive_id_type, + ) def edit_message( - self, - channel: MessageChannel, - source: str, - message_id: Union[str, int], - chat_id: Union[str, int], - text: str, - title: Optional[str] = None, - buttons: Optional[List[List[dict]]] = None, + self, + channel: MessageChannel, + source: str, + message_id: Union[str, int], + chat_id: Union[str, int], + text: str, + title: Optional[str] = None, + buttons: Optional[List[List[dict]]] = None, ) -> bool: if channel != self._channel: return False @@ -116,10 +146,10 @@ class FeishuModule(_ModuleBase, _MessageBase[Feishu]): continue client: Feishu = self.get_instance(conf.name) if client and client.edit_message( - message_id=str(message_id), - title=title, - text=text, - buttons=buttons, + message_id=str(message_id), + title=title, + text=text, + buttons=buttons, ): return True return False @@ -128,19 +158,15 @@ class FeishuModule(_ModuleBase, _MessageBase[Feishu]): for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue - targets = message.targets - userid = message.userid - chat_id = None - if not userid and targets is not None: - userid = targets.get("feishu_userid") or targets.get("feishu_openid") - chat_id = targets.get("feishu_chat_id") + userid, chat_id, receive_id_type = self._resolve_message_target(message) client: Feishu = self.get_instance(conf.name) if not client: continue result = client.send_notification( message=message, - userid=str(userid).strip() if userid else None, - chat_id=str(chat_id).strip() if chat_id else None, + userid=userid, + chat_id=chat_id, + receive_id_type=receive_id_type, ) if result and result.get("success"): return MessageResponse( @@ -151,28 +177,3 @@ class FeishuModule(_ModuleBase, _MessageBase[Feishu]): success=True, ) return None - - -def run_async(coro): - try: - loop = asyncio.get_running_loop() - except RuntimeError: - return asyncio.run(coro) - - result: Dict[str, Any] = {} - error: Dict[str, BaseException] = {} - done = threading.Event() - - def runner(): - try: - result["value"] = asyncio.run(coro) - except BaseException as err: - error["value"] = err - finally: - done.set() - - threading.Thread(target=runner, daemon=True).start() - done.wait() - if error.get("value"): - raise error["value"] - return result.get("value") diff --git a/app/modules/feishu/feishu.py b/app/modules/feishu/feishu.py index fef05223..2b943db8 100644 --- a/app/modules/feishu/feishu.py +++ b/app/modules/feishu/feishu.py @@ -7,11 +7,10 @@ import lark_oapi as lark from lark_oapi.api.im.v1 import ( CreateMessageRequest, CreateMessageRequestBody, + PatchMessageRequest, + PatchMessageRequestBody, P2ImMessageReceiveV1, - UpdateMessageRequest, - UpdateMessageRequestBody, ) -from lark_oapi.card.model import Card from lark_oapi.core.const import FEISHU_DOMAIN from lark_oapi.core.enum import LogLevel from lark_oapi.event.callback.model.p2_card_action_trigger import ( @@ -58,6 +57,7 @@ class Feishu: self._stop_event = threading.Event() self._ws_thread: Optional[threading.Thread] = None self._user_chat_mapping: Dict[str, str] = {} + self._user_receive_id_type_mapping: Dict[str, str] = {} self._chat_open_mapping: Dict[str, str] = {} if not self._app_id or not self._app_secret: @@ -131,7 +131,8 @@ class Feishu: threading.Thread(target=_run, daemon=True).start() - def _extract_message_text(self, message) -> str: + @staticmethod + def _extract_message_text(message) -> str: """从飞书事件消息体中提取可读文本。""" raw_content = getattr(message, "content", None) if not raw_content: @@ -155,6 +156,19 @@ class Feishu: self._user_chat_mapping[normalized_userid] = normalized_chat_id self._chat_open_mapping[normalized_chat_id] = normalized_userid + def _remember_user_id_type( + self, + open_id: Optional[str] = None, + user_id: Optional[str] = None, + ) -> None: + """记住用户对应的飞书 ID 类型,避免回消息时误用 open_id/user_id。""" + normalized_open_id = (open_id or "").strip() + normalized_user_id = (user_id or "").strip() + if normalized_open_id: + self._user_receive_id_type_mapping[normalized_open_id] = "open_id" + if normalized_user_id: + self._user_receive_id_type_mapping[normalized_user_id] = "user_id" + def _on_message(self, data: P2ImMessageReceiveV1) -> None: """处理飞书长连接收到的普通消息事件。""" event = getattr(data, "event", None) @@ -180,6 +194,7 @@ class Feishu: }, } userid = open_id or user_id + self._remember_user_id_type(open_id=open_id, user_id=user_id) self._remember_target(userid=userid, chat_id=chat_id) logger.info( "收到来自 %s 的飞书消息:userid=%s, chat_id=%s, text=%s", @@ -216,6 +231,10 @@ class Feishu: }, } userid = payload["sender"].get("open_id") or payload["sender"].get("user_id") + self._remember_user_id_type( + open_id=payload["sender"].get("open_id"), + user_id=payload["sender"].get("user_id"), + ) self._remember_target(userid=userid, chat_id=payload.get("chat_id")) logger.info( "收到来自 %s 的飞书按钮回调:userid=%s, callback_data=%s", @@ -296,7 +315,12 @@ class Feishu: return None if text.startswith("/") and self._admins and str(userid) not in self._admins: - self.send_text("只有管理员才有权限执行此命令", userid=str(userid)) + self.send_text( + "只有管理员才有权限执行此命令", + userid=str(userid), + chat_id=message.get("chat_id"), + receive_id_type="open_id" if open_id else "user_id", + ) return None return CommingMessage( @@ -309,15 +333,28 @@ class Feishu: chat_id=message.get("chat_id"), ) - def _resolve_target(self, userid: Optional[str] = None, chat_id: Optional[str] = None) -> Tuple[str, str]: + def _resolve_target( + self, + userid: Optional[str] = None, + chat_id: Optional[str] = None, + receive_id_type: Optional[str] = None, + ) -> Tuple[str, str]: """解析飞书发送目标,优先走显式用户,其次回退默认配置。""" 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 if not resolved_userid and not resolved_chat_id: resolved_userid = self._default_open_id resolved_chat_id = self._default_chat_id + if resolved_userid and not normalized_receive_id_type: + normalized_receive_id_type = "open_id" + if normalized_receive_id_type == "chat_id" and resolved_chat_id: + return resolved_chat_id, "chat_id" if resolved_userid: - return resolved_userid, "open_id" + if normalized_receive_id_type in {"open_id", "user_id"}: + return resolved_userid, normalized_receive_id_type + remembered_type = self._user_receive_id_type_mapping.get(resolved_userid) + return resolved_userid, remembered_type or "open_id" if resolved_chat_id: return resolved_chat_id, "chat_id" raise ValueError("未找到可发送的飞书目标") @@ -414,11 +451,26 @@ class Feishu: "chat_id": getattr(data, "chat_id", None), } - def send_text(self, text: str, userid: Optional[str] = None, chat_id: Optional[str] = None) -> Optional[dict]: + def send_text( + self, + text: str, + userid: Optional[str] = None, + chat_id: Optional[str] = None, + receive_id_type: Optional[str] = None, + ) -> Optional[dict]: """发送纯文本消息。""" try: - receive_id, receive_id_type = self._resolve_target(userid=userid, chat_id=chat_id) - result = self._send_message(receive_id, receive_id_type, "text", {"text": text}) + receive_id, resolved_receive_id_type = self._resolve_target( + 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} @@ -428,7 +480,13 @@ class Feishu: result["chat_id"] = result.get("chat_id") or chat_id or self._user_chat_mapping.get(userid or "") or self._default_chat_id return result - def send_notification(self, message: Notification, userid: Optional[str] = None, chat_id: Optional[str] = None) -> Optional[dict]: + def send_notification( + self, + message: Notification, + userid: Optional[str] = None, + chat_id: Optional[str] = None, + receive_id_type: Optional[str] = None, + ) -> Optional[dict]: """发送通知消息,优先使用交互卡片承载按钮。""" payload = self._build_card( title=message.title, @@ -437,8 +495,17 @@ class Feishu: buttons=message.buttons, ) try: - receive_id, receive_id_type = self._resolve_target(userid=userid, chat_id=chat_id) - result = self._send_message(receive_id, receive_id_type, "interactive", {"card": payload}) + receive_id, resolved_receive_id_type = self._resolve_target( + 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} @@ -455,13 +522,12 @@ class Feishu: card = self._build_card(title=title, text=text, link=None, buttons=buttons) try: - response = self._api_client.im.v1.message.update( - UpdateMessageRequest.builder() + response = self._api_client.im.v1.message.patch( + PatchMessageRequest.builder() .message_id(message_id) .request_body( - UpdateMessageRequestBody.builder() - .msg_type("interactive") - .content(json.dumps({"card": card}, ensure_ascii=False)) + PatchMessageRequestBody.builder() + .content(json.dumps(card, ensure_ascii=False)) .build() ) .build() @@ -478,7 +544,14 @@ class Feishu: logger.error(f"飞书消息更新失败:{err}") return False - def send_medias_message(self, message: Notification, medias: List[MediaInfo]) -> Optional[dict]: + def send_medias_message( + self, + message: Notification, + medias: List[MediaInfo], + userid: Optional[str] = None, + chat_id: Optional[str] = None, + receive_id_type: Optional[str] = None, + ) -> Optional[dict]: """发送媒体列表消息,复用通知发送链路。""" lines = [] for index, media in enumerate(medias[:10], start=1): @@ -492,9 +565,21 @@ class Feishu: userid=message.userid, targets=message.targets, ) - return self.send_notification(proxy_message, userid=message.userid) + return self.send_notification( + proxy_message, + userid=userid or message.userid, + chat_id=chat_id, + receive_id_type=receive_id_type, + ) - def send_torrents_message(self, message: Notification, torrents: List[Context]) -> Optional[dict]: + def send_torrents_message( + self, + message: Notification, + torrents: List[Context], + userid: Optional[str] = None, + chat_id: Optional[str] = None, + receive_id_type: Optional[str] = None, + ) -> Optional[dict]: """发送种子列表消息,复用通知发送链路。""" lines = [] for index, torrent in enumerate(torrents[:10], start=1): @@ -509,4 +594,9 @@ class Feishu: userid=message.userid, targets=message.targets, ) - return self.send_notification(proxy_message, userid=message.userid) + return self.send_notification( + proxy_message, + userid=userid or message.userid, + chat_id=chat_id, + receive_id_type=receive_id_type, + ) diff --git a/tests/test_feishu.py b/tests/test_feishu.py new file mode 100644 index 00000000..baf11d8d --- /dev/null +++ b/tests/test_feishu.py @@ -0,0 +1,200 @@ +import sys +import json +import unittest +from types import ModuleType, SimpleNamespace +from unittest.mock import ANY, MagicMock, patch + + +sys.modules.setdefault("psutil", ModuleType("psutil")) +sys.modules.setdefault("cn2an", ModuleType("cn2an")) +sys.modules.setdefault("dateparser", ModuleType("dateparser")) +sys.modules.setdefault("zhconv", ModuleType("zhconv")) + +if "Pinyin2Hanzi" not in sys.modules: + pinyin_module = ModuleType("Pinyin2Hanzi") + setattr(pinyin_module, "is_pinyin", lambda value: False) + sys.modules["Pinyin2Hanzi"] = pinyin_module + +from app.modules.feishu import FeishuModule +from app.modules.feishu.feishu import Feishu +from app.schemas import Notification +from app.schemas.types import MessageChannel + + +class TestFeishu(unittest.TestCase): + @staticmethod + def _build_client(**kwargs) -> Feishu: + with patch.object(Feishu, "_build_api_client", return_value=MagicMock()), patch.object( + Feishu, "_start_ws_client" + ): + return Feishu( + FEISHU_APP_ID="cli_test_app_id", + FEISHU_APP_SECRET="cli_test_app_secret", + name="feishu-test", + **kwargs, + ) + + @staticmethod + def _success_response(message_id="om_test", chat_id="oc_test"): + response = MagicMock() + response.success.return_value = True + response.data = SimpleNamespace(message_id=message_id, chat_id=chat_id) + return response + + @staticmethod + def _build_message_api(create_response=None, patch_response=None): + message_api = SimpleNamespace( + create=MagicMock(return_value=create_response), + patch=MagicMock(return_value=patch_response), + update=MagicMock(), + ) + api_client = SimpleNamespace(im=SimpleNamespace(v1=SimpleNamespace(message=message_api))) + return api_client, message_api + + def test_parse_message_returns_callback_message(self): + client = self._build_client() + + result = client.parse_message( + { + "type": "cardAction", + "callback_data": "approve", + "message_id": "om_123", + "chat_id": "oc_123", + "sender": { + "open_id": "ou_user_1", + "user_id": "u_user_1", + "name": "tester", + }, + } + ) + + self.assertIsNotNone(result) + self.assertEqual(result.channel, MessageChannel.Feishu) + self.assertEqual(result.userid, "ou_user_1") + self.assertEqual(result.text, "CALLBACK:approve") + self.assertTrue(result.is_callback) + self.assertEqual(result.chat_id, "oc_123") + + def test_parse_message_blocks_non_admin_command(self): + client = self._build_client(FEISHU_ADMINS="ou_admin") + + with patch.object(client, "send_text", return_value={"success": True}) as send_text: + result = client.parse_message( + { + "type": "message", + "text": "/help", + "chat_id": "oc_chat_1", + "sender": { + "open_id": "ou_user_2", + "user_id": "u_user_2", + "name": "tester", + }, + } + ) + + self.assertIsNone(result) + send_text.assert_called_once_with( + "只有管理员才有权限执行此命令", + userid="ou_user_2", + chat_id="oc_chat_1", + receive_id_type="open_id", + ) + + def test_send_notification_uses_direct_card_content(self): + client = self._build_client() + client._api_client, message_api = self._build_message_api( + create_response=self._success_response() + ) + + result = client.send_notification( + Notification( + title="测试标题", + text="测试正文", + buttons=[[{"text": "确认", "callback_data": "confirm"}]], + ), + userid="ou_user_3", + ) + + self.assertTrue(result["success"]) + request = message_api.create.call_args.args[0] + self.assertEqual(request.receive_id_type, "open_id") + self.assertEqual(request.request_body.msg_type, "interactive") + + content = json.loads(request.request_body.content) + self.assertNotIn("card", content) + self.assertEqual(content["elements"][0]["tag"], "markdown") + + def test_send_notification_supports_user_id_target(self): + client = self._build_client() + client._api_client, message_api = self._build_message_api( + create_response=self._success_response() + ) + + client.send_notification( + Notification(title="测试标题", text="测试正文"), + userid="u_user_4", + receive_id_type="user_id", + ) + + request = message_api.create.call_args.args[0] + self.assertEqual(request.receive_id_type, "user_id") + + def test_edit_message_uses_patch_api_for_cards(self): + client = self._build_client() + client._api_client, message_api = self._build_message_api( + patch_response=self._success_response() + ) + + success = client.edit_message( + message_id="om_456", + title="测试标题", + text="测试正文", + buttons=[[{"text": "确认", "callback_data": "confirm"}]], + ) + + self.assertTrue(success) + message_api.patch.assert_called_once() + message_api.update.assert_not_called() + + request = message_api.patch.call_args.args[0] + self.assertEqual(request.message_id, "om_456") + content = json.loads(request.request_body.content) + self.assertNotIn("card", content) + self.assertEqual(content["elements"][0]["tag"], "markdown") + + def test_module_send_direct_message_prefers_open_id_target(self): + module = FeishuModule() + module._channel = MessageChannel.Feishu + conf = SimpleNamespace(name="feishu-main") + client = MagicMock() + client.send_notification.return_value = { + "success": True, + "message_id": "om_789", + "chat_id": "oc_789", + } + + 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): + response = module.send_direct_message( + Notification( + targets={ + "feishu_userid": "u_target", + "feishu_openid": "ou_target", + } + ) + ) + + client.send_notification.assert_called_once_with( + message=ANY, + userid="ou_target", + chat_id=None, + receive_id_type="open_id", + ) + self.assertTrue(response.success) + self.assertEqual(response.message_id, "om_789") + self.assertEqual(response.chat_id, "oc_789") + + +if __name__ == "__main__": + unittest.main()