""" 验证飞书渠道的消息解析、长连接、卡片流式更新和媒体发送。 """ import json import tempfile import threading from pathlib import Path from types import SimpleNamespace from unittest.mock import ANY, MagicMock, patch import pytest from app.testing.bootstrap import ensure_optional_stub # 可选三方依赖在 CI / 全新环境可能未安装,补占位避免 app.modules.feishu 导入失败 ensure_optional_stub("psutil") ensure_optional_stub("dateparser") ensure_optional_stub("Pinyin2Hanzi", is_pinyin=lambda value: False) # 先安装可选依赖占位,避免加载飞书及消息模块时缺少依赖。 from app.agent.callback import StreamingHandler # noqa: E402 # 先安装可选依赖占位,避免加载飞书及消息模块时缺少依赖。 from app.modules.feishu.feishu import Feishu # noqa: E402 # 先安装可选依赖占位,避免加载飞书及消息模块时缺少依赖。 from app.modules.feishu.module import FeishuModule # noqa: E402 # 先安装可选依赖占位,避免加载飞书及消息模块时缺少依赖。 from app.modules.feishu.openapi import FeishuOpenApi, FeishuResponse # noqa: E402 # 先安装可选依赖占位,避免加载飞书及消息模块时缺少依赖。 from app.schemas.message import ( # noqa: E402 Message, MessageResponse, ) # 先安装可选依赖占位,避免加载飞书及消息模块时缺少依赖。 from app.schemas.notification import ( # noqa: E402 ChannelCapability, ChannelCapabilityManager, ) # 先安装可选依赖占位,避免加载飞书及消息模块时缺少依赖。 from app.schemas.types import MessageType, NotificationChannel # noqa: E402 # 返回 FeishuResponse 的 OpenAPI 方法;桩默认返回失败,用例须显式声明期望成功的调用。 _JSON_API_METHODS = ( "create_message", "reply_message", "patch_message", "create_message_reaction", "delete_message_reaction", "upload_image", "upload_file", "create_card", "update_card_element_content", "update_card_settings", ) _STREAM_METADATA_TEMPLATE = { "card_id": "card_stream", "element_id": Feishu.STREAM_CARD_BODY_ELEMENT_ID, "sequence": 0, } def _ok(**data) -> FeishuResponse: """ 构造开放平台成功响应。 """ return FeishuResponse(code=0, msg="success", data=data, log_id="log_test") def _message_ok(message_id="om_test", chat_id="oc_test", msg_type="interactive") -> FeishuResponse: """ 构造发送/回复消息接口的成功响应。 """ return _ok(message_id=message_id, chat_id=chat_id, msg_type=msg_type) def _build_api() -> MagicMock: """ 构造按 FeishuOpenApi 契约约束的桩,所有 JSON 接口默认返回失败。 """ api = MagicMock(spec=FeishuOpenApi) for method in _JSON_API_METHODS: getattr(api, method).return_value = FeishuResponse(code=99999, msg="未设置桩响应") return api def _build_client(**kwargs) -> Feishu: """ 构造不会启动长连接、OpenAPI 为桩的飞书客户端。 """ with ( patch.object(Feishu, "_build_api_client", return_value=_build_api()), 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, ) @pytest.fixture def client() -> Feishu: """ 默认飞书客户端。 """ return _build_client() @pytest.fixture def api(client: Feishu) -> MagicMock: """ 客户端持有的 OpenAPI 桩。 """ return client._api_client def _sent_content(call) -> dict: """ 解析一次 create_message / reply_message 调用中的消息内容 JSON。 """ return json.loads(call.kwargs["content"]) def _stream_metadata(**overrides) -> dict: """ 构造流式卡片 metadata,每个用例独立一份,避免共享可变状态。 """ return {"feishu_streaming": {**_STREAM_METADATA_TEMPLATE, **overrides}} def _image_response(content: bytes = b"png-bytes", content_type: str = "image/png") -> MagicMock: """ 构造远程图片下载响应。 """ response = MagicMock() response.content = content response.headers = {"Content-Type": content_type} return response def _event(event_type: str, event: dict) -> bytes: """ 构造长连接收到的 2.0 版事件报文。 """ return json.dumps( {"schema": "2.0", "header": {"event_type": event_type, "event_id": "ev_test"}, "event": event}, ensure_ascii=False, ).encode("utf-8") def _message_event(message: dict, open_id: str = "ou_user_evt", user_id=None) -> dict: """ 构造 im.message.receive_v1 事件体。 """ return { "sender": {"sender_id": {"open_id": open_id, "user_id": user_id}}, "message": message, } # ---------------------------------------------------------------- 入站消息解析 def test_parse_message_returns_callback_message(client): """ 卡片回调解析为带渠道、用户及回调数据的统一消息。 """ with patch( "app.modules.feishu.feishu.get_configured_user_channel_lookup", return_value=lambda **_bindings: None, ): 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", }, } ) assert result is not None assert result.channel == NotificationChannel.Feishu assert result.userid == "ou_user_1" assert result.text == "CALLBACK:approve" assert result.is_callback assert result.chat_id == "oc_123" def test_extract_card_callback_data_supports_new_and_legacy_values(): """ 卡片回调数据提取兼容新字段、旧字段和直接字符串。 """ assert Feishu._extract_card_callback_data({"callback_data": "approve"}) == "approve" assert Feishu._extract_card_callback_data({"value": "legacy"}) == "legacy" assert Feishu._extract_card_callback_data("direct") == "direct" assert Feishu._extract_card_callback_data({}, name="fallback") == "fallback" def test_parse_message_blocks_non_admin_command(): """ 非管理员发出的管理命令不会进入消息处理。 """ client = _build_client(FEISHU_ADMINS="ou_admin") with ( patch( "app.modules.feishu.feishu.get_configured_user_channel_lookup", return_value=lambda **_bindings: None, ), 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", }, } ) assert result is None send_text.assert_called_once_with( "只有管理员才有权限执行此命令", userid="ou_user_2", chat_id="oc_chat_1", receive_id_type="open_id", ) def test_parse_message_maps_feishu_ids_to_moviepilot_username(client): """ 飞书稳定用户标识映射到绑定的 MoviePilot 用户名。 """ get_name = MagicMock(return_value="moviepilot-user") with patch( "app.modules.feishu.feishu.get_configured_user_channel_lookup", return_value=get_name, ): result = client.parse_message( { "type": "message", "text": "/ai 添加黑客帝国订阅", "sender": { "open_id": "ou_bound_user", "user_id": "u_bound_user", "name": "ou_bound_user", }, } ) assert result is not None assert result.userid == "ou_bound_user" assert result.username == "moviepilot-user" get_name.assert_called_once_with( feishu_openid="ou_bound_user", feishu_userid="u_bound_user", ) def test_parse_message_supports_image_and_file_payloads(client): """ 消息解析保留图片和通用文件的飞书资源引用。 """ with patch( "app.modules.feishu.feishu.get_configured_user_channel_lookup", return_value=lambda **_bindings: None, ): image_message = client.parse_message( { "type": "message", "text": "", "images": [{"ref": "feishu://image/img_v2_test"}], "message_id": "om_img", "chat_id": "oc_chat", "sender": {"open_id": "ou_user_5", "name": "tester"}, } ) file_message = client.parse_message( { "type": "message", "text": "", "files": [{"ref": "feishu://file/file_key/report.pdf", "name": "report.pdf"}], "message_id": "om_file", "chat_id": "oc_chat", "sender": {"open_id": "ou_user_6", "name": "tester"}, } ) assert image_message.images[0].ref == "feishu://image/img_v2_test" assert file_message.files[0].ref == "feishu://file/file_key/report.pdf" # ---------------------------------------------------------------- 长连接事件分发 def test_event_handlers_cover_common_im_and_card_events(client): """ 长连接需要应答的 IM 与卡片事件都应有处理函数,避免飞书按未处理事件重推。 """ handlers = client._event_handlers() assert set(handlers) >= { "im.message.receive_v1", "im.message.message_read_v1", "im.message.reaction.created_v1", "im.message.reaction.deleted_v1", "im.message.recalled_v1", "im.chat.access_event.bot_p2p_chat_entered_v1", "card.action.trigger", } assert all(callable(handler) for handler in handlers.values()) def test_dispatch_event_forwards_received_message_to_message_chain(client): """ 收到飞书消息事件后,将规范化载荷交给消息链。 """ event = _message_event( { "message_id": "om_text_evt", "chat_id": "oc_chat_evt", "chat_type": "group", "message_type": "text", "content": json.dumps({"text": " /search 黑客帝国 "}, ensure_ascii=False), }, open_id="ou_user_evt", user_id="u_user_evt", ) with patch.object(client, "_forward_to_message_chain") as forward: result = client._dispatch_event(_event("im.message.receive_v1", event)) assert result is None payload = forward.call_args.args[0] assert payload["type"] == "message" assert payload["source"] == "feishu-test" assert payload["message_id"] == "om_text_evt" assert payload["chat_id"] == "oc_chat_evt" assert payload["chat_type"] == "group" assert payload["text"] == "/search 黑客帝国" assert payload["sender"] == {"open_id": "ou_user_evt", "user_id": "u_user_evt", "name": "ou_user_evt"} # 收到消息后回复同一用户应落到其所在会话。 assert client._user_chat_mapping["ou_user_evt"] == "oc_chat_evt" def test_dispatch_card_action_returns_toast_and_forwards_callback(client): """ 卡片回调必须同步返回 toast(由长连接编码进应答帧),并转发 callback_data。 """ event = { "operator": {"open_id": None, "user_id": "u_operator"}, "action": {"value": {"callback_data": "/subscribe 1"}, "name": "btn"}, "context": {"open_message_id": "om_card", "open_chat_id": "oc_card"}, } with patch.object(client, "_forward_to_message_chain") as forward: result = client._dispatch_event(_event("card.action.trigger", event)) assert result == {"toast": {"type": "info", "content": "操作已提交"}} payload = forward.call_args.args[0] assert payload["type"] == "cardAction" assert payload["callback_data"] == "/subscribe 1" assert payload["message_id"] == "om_card" assert payload["chat_id"] == "oc_card" assert payload["sender"]["user_id"] == "u_operator" # 仅有 user_id 的操作者,后续回复应按 user_id 类型投递。 assert client._resolve_target(userid="u_operator") == ("u_operator", "user_id") @pytest.mark.parametrize( "event_type, event", [ ("im.message.message_read_v1", {"reader": {"reader_id": {"open_id": "ou_r"}}, "message_id_list": ["om_1"]}), ("im.message.reaction.created_v1", {"message_id": "om_1", "reaction_type": {"emoji_type": "OK"}}), ("im.message.reaction.deleted_v1", {"message_id": "om_1", "reaction_type": {"emoji_type": "OK"}}), ("im.message.recalled_v1", {"message_id": "om_1", "chat_id": "oc_1"}), ("im.chat.access_event.bot_p2p_chat_entered_v1", {"chat_id": "oc_1", "operator_id": {"open_id": "ou_1"}}), ("contact.user.created_v3", {"object": {}}), ], ) def test_dispatch_event_ignores_informational_and_unknown_events(client, event_type, event): """ 已读、表情、撤回等通知类事件与未知事件只记录日志,不进入消息链也不产生应答数据。 """ with patch.object(client, "_forward_to_message_chain") as forward: result = client._dispatch_event(_event(event_type, event)) assert result is None forward.assert_not_called() def test_on_message_wraps_feishu_image_ref_with_message_id(client): """ 图片引用包含原消息标识,供资源下载接口定位。 """ event = _message_event( { "message_id": "om_img_evt", "chat_id": "oc_chat_evt", "chat_type": "p2p", "message_type": "image", "content": json.dumps({"image_key": "img_v2_evt"}), } ) with patch.object(client, "_forward_to_message_chain") as forward: client._on_message(event) payload = forward.call_args.args[0] assert payload["images"][0]["ref"] == "feishu://image/om_img_evt/img_v2_evt" def test_on_message_wraps_feishu_audio_ref_with_message_id(client): """ 音频引用包含原消息标识、文件标识和名称。 """ event = _message_event( { "message_id": "om_audio_evt", "chat_id": "oc_chat_evt", "chat_type": "p2p", "message_type": "audio", "content": json.dumps({"file_key": "file_audio_evt", "file_name": "voice.opus"}), } ) with patch.object(client, "_forward_to_message_chain") as forward: client._on_message(event) payload = forward.call_args.args[0] assert payload["audio_refs"] == ["feishu://file/om_audio_evt/file_audio_evt/voice.opus"] # ---------------------------------------------------------------- 长连接生命周期 class _BlockingLongConnection: """ 替身长连接:run() 阻塞到 stop(),不访问网络;可配置为忽略 stop 模拟卡死线程。 """ instances = [] def __init__(self, app_id, app_secret, on_event, domain, name, honor_stop=True): """ 记录构造参数,供用例核对客户端传入的配置与事件回调。 """ self.app_id = app_id self.app_secret = app_secret self.on_event = on_event self.domain = domain self.name = name self.honor_stop = honor_stop self.running = threading.Event() self.release = threading.Event() self.stop_calls = 0 _BlockingLongConnection.instances.append(self) def run(self) -> None: """ 阻塞直到被释放。 """ self.running.set() self.release.wait(timeout=10) def stop(self) -> None: """ 记录停止请求,按配置决定是否让 run() 返回。 """ self.stop_calls += 1 if self.honor_stop: self.release.set() @pytest.fixture def long_connection_factory(): """ 把 Feishu 使用的 FeishuLongConnection 替换为阻塞替身,结束时释放所有线程。 """ _BlockingLongConnection.instances = [] options = {"honor_stop": True} def _factory(*args, **kwargs): return _BlockingLongConnection(*args, honor_stop=options["honor_stop"], **kwargs) with patch("app.modules.feishu.feishu.FeishuLongConnection", side_effect=_factory): yield options for instance in _BlockingLongConnection.instances: instance.release.set() def _start_client() -> Feishu: """ 构造会真实启动长连接线程的客户端(长连接为替身,OpenAPI 为桩)。 """ with patch.object(Feishu, "_build_api_client", return_value=_build_api()): return Feishu( FEISHU_APP_ID="cli_test_app_id", FEISHU_APP_SECRET="cli_test_app_secret", name="feishu-test", ) def test_start_ws_client_runs_long_connection_in_background_thread(long_connection_factory): """ 启动长连接时在独立后台线程运行,并公开就绪状态。 """ client = _start_client() connection = _BlockingLongConnection.instances[0] assert connection.running.wait(timeout=2) assert client._ws_thread.is_alive() assert client._ws_thread is not threading.current_thread() assert client.get_state() assert (connection.app_id, connection.app_secret, connection.name) == ( "cli_test_app_id", "cli_test_app_secret", "feishu-test", ) # 长连接收到的事件应交给该客户端自己的分发入口。 with patch.object(client, "_forward_to_message_chain"): toast = connection.on_event( _event("card.action.trigger", {"operator": {"open_id": "ou_1"}, "action": {"value": "ok"}}) ) assert toast["toast"]["content"] == "操作已提交" assert client.stop() def test_stop_stops_long_connection_joins_thread_and_closes_api(long_connection_factory): """ 停止时关闭长连接,等待线程结束并释放接口客户端。 """ client = _start_client() connection = _BlockingLongConnection.instances[0] assert connection.running.wait(timeout=2) api = client._api_client assert client.stop() is True assert connection.stop_calls == 1 assert not client._ws_thread.is_alive() assert not client.get_state() api.close.assert_called_once() def test_stop_returns_false_when_thread_exceeds_join_budget(long_connection_factory): """ 停止等待超过线程回收预算时返回 False,保留未结束状态。 """ long_connection_factory["honor_stop"] = False client = _start_client() client._ws_join_timeout_seconds = 0.05 connection = _BlockingLongConnection.instances[0] assert connection.running.wait(timeout=2) api = client._api_client assert client.stop() is False assert connection.stop_calls == 1 assert client._ws_thread.is_alive() # 线程仍可能在使用 OpenAPI 客户端,未退出时不得关闭 HTTP 会话。 api.close.assert_not_called() def test_run_ws_client_marks_ready_only_while_running(client): """ 长连接仅在实际运行期间公开就绪状态。 """ observed = [] client._ws_client = MagicMock() client._ws_client.run.side_effect = lambda: observed.append(client.get_state()) client._run_ws_client() assert observed == [True] assert not client.get_state() def test_run_ws_client_swallows_long_connection_error(client): """ 长连接运行异常不向外传播,结束后清除就绪状态。 """ client._ws_client = MagicMock() client._ws_client.run.side_effect = RuntimeError("fatal") client._run_ws_client() assert not client.get_state() # ---------------------------------------------------------------- 发送通知与卡片 def test_send_notification_uses_direct_card_content(client, api): """ 普通通知直接发送卡片正文,不增加多余包装层。 """ api.create_message.return_value = _message_ok() result = client.send_notification( Message( title="测试标题", text="测试正文", buttons=[[{"text": "确认", "callback_data": "confirm"}]], ), userid="ou_user_3", ) assert result["success"] call = api.create_message.call_args assert call.kwargs["receive_id_type"] == "open_id" assert call.kwargs["receive_id"] == "ou_user_3" assert call.kwargs["msg_type"] == "interactive" assert call.kwargs["uuid"] content = _sent_content(call) assert "card" not in content assert content["schema"] == "2.0" assert content["config"]["update_multi"] assert content["body"]["padding"] == "12px 12px 12px 12px" assert content["body"]["elements"][0]["text_size"] == "heading" assert content["body"]["elements"][0]["tag"] == "markdown" button = content["body"]["elements"][-1]["columns"][0]["elements"][0] assert button["tag"] == "button" assert "value" not in button assert button["behaviors"] == [{"type": "callback", "value": {"callback_data": "confirm"}}] def test_send_notification_returns_failure_when_api_rejects(client, api): """ 飞书接口拒绝通知时返回失败结果。 """ api.create_message.return_value = FeishuResponse(code=230002, msg="bot not in chat") result = client.send_notification(Message(title="标题", text="正文"), userid="ou_user_3") assert result == {"success": False} def test_send_notification_keeps_markdown_images_for_normal_card(client, api): """ 普通卡片保留正文中的 Markdown 图片语法。 """ api.create_message.return_value = _message_ok() result = client.send_notification( Message( title="普通通知", text="海报:![poster](https://example.com/poster.jpg)", ), userid="ou_user_img_md", ) assert result["success"] content = _sent_content(api.create_message.call_args) assert content["body"]["elements"][1]["content"] == "海报:![poster](https://example.com/poster.jpg)" def test_send_notification_embeds_remote_image_in_card(client, api): """ 远程图片下载上传后嵌入卡片,并清理临时文件。 """ uploaded = {} def _upload_image(file_path: Path): uploaded["path"] = file_path uploaded["bytes"] = file_path.read_bytes() return _ok(image_key="img_v2_remote") api.upload_image.side_effect = _upload_image api.create_message.return_value = _message_ok() response = _image_response() with patch("app.modules.feishu.feishu.RequestUtils") as request_utils: request_utils.return_value.get_res.return_value = response result = client.send_notification( Message( title="测试标题", text="测试正文", image="https://example.com/poster.png", buttons=[[{"text": "确认", "callback_data": "confirm"}]], ), userid="ou_user_img", ) assert result["success"] response.close.assert_called_once() api.upload_image.assert_called_once() assert uploaded["bytes"] == b"png-bytes" assert uploaded["path"].suffix == ".png" # 下载到的临时图片上传后必须清理。 assert not uploaded["path"].exists() call = api.create_message.call_args assert call.kwargs["msg_type"] == "interactive" content = _sent_content(call) assert content["body"]["padding"] == "0px 0px 0px 0px" image_element = content["body"]["elements"][0] assert image_element["tag"] == "img" assert image_element["img_key"] == "img_v2_remote" assert content["body"]["elements"][1]["margin"] == "12px 12px 0px 12px" assert content["body"]["elements"][2]["margin"] == "4px 12px 12px 12px" assert content["body"]["elements"][-1]["margin"] == "0px 12px 12px 12px" assert content["body"]["elements"][-1]["tag"] == "column_set" def test_send_notification_supports_user_id_target(client, api): """ 通知接收目标支持飞书 user_id 标识类型。 """ api.create_message.return_value = _message_ok() client.send_notification( Message(title="测试标题", text="测试正文"), userid="u_user_4", receive_id_type="user_id", ) call = api.create_message.call_args assert call.kwargs["receive_id_type"] == "user_id" assert call.kwargs["receive_id"] == "u_user_4" def test_edit_message_uses_patch_api_for_cards(client, api): """ 普通卡片编辑通过消息更新接口提交完整卡片正文。 """ api.patch_message.return_value = _ok() success = client.edit_message( message_id="om_456", title="测试标题", text="测试正文", buttons=[[{"text": "确认", "callback_data": "confirm"}]], ) assert success api.patch_message.assert_called_once() # 带按钮的普通卡片走 IM PATCH,不得误入 CardKit 流式更新。 api.update_card_element_content.assert_not_called() message_id, card_json = api.patch_message.call_args.args assert message_id == "om_456" content = json.loads(card_json) assert "card" not in content assert content["schema"] == "2.0" assert content["config"]["update_multi"] assert content["body"]["elements"][0]["tag"] == "markdown" button = content["body"]["elements"][-1]["columns"][0]["elements"][0] assert button["behaviors"] == [{"type": "callback", "value": {"callback_data": "confirm"}}] def test_send_notification_replies_when_original_message_id_is_present(client, api): """ 存在原消息标识时,通过回复接口发送通知卡片。 """ api.reply_message.return_value = _message_ok(message_id="om_reply") result = client.send_notification( Message(title="回复标题", text="回复正文"), userid="ou_user_9", original_message_id="om_origin", ) assert result["success"] assert result["message_id"] == "om_reply" api.create_message.assert_not_called() api.reply_message.assert_called_once() call = api.reply_message.call_args assert call.kwargs["message_id"] == "om_origin" assert call.kwargs["msg_type"] == "interactive" assert call.kwargs["reply_in_thread"] is False assert _sent_content(call)["schema"] == "2.0" def test_message_reaction_create_and_delete_use_official_api(client, api): """ 消息表情的添加与删除使用飞书官方接口。 """ api.create_message_reaction.return_value = _ok(reaction_id="reaction_1") api.delete_message_reaction.return_value = _ok() reaction_id = client.add_message_reaction("om_origin", Feishu.PROCESSING_REACTION_EMOJI) deleted = client.delete_message_reaction("om_origin", "reaction_1") assert reaction_id == "reaction_1" assert deleted api.create_message_reaction.assert_called_once_with("om_origin", Feishu.PROCESSING_REACTION_EMOJI) api.delete_message_reaction.assert_called_once_with("om_origin", "reaction_1") def test_message_reaction_failures_return_empty_results(client, api): """ 消息表情操作失败时分别返回空标识或失败状态。 """ api.create_message_reaction.return_value = FeishuResponse(code=231001, msg="denied") api.delete_message_reaction.return_value = FeishuResponse(code=231001, msg="denied") assert client.add_message_reaction("om_origin", Feishu.PROCESSING_REACTION_EMOJI) is None assert client.delete_message_reaction("om_origin", "reaction_1") is False # ---------------------------------------------------------------- 流式卡片 def test_send_notification_uses_streaming_card_for_agent_text(client, api): """ Agent 文本通知创建流式卡片并返回后续更新所需元数据。 """ api.create_card.return_value = _ok(card_id="card_stream") api.create_message.return_value = _message_ok(message_id="om_stream", chat_id="oc_stream") result = client.send_notification( Message(mtype=MessageType.Agent, title="MoviePilot助手", text="第一帧内容"), userid="ou_user_stream", ) assert result["success"] assert result["metadata"]["feishu_streaming"]["card_id"] == "card_stream" assert result["metadata"]["feishu_streaming"]["sequence"] == 0 assert result["metadata"]["feishu_streaming"]["sent_image_urls"] == [] card_payload = json.loads(api.create_card.call_args.args[0]) assert card_payload["config"]["streaming_mode"] assert card_payload["body"]["elements"][-1]["element_id"] == Feishu.STREAM_CARD_BODY_ELEMENT_ID assert card_payload["body"]["elements"][-1]["content"] == "第一帧内容" call = api.create_message.call_args assert call.kwargs["msg_type"] == "interactive" assert _sent_content(call) == {"type": "card", "data": {"card_id": "card_stream"}} def test_streaming_card_not_sent_when_card_creation_fails(client, api): """ 流式卡片创建失败时不继续发送消息。 """ api.create_card.return_value = FeishuResponse(code=200740, msg="invalid card") result = client.send_notification( Message(mtype=MessageType.Agent, title="MoviePilot助手", text="第一帧内容"), userid="ou_user_stream", ) assert result == {"success": False} api.create_message.assert_not_called() def test_streaming_card_sends_markdown_images_separately(client, api): """ 流式卡片将 Markdown 图片独立发送,正文保留可读替代文本。 """ api.create_card.return_value = _ok(card_id="card_stream") api.create_message.return_value = _message_ok(message_id="om_stream", chat_id="oc_stream") api.upload_image.return_value = _ok(image_key="img_v2_stream") with patch("app.modules.feishu.feishu.RequestUtils") as request_utils: request_utils.return_value.get_res.return_value = _image_response(content_type="image/jpeg") result = client.send_notification( Message( mtype=MessageType.Agent, title="MoviePilot助手", text="找到海报 ![poster](https://example.com/poster.jpg)\n[详情](https://example.com/detail)", ), userid="ou_user_stream", ) assert result["success"] card_payload = json.loads(api.create_card.call_args.args[0]) body_content = card_payload["body"]["elements"][-1]["content"] assert "![poster]" not in body_content assert "poster.jpg" not in body_content assert "poster" in body_content assert "[详情](https://example.com/detail)" in body_content assert api.upload_image.call_count == 1 assert api.create_message.call_count == 2 assert result["metadata"]["feishu_streaming"]["sent_image_urls"] == ["https://example.com/poster.jpg"] image_payload = _sent_content(api.create_message.call_args_list[-1]) assert image_payload["body"]["elements"][0]["img_key"] == "img_v2_stream" def test_streaming_card_sends_notification_image_separately(client, api): """ 流式通知的附带图片独立发送,并记录已发送地址。 """ api.create_card.return_value = _ok(card_id="card_stream") api.create_message.return_value = _message_ok(message_id="om_stream", chat_id="oc_stream") api.upload_image.return_value = _ok(image_key="img_v2_agent_image") with patch("app.modules.feishu.feishu.RequestUtils") as request_utils: request_utils.return_value.get_res.return_value = _image_response() result = client.send_notification( Message( mtype=MessageType.Agent, title="MoviePilot助手", text="第一帧内容", image="https://example.com/agent.png", ), userid="ou_user_stream", ) assert result["success"] assert api.upload_image.call_count == 1 assert api.create_message.call_count == 2 assert result["metadata"]["feishu_streaming"]["sent_image_urls"] == ["https://example.com/agent.png"] stream_call, image_call = api.create_message.call_args_list assert _sent_content(stream_call)["data"]["card_id"] == "card_stream" assert _sent_content(image_call)["body"]["elements"][0]["img_key"] == "img_v2_agent_image" def test_send_notification_replies_with_streaming_card_for_agent_text(client, api): """ Agent 回复通过回复接口引用已创建的流式卡片。 """ api.create_card.return_value = _ok(card_id="card_stream") api.reply_message.return_value = _message_ok(message_id="om_reply", chat_id="oc_stream") result = client.send_notification( Message(mtype=MessageType.Agent, title="MoviePilot助手", text="第一帧内容"), userid="ou_user_stream", original_message_id="om_origin", ) assert result["success"] api.create_message.assert_not_called() call = api.reply_message.call_args assert call.kwargs["message_id"] == "om_origin" assert call.kwargs["msg_type"] == "interactive" assert _sent_content(call)["data"]["card_id"] == "card_stream" assert result["metadata"]["feishu_streaming"]["sequence"] == 0 def test_edit_replied_streaming_card_uses_first_increment_sequence(client, api): """ 回复流式卡片的首次内容更新使用递增序号 1。 """ api.update_card_element_content.return_value = _ok() metadata = _stream_metadata(sent_image_urls=["https://example.com/poster.jpg"]) success = client.edit_message(message_id="om_reply", text="补充内容", metadata=metadata) assert success api.patch_message.assert_not_called() assert api.update_card_element_content.call_args.kwargs["sequence"] == 1 assert metadata["feishu_streaming"]["sequence"] == 1 def test_edit_message_uses_cardkit_content_for_streaming_card(client, api): """ 流式卡片编辑通过 CardKit 更新正文元素。 """ api.update_card_element_content.return_value = _ok() success = client.edit_message( message_id="om_stream", text="第二帧内容", metadata=_stream_metadata(sent_image_urls=["https://example.com/poster.jpg"]), ) assert success api.update_card_element_content.assert_called_once() api.patch_message.assert_not_called() kwargs = api.update_card_element_content.call_args.kwargs assert kwargs["card_id"] == "card_stream" assert kwargs["element_id"] == Feishu.STREAM_CARD_BODY_ELEMENT_ID assert kwargs["content"] == "第二帧内容" assert kwargs["sequence"] == 1 assert kwargs["uuid"] def test_edit_streaming_card_sequence_keeps_increasing_after_failed_update(client, api, monkeypatch): """ 某次流式更新失败后,下一次仍须使用更大的 sequence,否则会被 CardKit 持续拒绝。 """ api.update_card_element_content.side_effect = [ FeishuResponse(code=-1, msg="timeout"), _ok(), ] metadata = _stream_metadata(sequence=4, sent_image_urls=[]) clock = [0.0] monkeypatch.setattr("app.modules.feishu.feishu.time.monotonic", lambda: clock[0]) first = client.edit_message(message_id="om_stream", text="第一次", metadata=metadata) clock[0] += Feishu._STREAM_RETRY_INITIAL_SECONDS second = client.edit_message(message_id="om_stream", text="第二次", metadata=metadata) assert first is False assert second is True sequences = [call.kwargs["sequence"] for call in api.update_card_element_content.call_args_list] assert sequences == [5, 6] # 流式更新失败不得降级为普通卡片 PATCH。 api.patch_message.assert_not_called() @pytest.mark.parametrize("code", [200850, 300309]) def test_edit_streaming_card_reopens_and_retries_with_shared_sequence(client, api, code): """ 超过十分钟后的关闭错误应在同一卡片重开,每次请求使用新序号和 UUID。 """ api.update_card_element_content.side_effect = [ FeishuResponse(code=code, msg="streaming mode is closed"), _ok(), _ok(), ] api.update_card_settings.return_value = _ok() metadata = _stream_metadata(sequence=480) assert client.edit_message("om_stream", text="完整累积内容", metadata=metadata) assert client.edit_message("om_stream", text="下一帧", metadata=metadata) requests = api.mock_calls assert [request[0] for request in requests] == [ "update_card_element_content", "update_card_settings", "update_card_element_content", "update_card_element_content", ] assert [request.kwargs["sequence"] for request in requests] == [481, 482, 483, 484] assert len({request.kwargs["uuid"] for request in requests}) == 4 assert {request.kwargs["card_id"] for request in requests} == {"card_stream"} assert json.loads(requests[1].kwargs["settings"]) == {"config": {"streaming_mode": True}} assert requests[2].kwargs["content"] == "完整累积内容" assert metadata["feishu_streaming"]["sequence"] == 484 assert "retry_at" not in metadata["feishu_streaming"] api.patch_message.assert_not_called() def test_edit_streaming_card_can_recover_on_later_expiration(client, api): """ 同一次长会话再次到期后仍可重开,收尾序号大于全部恢复请求。 """ closed = FeishuResponse(code=300309, msg="closed") api.update_card_element_content.side_effect = [closed, _ok(), closed, _ok()] api.update_card_settings.return_value = _ok() metadata = _stream_metadata() assert client.edit_message("om_stream", text="十分钟", metadata=metadata) assert client.edit_message("om_stream", text="二十分钟", metadata=metadata) assert client.close_streaming_card("card_stream", metadata["feishu_streaming"]["sequence"] + 1) assert [request.kwargs["sequence"] for request in api.mock_calls] == list(range(1, 8)) assert json.loads(api.mock_calls[-1].kwargs["settings"]) == {"config": {"streaming_mode": False}} @pytest.mark.parametrize("failure", ["settings", "content", "ordinary", "exception"]) def test_edit_streaming_card_failure_backs_off_and_recovers(client, api, monkeypatch, failure): """ 恢复失败或普通请求失败后,高频刷新不出站;到期重试最新文本并清除退避。 """ clock = [100.0] monkeypatch.setattr("app.modules.feishu.feishu.time.monotonic", lambda: clock[0]) closed = FeishuResponse(code=300309, msg="closed") api.update_card_element_content.side_effect = { "settings": [closed], "content": [closed, closed], "ordinary": [FeishuResponse(code=-1, msg="timeout")], "exception": [RuntimeError("transport failure")], }[failure] api.update_card_settings.return_value = ( _ok() if failure == "content" else FeishuResponse(code=-1, msg="timeout") ) metadata = _stream_metadata(sequence=4) if failure == "exception": with pytest.raises(RuntimeError, match="transport failure"): client.edit_message("om_stream", text="未发送", metadata=metadata) else: assert not client.edit_message("om_stream", text="未发送", metadata=metadata) count = len(api.mock_calls) sequence = metadata["feishu_streaming"]["sequence"] assert count == {"settings": 2, "content": 3, "ordinary": 1, "exception": 1}[failure] for _ in range(100): assert not client.edit_message("om_stream", text="更多内容", metadata=metadata) assert len(api.mock_calls) == count assert metadata["feishu_streaming"]["sequence"] == sequence api.update_card_element_content.side_effect = None api.update_card_element_content.return_value = _ok() clock[0] += Feishu._STREAM_RETRY_INITIAL_SECONDS assert client.edit_message("om_stream", text="最新完整内容", metadata=metadata) assert api.update_card_element_content.call_args.kwargs["content"] == "最新完整内容" assert metadata["feishu_streaming"]["sequence"] == sequence + 1 assert "retry_at" not in metadata["feishu_streaming"] assert "retry_delay" not in metadata["feishu_streaming"] api.patch_message.assert_not_called() def test_edit_streaming_card_backoff_is_capped_and_card_local(client, api, monkeypatch): """ 持续失败的重试间隔递增到一分钟,不影响其他卡片。 """ clock = [0.0] monkeypatch.setattr("app.modules.feishu.feishu.time.monotonic", lambda: clock[0]) api.update_card_element_content.return_value = FeishuResponse(code=-1, msg="timeout") metadata = _stream_metadata() with patch("app.modules.feishu.feishu.logger.warning") as warning: for delay in [2, 4, 8, 16, 32, 60, 60]: assert not client.edit_message("om_stream", text="待发送", metadata=metadata) assert metadata["feishu_streaming"]["retry_at"] == clock[0] + delay count = api.update_card_element_content.call_count clock[0] += delay - 0.1 assert not client.edit_message("om_stream", text="待发送", metadata=metadata) assert api.update_card_element_content.call_count == count clock[0] += 0.1 assert warning.call_count == 7 api.update_card_element_content.return_value = _ok() assert client.edit_message("om_other", text="独立卡片", metadata=_stream_metadata(card_id="other")) assert api.update_card_element_content.call_args.kwargs["card_id"] == "other" api.update_card_settings.assert_not_called() api.patch_message.assert_not_called() @pytest.mark.asyncio @pytest.mark.parametrize("recover", [False, True]) async def test_agent_flush_preserves_unsent_text_during_feishu_backoff(client, api, monkeypatch, recover): """ 真实刷新链保留失败文本和共享序号;收尾只在全部送达后报告成功。 """ clock = [100.0] monkeypatch.setattr("app.modules.feishu.feishu.time.monotonic", lambda: clock[0]) api.update_card_element_content.return_value = FeishuResponse(code=300309, msg="closed") api.update_card_settings.return_value = FeishuResponse(code=-1, msg="timeout") handler = StreamingHandler() handler._streaming_enabled = True handler._channel = NotificationChannel.Feishu.value handler._source = "feishu-test" handler._buffer = "完整回复" handler._sent_text = "旧回复" handler._message_response = MessageResponse( channel=NotificationChannel.Feishu, source="feishu-test", message_id="om_stream", chat_id="oc_test", success=True, metadata=_stream_metadata(), ) response = handler._message_response chain = MagicMock() chain.edit_message.side_effect = lambda **kwargs: client.edit_message( kwargs["message_id"], text=kwargs["text"], metadata=kwargs["metadata"], ) chain.finalize_message.side_effect = lambda result: client.close_streaming_card( result.metadata["feishu_streaming"]["card_id"], result.metadata["feishu_streaming"]["sequence"] + 1, ) with patch("app.agent.callback._StreamChain", return_value=chain): await handler._flush() for _ in range(10): await handler._flush() assert api.update_card_element_content.call_count == 1 assert handler._sent_text == "旧回复" assert response.metadata["feishu_streaming"]["sequence"] == 2 if recover: clock[0] += Feishu._STREAM_RETRY_INITIAL_SECONDS api.update_card_element_content.return_value = _ok() api.update_card_settings.return_value = _ok() all_sent, final_text = await handler.stop_streaming() assert all_sent is recover assert final_text == ("完整回复" if recover else "") if not recover: assert await handler.take() == "完整回复" assert api.update_card_settings.call_args.kwargs["sequence"] == (4 if recover else 3) api.patch_message.assert_not_called() def test_edit_streaming_card_removes_markdown_image_syntax(client, api): """ 流式内容更新移除 Markdown 图片语法,保留图片替代文本。 """ api.update_card_element_content.return_value = _ok() success = client.edit_message( message_id="om_stream", text="第二帧 ![poster](https://example.com/poster.jpg)", metadata=_stream_metadata(sent_image_urls=["https://example.com/poster.jpg"]), ) assert success api.patch_message.assert_not_called() assert api.update_card_element_content.call_args.kwargs["content"] == "第二帧 poster" def test_edit_streaming_card_keeps_normal_markdown_links(client, api): """ 流式内容更新保留普通 Markdown 链接。 """ api.update_card_element_content.return_value = _ok() success = client.edit_message( message_id="om_stream", text="第二帧 [详情](https://example.com/detail)", chat_id="oc_stream", metadata=_stream_metadata(sent_image_urls=[]), ) assert success api.patch_message.assert_not_called() api.upload_image.assert_not_called() assert api.update_card_element_content.call_args.kwargs["content"] == "第二帧 [详情](https://example.com/detail)" def test_edit_streaming_card_hides_incomplete_markdown_image(client, api): """ 尚未生成完整的 Markdown 图片链接不在卡片正文展示。 """ api.update_card_element_content.return_value = _ok() success = client.edit_message( message_id="om_stream", text="第二帧 ![poster](https://example.com/poster", chat_id="oc_stream", metadata=_stream_metadata(sent_image_urls=[]), ) assert success api.patch_message.assert_not_called() api.upload_image.assert_not_called() assert api.update_card_element_content.call_args.kwargs["content"] == "第二帧" def test_edit_streaming_card_hides_incomplete_markdown_image_alt_text(client, api): """ 尚未生成完整的图片替代文本同样暂不展示。 """ api.update_card_element_content.return_value = _ok() success = client.edit_message( message_id="om_stream", text="第二帧 ![poster", chat_id="oc_stream", metadata=_stream_metadata(sent_image_urls=[]), ) assert success api.patch_message.assert_not_called() api.upload_image.assert_not_called() assert api.update_card_element_content.call_args.kwargs["content"] == "第二帧" def test_edit_streaming_card_sends_completed_markdown_image_once(client, api): """ 分块输出补齐的图片只发送一次,后续更新不重复上传。 """ api.update_card_element_content.return_value = _ok() api.create_message.return_value = _message_ok(message_id="om_img", chat_id="oc_stream") api.upload_image.return_value = _ok(image_key="img_v2_stream_edit") metadata = _stream_metadata(sent_image_urls=[]) with patch("app.modules.feishu.feishu.RequestUtils") as request_utils: request_utils.return_value.get_res.return_value = _image_response(b"jpg-bytes", "image/jpeg") first_success = client.edit_message( message_id="om_stream", text="第二帧 ![poster](https://example.com/poster.jpg)", chat_id="oc_stream", metadata=metadata, ) second_success = client.edit_message( message_id="om_stream", text="第二帧 ![poster](https://example.com/poster.jpg)", chat_id="oc_stream", metadata=metadata, ) assert first_success assert second_success assert api.upload_image.call_count == 1 assert metadata["feishu_streaming"]["sent_image_urls"] == ["https://example.com/poster.jpg"] api.create_message.assert_called_once() call = api.create_message.call_args assert call.kwargs["receive_id"] == "oc_stream" assert call.kwargs["receive_id_type"] == "chat_id" assert _sent_content(call)["body"]["elements"][0]["img_key"] == "img_v2_stream_edit" def test_edit_streaming_card_skips_non_image_markdown_target(client, api): """ Markdown 图片目标并非有效图片时不记录为已发送图片。 """ api.update_card_element_content.return_value = _ok() metadata = _stream_metadata(sent_image_urls=[]) with patch("app.modules.feishu.feishu.RequestUtils") as request_utils: request_utils.return_value.get_res.return_value = _image_response(b"", "text/html") success = client.edit_message( message_id="om_stream", text="第二帧 ![link](https://example.com/detail)", chat_id="oc_stream", metadata=metadata, ) assert success api.upload_image.assert_not_called() assert metadata["feishu_streaming"]["sent_image_urls"] == [] api.create_message.assert_not_called() def test_close_streaming_card_updates_card_settings(client, api): """ 关闭流式卡片时更新设置以退出流式模式。 """ api.update_card_settings.return_value = _ok() success = client.close_streaming_card(card_id="card_stream", sequence=3) assert success kwargs = api.update_card_settings.call_args.kwargs assert kwargs["card_id"] == "card_stream" assert kwargs["sequence"] == 3 assert json.loads(kwargs["settings"]) == {"config": {"streaming_mode": False}} def test_close_streaming_card_reports_failure(client, api): """ 流式卡片关闭失败时向调用方返回 False。 """ api.update_card_settings.return_value = FeishuResponse(code=300309, msg="sequence") assert client.close_streaming_card(card_id="card_stream", sequence=3) is False # ---------------------------------------------------------------- 文件、语音与下载 def test_feishu_channel_capabilities_enable_images_and_files(): """ 飞书渠道声明支持图片与文件发送能力。 """ assert ChannelCapabilityManager.supports_capability( NotificationChannel.Feishu, ChannelCapability.IMAGES, ) assert ChannelCapabilityManager.supports_capability( NotificationChannel.Feishu, ChannelCapability.FILE_SENDING, ) def test_send_file_uploads_image_then_sends_mixed_card(client, api): """ 图片文件上传后以包含图片及说明的卡片发送。 """ api.upload_image.return_value = _ok(image_key="img_v2_uploaded") api.create_message.return_value = _message_ok(message_id="om_image") with tempfile.NamedTemporaryFile(suffix=".png") as fp: fp.write(b"png-bytes") fp.flush() result = client.send_file( file_path=fp.name, userid="ou_user_7", title="图片标题", text="图片说明", ) assert result["success"] api.upload_image.assert_called_once_with(Path(fp.name)) api.upload_file.assert_not_called() call = api.create_message.call_args assert call.kwargs["msg_type"] == "interactive" content = _sent_content(call) assert content["body"]["padding"] == "0px 0px 0px 0px" assert content["body"]["elements"][0]["img_key"] == "img_v2_uploaded" assert content["body"]["elements"][1]["content"] == "图片标题" assert content["body"]["elements"][1]["margin"] == "12px 12px 0px 12px" assert content["body"]["elements"][2]["content"] == "图片说明" assert content["body"]["elements"][2]["margin"] == "4px 12px 12px 12px" def test_send_file_keeps_non_image_file_message_and_caption(client, api): """ 非图片文件按文件消息发送,并保留标题和说明。 """ api.upload_file.return_value = _ok(file_key="file_doc") api.create_message.return_value = _message_ok(message_id="om_file") with ( tempfile.NamedTemporaryFile(suffix=".txt") as fp, patch.object(client, "send_text", return_value={"success": True}) as send_text, ): fp.write(b"text-bytes") fp.flush() result = client.send_file( file_path=fp.name, userid="ou_user_7", title="文件标题", text="文件说明", ) assert result["success"] api.upload_file.assert_called_once_with( Path(fp.name), file_type="stream", file_name=Path(fp.name).name, duration=None, ) call = api.create_message.call_args assert call.kwargs["msg_type"] == "file" assert _sent_content(call) == {"file_key": "file_doc"} send_text.assert_called_once() assert "文件标题" in send_text.call_args.args[0] assert "文件说明" in send_text.call_args.args[0] def test_send_file_fails_without_sending_when_upload_rejected(client, api): """ 文件上传被拒绝时返回失败,不再发送消息。 """ api.upload_file.return_value = FeishuResponse(code=234001, msg="too large") with tempfile.NamedTemporaryFile(suffix=".pdf") as fp: fp.write(b"pdf-bytes") fp.flush() result = client.send_file(file_path=fp.name, userid="ou_user_7") assert result == {"success": False} assert api.upload_file.call_args.kwargs["file_type"] == "pdf" api.create_message.assert_not_called() def test_send_voice_uploads_audio_file_and_optionally_sends_caption(client, api): """ 语音文件上传后按音频消息发送,并按需发送说明。 """ api.upload_file.return_value = _ok(file_key="file_audio") api.create_message.return_value = _message_ok(message_id="om_audio") with tempfile.NamedTemporaryFile(suffix=".opus") as fp: fp.write(b"opus-bytes") fp.flush() with patch.object(client, "send_text", return_value={"success": True}) as send_text: result = client.send_voice( voice_path=fp.name, userid="ou_user_8", caption="这是说明", ) assert result["success"] assert api.upload_file.call_args.kwargs["file_type"] == "opus" call = api.create_message.call_args assert call.kwargs["msg_type"] == "audio" assert _sent_content(call) == {"file_key": "file_audio"} send_text.assert_called_once() assert send_text.call_args.args[0] == "这是说明" def test_download_helpers_return_bytes_and_metadata(client, api): """ 图片、文件与消息资源下载返回字节内容、名称和媒体类型。 """ api.download_image.return_value = (b"image-bytes", "poster.png", "image/png") api.download_file.return_value = (b"file-bytes", "report.txt", "text/plain") api.download_message_resource.return_value = (b"resource-bytes", "voice.opus", "audio/ogg") image_download = client.download_image_bytes("img_v2_test") file_download = client.download_file_bytes("file_test") resource_download = client.download_message_resource_bytes("om_test", "file_test", "audio") assert image_download == (b"image-bytes", "poster.png", "image/png") assert file_download == (b"file-bytes", "report.txt", "text/plain") assert resource_download == (b"resource-bytes", "voice.opus", "audio/ogg") api.download_image.assert_called_once_with("img_v2_test") api.download_file.assert_called_once_with("file_test") api.download_message_resource.assert_called_once_with("om_test", "file_test", "audio") def test_download_helpers_skip_api_for_missing_keys(client, api): """ 资源标识缺失时直接返回空结果,不调用飞书接口。 """ assert client.download_image_bytes("") is None assert client.download_file_bytes("") is None assert client.download_message_resource_bytes("", "file_test", "audio") is None api.download_image.assert_not_called() api.download_file.assert_not_called() api.download_message_resource.assert_not_called() # ---------------------------------------------------------------- 广播与目标解析 def test_send_notification_broadcasts_to_remembered_chats_without_target(client, api): """ 无显式目标且未配置默认目标时,应回退向最近互动过的会话发送。 """ api.create_message.return_value = _message_ok() 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(Message(title="插件通知", text="无目标通知")) assert result["success"] assert api.create_message.call_count == 2 calls = api.create_message.call_args_list assert sorted(call.kwargs["receive_id"] for call in calls) == ["oc_group_a", "oc_p2p_c"] assert all(call.kwargs["receive_id_type"] == "chat_id" for call in calls) # 每次发送携带独立幂等 uuid,避免飞书把不同会话的消息判为重复请求。 assert len({call.kwargs["uuid"] for call in calls}) == 2 def test_send_without_target_raises_config_hint_when_nothing_available(client, api): """ 既无目标又无历史互动时,应报出含配置指引的明确错误。 """ api.create_message.return_value = _message_ok() with pytest.raises(ValueError) as exc_info: client._send_with_fallback_broadcast("text", {"text": "hello"}) assert "FEISHU_OPEN_ID" in str(exc_info.value) assert "FEISHU_CHAT_ID" in str(exc_info.value) api.create_message.assert_not_called() # ---------------------------------------------------------------- FeishuModule def test_module_send_direct_message_prefers_open_id_target(): """ 模块直接发送消息时优先使用 open_id 目标。 """ module = FeishuModule() module._channel = NotificationChannel.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( Message( 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", original_message_id=None, ) assert response.success assert response.message_id == "om_789" assert response.chat_id == "oc_789" def test_module_plain_direct_message_uses_literal_text_transport(): """ 纯文本直发不得进入会解释密钥字符的 Markdown 卡片路径。 """ module = FeishuModule() module._channel = NotificationChannel.Feishu conf = SimpleNamespace(name="feishu-main") client = MagicMock() client.send_text.return_value = { "success": True, "message_id": "om_plain", "chat_id": "oc_plain", } literal_text = "G2A1_PROTECTED_MARKER_20260812\n**literal markdown**\n" 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( Message( channel=NotificationChannel.Feishu, source="feishu-main", userid="ou_target", text=literal_text, private_delivery=True, parse_mode="plain", ) ) client.send_text.assert_called_once_with( text=literal_text, userid="ou_target", chat_id=None, receive_id_type=None, original_message_id=None, ) client.send_notification.assert_not_called() assert response.success def test_module_download_helpers_delegate_to_client(): """ 模块下载入口委托飞书客户端,并转换为所需字节或数据 URL。 """ module = FeishuModule() client = MagicMock() client.download_image_bytes.return_value = (b"image", "poster.png", "image/png") client.download_file_bytes.return_value = (b"file", "note.txt", "text/plain") client.download_message_resource_bytes.return_value = (b"image", "poster.png", "image/png") with ( patch.object(module, "get_config", return_value=SimpleNamespace(name="feishu-main")), patch.object(module, "get_instance", return_value=client), ): data_url = module.download_feishu_image_to_data_url("feishu://image/om_msg/img_v2_xxx", "feishu-main") file_bytes = module.download_feishu_file_bytes("feishu://file/file_xxx/note.txt", "feishu-main") audio_bytes = module.download_feishu_file_bytes( "feishu://file/om_audio/file_audio/voice.opus", "feishu-main", ) assert data_url.startswith("data:image/png;base64,") assert file_bytes == b"file" assert audio_bytes == b"image" client.download_message_resource_bytes.assert_any_call( message_id="om_msg", file_key="img_v2_xxx", resource_type="image", ) client.download_message_resource_bytes.assert_any_call( message_id="om_audio", file_key="file_audio", resource_type="audio", ) def test_module_message_reaction_helpers_delegate_to_client(): """ 模块消息表情入口委托对应客户端操作。 """ module = FeishuModule() client = MagicMock() client.add_message_reaction.return_value = "reaction_2" client.delete_message_reaction.return_value = True with ( patch.object(module, "get_config", return_value=SimpleNamespace(name="feishu-main")), patch.object(module, "get_instance", return_value=client), ): reaction_id = module.add_feishu_message_reaction("om_x", "GLANCE", "feishu-main") deleted = module.delete_feishu_message_reaction("om_x", "reaction_2", "feishu-main") assert reaction_id == "reaction_2" assert deleted def test_module_processing_status_uses_reaction_helpers(): """ 处理中状态通过表情入口创建和移除,并保存表情标识。 """ module = FeishuModule() module._channel = NotificationChannel.Feishu with ( patch.object(module, "add_feishu_message_reaction", return_value="reaction_processing") as add_reaction, patch.object(module, "delete_feishu_message_reaction", return_value=True) as delete_reaction, ): status = module.mark_message_processing_started( channel=NotificationChannel.Feishu, source="feishu-main", userid="ou_x", message_id="om_x", chat_id="oc_x", text="hello", ) deleted = module.mark_message_processing_finished( channel=NotificationChannel.Feishu, source="feishu-main", userid="ou_x", status=status, ) add_reaction.assert_called_once_with( message_id="om_x", emoji_type="GLANCE", source="feishu-main", ) delete_reaction.assert_called_once_with( message_id="om_x", reaction_id="reaction_processing", source="feishu-main", ) assert status["metadata"]["reaction_id"] == "reaction_processing" assert deleted def test_module_finalize_message_closes_streaming_card(): """ 消息完成入口使用元数据关闭对应流式卡片。 """ module = FeishuModule() module._channel = NotificationChannel.Feishu client = MagicMock() client.close_streaming_card.return_value = True with ( patch.object(module, "get_config", return_value=SimpleNamespace(name="feishu-main")), patch.object(module, "get_instance", return_value=client), ): success = module.finalize_message( MessageResponse( message_id="om_stream", chat_id="oc_stream", channel=NotificationChannel.Feishu, source="feishu-main", metadata={"feishu_streaming": {"card_id": "card_stream", "sequence": 2}}, success=True, ) ) assert success client.close_streaming_card.assert_called_once_with(card_id="card_stream", sequence=3) def test_module_post_message_prefers_file_and_voice_paths(): """ 模块发送入口优先按文件或语音载荷选择专用发送路径。 """ 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( Message( file_path="/tmp/demo.txt", text="说明", title="标题", userid="ou_user", ) ) module.post_message( Message( voice_path="/tmp/demo.opus", voice_caption="语音说明", userid="ou_user", ) ) client.send_file.assert_called_once() client.send_voice.assert_called_once() def test_module_post_message_sends_image_card_before_file_attachment(): """ 通知含图片和附件时先发送图片卡片,再发送不重复说明的附件。 """ 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( Message( file_path="/tmp/demo.txt", file_name="demo.txt", image="https://example.com/poster.png", text="说明", title="标题", userid="ou_user", ) ) client.send_notification.assert_called_once() sent_message = client.send_notification.call_args.kwargs["message"] assert sent_message.image == "https://example.com/poster.png" assert sent_message.file_path is None client.send_file.assert_called_once() assert client.send_file.call_args.kwargs.get("title") is None assert client.send_file.call_args.kwargs.get("text") is None def test_module_send_direct_message_sends_image_card_before_file_attachment(): """ 直接消息含图片和附件时先发送卡片,并返回卡片消息标识。 """ module = FeishuModule() module._channel = NotificationChannel.Feishu conf = SimpleNamespace(name="feishu-main") client = MagicMock() client.send_notification.return_value = { "success": True, "message_id": "om_card", "chat_id": "oc_card", } client.send_file.return_value = {"success": True, "message_id": "om_file"} 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( Message( channel=NotificationChannel.Feishu, source="feishu-main", file_path="/tmp/demo.txt", file_name="demo.txt", image="https://example.com/poster.png", text="说明", title="标题", userid="ou_user", ) ) assert response.success assert response.message_id == "om_card" client.send_notification.assert_called_once() client.send_file.assert_called_once() def test_module_post_message_passes_original_message_id_for_reply(): """ 模块将原消息标识传给客户端,保留回复语义。 """ 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( Message( title="标题", text="正文", userid="ou_user", original_message_id="om_source", original_chat_id="oc_source", ) ) client.send_notification.assert_called_once() assert client.send_notification.call_args.kwargs["original_message_id"] == "om_source" def test_module_resolve_message_target_prefers_original_chat_id(): """ 群聊@回复必须优先发回原会话,而不是按 open_id 发到私聊。 """ userid, chat_id, receive_id_type = FeishuModule._resolve_message_target( Message(userid="ou_user", original_chat_id="oc_group") ) assert userid is None assert chat_id == "oc_group" assert receive_id_type == "chat_id" # 非回复类消息仍按原有逻辑优先 open_id。 userid, chat_id, receive_id_type = FeishuModule._resolve_message_target(Message(userid="ou_user")) assert userid == "ou_user" assert chat_id is None assert receive_id_type == "open_id" # 无用户ID时回退 targets 中的飞书字段。 userid, chat_id, receive_id_type = FeishuModule._resolve_message_target( Message(targets={"feishu_chat_id": "oc_config"}) ) assert userid is None assert chat_id == "oc_config" def test_module_private_delivery_ignores_original_group_chat(): """ 私聊投递只保留用户身份,并让客户端按已记录 ID 类型发送。 """ userid, chat_id, receive_id_type = FeishuModule._resolve_message_target( Message( userid="user_target", original_chat_id="oc_group", private_delivery=True, ) ) assert userid == "user_target" assert chat_id is None assert receive_id_type is None def test_module_post_message_replies_to_original_chat_for_group_message(): """ 携带原会话上下文的回复应定向到原会话(群聊)。 """ 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( Message( 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, )