Files
archived-MoviePilot/tests/test_feishu.py
2026-05-23 12:18:59 +08:00

1484 lines
57 KiB
Python

import sys
import asyncio
import json
import tempfile
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"))
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.message import (
ChannelCapability,
ChannelCapabilityManager,
MessageResponse,
)
from app.schemas.types import MessageChannel, NotificationType
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,
msg_type="interactive",
)
return response
@staticmethod
def _reaction_success_response(reaction_id="reaction_test"):
response = MagicMock()
response.success.return_value = True
response.data = SimpleNamespace(reaction_id=reaction_id)
return response
@staticmethod
def _card_create_success_response(card_id="card_test"):
response = MagicMock()
response.success.return_value = True
response.data = SimpleNamespace(card_id=card_id)
return response
@staticmethod
def _build_message_api(
create_response=None,
patch_response=None,
reply_response=None,
reaction_create_response=None,
reaction_delete_response=None,
card_create_response=None,
card_settings_response=None,
card_content_response=None,
image_create_response=None,
file_create_response=None,
image_get_response=None,
file_get_response=None,
message_resource_response=None,
):
message_api = SimpleNamespace(
create=MagicMock(return_value=create_response),
patch=MagicMock(return_value=patch_response),
reply=MagicMock(return_value=reply_response),
update=MagicMock(),
)
message_reaction_api = SimpleNamespace(
create=MagicMock(return_value=reaction_create_response),
delete=MagicMock(return_value=reaction_delete_response),
)
image_api = SimpleNamespace(
create=MagicMock(return_value=image_create_response),
get=MagicMock(return_value=image_get_response),
)
file_api = SimpleNamespace(
create=MagicMock(return_value=file_create_response),
get=MagicMock(return_value=file_get_response),
)
message_resource_api = SimpleNamespace(
get=MagicMock(return_value=message_resource_response),
)
api_client = SimpleNamespace(
im=SimpleNamespace(
v1=SimpleNamespace(
message=message_api,
message_reaction=message_reaction_api,
image=image_api,
file=file_api,
message_resource=message_resource_api,
)
),
cardkit=SimpleNamespace(
v1=SimpleNamespace(
card=SimpleNamespace(
create=MagicMock(return_value=card_create_response),
settings=MagicMock(return_value=card_settings_response),
),
card_element=SimpleNamespace(
content=MagicMock(return_value=card_content_response),
),
)
),
)
return api_client, message_api
@staticmethod
def _resource_response(
content: bytes,
file_name: str = "resource.bin",
content_type: str = "application/octet-stream",
):
response = MagicMock()
response.code = 0
response.file = MagicMock()
response.file.read.return_value = content
response.file_name = file_name
response.raw = SimpleNamespace(headers={"Content-Type": content_type})
return response
def test_parse_message_returns_callback_message(self):
client = self._build_client()
with patch("app.modules.feishu.feishu.UserOper.get_name", return_value=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",
},
}
)
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_extract_card_callback_data_supports_new_and_legacy_values(self):
self.assertEqual(
Feishu._extract_card_callback_data({"callback_data": "approve"}),
"approve",
)
self.assertEqual(
Feishu._extract_card_callback_data({"value": "legacy"}),
"legacy",
)
self.assertEqual(
Feishu._extract_card_callback_data("direct"),
"direct",
)
self.assertEqual(
Feishu._extract_card_callback_data({}, name="fallback"),
"fallback",
)
def test_build_event_handler_registers_common_im_events(self):
registered = []
class _Builder:
def __getattr__(self, name):
if name.startswith("register_"):
def _register(handler):
registered.append(name)
return self
return _register
raise AttributeError(name)
def build(self):
return "handler"
client = self._build_client()
fake_builder = _Builder()
with patch(
"app.modules.feishu.feishu.lark.EventDispatcherHandler.builder",
return_value=fake_builder,
):
handler = client._build_event_handler()
self.assertEqual(handler, "handler")
self.assertIn("register_p2_im_message_receive_v1", registered)
self.assertIn("register_p2_im_message_message_read_v1", registered)
self.assertIn("register_p2_im_message_reaction_created_v1", registered)
self.assertIn("register_p2_im_message_reaction_deleted_v1", registered)
self.assertIn("register_p2_im_message_recalled_v1", registered)
self.assertIn(
"register_p2_im_chat_access_event_bot_p2p_chat_entered_v1", registered
)
self.assertIn("register_p2_card_action_trigger", registered)
def test_parse_message_blocks_non_admin_command(self):
client = self._build_client(FEISHU_ADMINS="ou_admin")
with (
patch("app.modules.feishu.feishu.UserOper.get_name", return_value=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",
},
}
)
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_parse_message_maps_feishu_ids_to_moviepilot_username(self):
client = self._build_client()
with patch(
"app.modules.feishu.feishu.UserOper.get_name",
return_value="moviepilot-user",
) as 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",
},
}
)
self.assertIsNotNone(result)
self.assertEqual(result.userid, "ou_bound_user")
self.assertEqual(result.username, "moviepilot-user")
get_name.assert_called_once_with(
feishu_openid="ou_bound_user",
feishu_userid="u_bound_user",
)
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["schema"], "2.0")
self.assertTrue(content["config"]["update_multi"])
self.assertEqual(content["body"]["padding"], "12px 12px 12px 12px")
self.assertEqual(content["body"]["elements"][0]["text_size"], "heading")
self.assertEqual(content["body"]["elements"][0]["tag"], "markdown")
button = content["body"]["elements"][-1]["columns"][0]["elements"][0]
self.assertEqual(button["tag"], "button")
self.assertNotIn("value", button)
self.assertEqual(
button["behaviors"],
[{"type": "callback", "value": {"callback_data": "confirm"}}],
)
def test_send_notification_keeps_markdown_images_for_normal_card(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="海报:![poster](https://example.com/poster.jpg)",
),
userid="ou_user_img_md",
)
self.assertTrue(result["success"])
request = message_api.create.call_args.args[0]
content = json.loads(request.request_body.content)
self.assertEqual(
content["body"]["elements"][1]["content"],
"海报:![poster](https://example.com/poster.jpg)",
)
def test_send_notification_embeds_remote_image_in_card(self):
client = self._build_client()
image_upload_response = MagicMock()
image_upload_response.success.return_value = True
image_upload_response.data = SimpleNamespace(image_key="img_v2_remote")
client._api_client, message_api = self._build_message_api(
create_response=self._success_response(),
image_create_response=image_upload_response,
)
response = MagicMock()
response.content = b"png-bytes"
response.headers = {"Content-Type": "image/png"}
with patch("app.modules.feishu.feishu.RequestUtils") as request_utils:
request_utils.return_value.get_res.return_value = response
result = client.send_notification(
Notification(
title="测试标题",
text="测试正文",
image="https://example.com/poster.png",
buttons=[[{"text": "确认", "callback_data": "confirm"}]],
),
userid="ou_user_img",
)
self.assertTrue(result["success"])
response.close.assert_called_once()
client._api_client.im.v1.image.create.assert_called_once()
request = message_api.create.call_args.args[0]
self.assertEqual(request.request_body.msg_type, "interactive")
content = json.loads(request.request_body.content)
self.assertEqual(content["body"]["padding"], "0px 0px 0px 0px")
image_element = content["body"]["elements"][0]
self.assertEqual(image_element["tag"], "img")
self.assertEqual(image_element["img_key"], "img_v2_remote")
self.assertEqual(content["body"]["elements"][1]["margin"], "12px 12px 0px 12px")
self.assertEqual(content["body"]["elements"][2]["margin"], "4px 12px 12px 12px")
self.assertEqual(
content["body"]["elements"][-1]["margin"], "0px 12px 12px 12px"
)
self.assertEqual(content["body"]["elements"][-1]["tag"], "column_set")
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["schema"], "2.0")
self.assertTrue(content["config"]["update_multi"])
self.assertEqual(content["body"]["elements"][0]["tag"], "markdown")
button = content["body"]["elements"][-1]["columns"][0]["elements"][0]
self.assertEqual(
button["behaviors"],
[{"type": "callback", "value": {"callback_data": "confirm"}}],
)
def test_send_notification_replies_when_original_message_id_is_present(self):
client = self._build_client()
client._api_client, message_api = self._build_message_api(
reply_response=self._success_response(message_id="om_reply")
)
result = client.send_notification(
Notification(title="回复标题", text="回复正文"),
userid="ou_user_9",
original_message_id="om_origin",
)
self.assertTrue(result["success"])
message_api.reply.assert_called_once()
request = message_api.reply.call_args.args[0]
self.assertEqual(request.message_id, "om_origin")
self.assertEqual(request.request_body.msg_type, "interactive")
def test_message_reaction_create_and_delete_use_official_api(self):
client = self._build_client()
client._api_client, _ = self._build_message_api(
reaction_create_response=self._reaction_success_response("reaction_1"),
reaction_delete_response=self._success_response(),
)
reaction_id = client.add_message_reaction(
"om_origin", Feishu.PROCESSING_REACTION_EMOJI
)
deleted = client.delete_message_reaction("om_origin", "reaction_1")
self.assertEqual(reaction_id, "reaction_1")
self.assertTrue(deleted)
create_request = (
client._api_client.im.v1.message_reaction.create.call_args.args[0]
)
self.assertEqual(create_request.message_id, "om_origin")
self.assertEqual(
create_request.request_body.reaction_type.emoji_type,
Feishu.PROCESSING_REACTION_EMOJI,
)
delete_request = (
client._api_client.im.v1.message_reaction.delete.call_args.args[0]
)
self.assertEqual(delete_request.message_id, "om_origin")
self.assertEqual(delete_request.reaction_id, "reaction_1")
def test_send_notification_uses_streaming_card_for_agent_text(self):
client = self._build_client()
client._api_client, message_api = self._build_message_api(
create_response=self._success_response(
message_id="om_stream", chat_id="oc_stream"
),
card_create_response=self._card_create_success_response("card_stream"),
)
result = client.send_notification(
Notification(
mtype=NotificationType.Agent,
title="MoviePilot助手",
text="第一帧内容",
),
userid="ou_user_stream",
)
self.assertTrue(result["success"])
self.assertEqual(
result["metadata"]["feishu_streaming"]["card_id"], "card_stream"
)
self.assertEqual(result["metadata"]["feishu_streaming"]["sequence"], 0)
self.assertEqual(result["metadata"]["feishu_streaming"]["sent_image_urls"], [])
card_request = client._api_client.cardkit.v1.card.create.call_args.args[0]
self.assertEqual(card_request.request_body.type, "card_json")
card_payload = json.loads(card_request.request_body.data)
self.assertTrue(card_payload["config"]["streaming_mode"])
self.assertEqual(
card_payload["body"]["elements"][-1]["element_id"],
Feishu.STREAM_CARD_BODY_ELEMENT_ID,
)
message_request = message_api.create.call_args.args[0]
self.assertEqual(message_request.request_body.msg_type, "interactive")
self.assertEqual(
json.loads(message_request.request_body.content)["data"]["card_id"],
"card_stream",
)
def test_streaming_card_sends_markdown_images_separately(self):
client = self._build_client()
image_upload_response = MagicMock()
image_upload_response.success.return_value = True
image_upload_response.data = SimpleNamespace(image_key="img_v2_stream")
client._api_client, message_api = self._build_message_api(
create_response=self._success_response(
message_id="om_stream", chat_id="oc_stream"
),
card_create_response=self._card_create_success_response("card_stream"),
image_create_response=image_upload_response,
)
response = MagicMock()
response.content = b"png-bytes"
response.headers = {"Content-Type": "image/jpeg"}
with patch("app.modules.feishu.feishu.RequestUtils") as request_utils:
request_utils.return_value.get_res.return_value = response
result = client.send_notification(
Notification(
mtype=NotificationType.Agent,
title="MoviePilot助手",
text="找到海报 ![poster](https://example.com/poster.jpg)\n[详情](https://example.com/detail)",
),
userid="ou_user_stream",
)
self.assertTrue(result["success"])
card_request = client._api_client.cardkit.v1.card.create.call_args.args[0]
card_payload = json.loads(card_request.request_body.data)
body_content = card_payload["body"]["elements"][-1]["content"]
self.assertNotIn("![poster]", body_content)
self.assertNotIn("poster.jpg", body_content)
self.assertIn("poster", body_content)
self.assertIn("[详情](https://example.com/detail)", body_content)
self.assertEqual(client._api_client.im.v1.image.create.call_count, 1)
self.assertEqual(message_api.create.call_count, 2)
self.assertEqual(
result["metadata"]["feishu_streaming"]["sent_image_urls"],
["https://example.com/poster.jpg"],
)
image_request = message_api.create.call_args_list[-1].args[0]
image_payload = json.loads(image_request.request_body.content)
self.assertEqual(image_payload["body"]["elements"][0]["img_key"], "img_v2_stream")
def test_streaming_card_sends_notification_image_separately(self):
client = self._build_client()
image_upload_response = MagicMock()
image_upload_response.success.return_value = True
image_upload_response.data = SimpleNamespace(image_key="img_v2_agent_image")
client._api_client, message_api = self._build_message_api(
create_response=self._success_response(
message_id="om_stream", chat_id="oc_stream"
),
card_create_response=self._card_create_success_response("card_stream"),
image_create_response=image_upload_response,
)
response = MagicMock()
response.content = b"png-bytes"
response.headers = {"Content-Type": "image/png"}
with patch("app.modules.feishu.feishu.RequestUtils") as request_utils:
request_utils.return_value.get_res.return_value = response
result = client.send_notification(
Notification(
mtype=NotificationType.Agent,
title="MoviePilot助手",
text="第一帧内容",
image="https://example.com/agent.png",
),
userid="ou_user_stream",
)
self.assertTrue(result["success"])
self.assertEqual(client._api_client.im.v1.image.create.call_count, 1)
self.assertEqual(message_api.create.call_count, 2)
self.assertEqual(
result["metadata"]["feishu_streaming"]["sent_image_urls"],
["https://example.com/agent.png"],
)
stream_request = message_api.create.call_args_list[0].args[0]
image_request = message_api.create.call_args_list[1].args[0]
self.assertEqual(
json.loads(stream_request.request_body.content)["data"]["card_id"],
"card_stream",
)
image_payload = json.loads(image_request.request_body.content)
self.assertEqual(
image_payload["body"]["elements"][0]["img_key"],
"img_v2_agent_image",
)
def test_send_notification_replies_with_streaming_card_for_agent_text(self):
client = self._build_client()
client._api_client, message_api = self._build_message_api(
reply_response=self._success_response(
message_id="om_reply", chat_id="oc_stream"
),
card_create_response=self._card_create_success_response("card_stream"),
)
result = client.send_notification(
Notification(
mtype=NotificationType.Agent,
title="MoviePilot助手",
text="第一帧内容",
),
userid="ou_user_stream",
original_message_id="om_origin",
)
self.assertTrue(result["success"])
message_api.create.assert_not_called()
reply_request = message_api.reply.call_args.args[0]
self.assertEqual(reply_request.message_id, "om_origin")
self.assertEqual(reply_request.request_body.msg_type, "interactive")
self.assertEqual(
json.loads(reply_request.request_body.content)["data"]["card_id"],
"card_stream",
)
self.assertEqual(result["metadata"]["feishu_streaming"]["sequence"], 0)
def test_edit_replied_streaming_card_uses_first_increment_sequence(self):
client = self._build_client()
client._api_client, message_api = self._build_message_api(
patch_response=self._success_response(),
card_content_response=self._success_response(),
)
success = client.edit_message(
message_id="om_reply",
text="补充内容",
metadata={
"feishu_streaming": {
"card_id": "card_stream",
"element_id": Feishu.STREAM_CARD_BODY_ELEMENT_ID,
"sequence": 0,
"sent_image_urls": ["https://example.com/poster.jpg"],
}
},
)
self.assertTrue(success)
message_api.patch.assert_not_called()
content_request = (
client._api_client.cardkit.v1.card_element.content.call_args.args[0]
)
self.assertEqual(content_request.request_body.sequence, 1)
def test_edit_message_uses_cardkit_content_for_streaming_card(self):
client = self._build_client()
client._api_client, message_api = self._build_message_api(
patch_response=self._success_response(),
card_content_response=self._success_response(),
)
success = client.edit_message(
message_id="om_stream",
text="第二帧内容",
metadata={
"feishu_streaming": {
"card_id": "card_stream",
"element_id": Feishu.STREAM_CARD_BODY_ELEMENT_ID,
"sequence": 0,
"sent_image_urls": ["https://example.com/poster.jpg"],
}
},
)
self.assertTrue(success)
client._api_client.cardkit.v1.card_element.content.assert_called_once()
message_api.patch.assert_not_called()
content_request = (
client._api_client.cardkit.v1.card_element.content.call_args.args[0]
)
self.assertEqual(content_request.card_id, "card_stream")
self.assertEqual(content_request.element_id, Feishu.STREAM_CARD_BODY_ELEMENT_ID)
self.assertEqual(content_request.request_body.sequence, 1)
def test_edit_streaming_card_removes_markdown_image_syntax(self):
client = self._build_client()
client._api_client, message_api = self._build_message_api(
patch_response=self._success_response(),
card_content_response=self._success_response(),
)
success = client.edit_message(
message_id="om_stream",
text="第二帧 ![poster](https://example.com/poster.jpg)",
metadata={
"feishu_streaming": {
"card_id": "card_stream",
"element_id": Feishu.STREAM_CARD_BODY_ELEMENT_ID,
"sequence": 0,
"sent_image_urls": ["https://example.com/poster.jpg"],
}
},
)
self.assertTrue(success)
message_api.patch.assert_not_called()
content_request = (
client._api_client.cardkit.v1.card_element.content.call_args.args[0]
)
self.assertEqual(content_request.request_body.content, "第二帧 poster")
def test_edit_streaming_card_keeps_normal_markdown_links(self):
client = self._build_client()
client._api_client, message_api = self._build_message_api(
patch_response=self._success_response(),
card_content_response=self._success_response(),
)
success = client.edit_message(
message_id="om_stream",
text="第二帧 [详情](https://example.com/detail)",
chat_id="oc_stream",
metadata={
"feishu_streaming": {
"card_id": "card_stream",
"element_id": Feishu.STREAM_CARD_BODY_ELEMENT_ID,
"sequence": 0,
"sent_image_urls": [],
}
},
)
self.assertTrue(success)
message_api.patch.assert_not_called()
client._api_client.im.v1.image.create.assert_not_called()
content_request = (
client._api_client.cardkit.v1.card_element.content.call_args.args[0]
)
self.assertEqual(
content_request.request_body.content,
"第二帧 [详情](https://example.com/detail)",
)
def test_edit_streaming_card_hides_incomplete_markdown_image(self):
client = self._build_client()
client._api_client, message_api = self._build_message_api(
patch_response=self._success_response(),
card_content_response=self._success_response(),
)
success = client.edit_message(
message_id="om_stream",
text="第二帧 ![poster](https://example.com/poster",
chat_id="oc_stream",
metadata={
"feishu_streaming": {
"card_id": "card_stream",
"element_id": Feishu.STREAM_CARD_BODY_ELEMENT_ID,
"sequence": 0,
"sent_image_urls": [],
}
},
)
self.assertTrue(success)
message_api.patch.assert_not_called()
client._api_client.im.v1.image.create.assert_not_called()
content_request = (
client._api_client.cardkit.v1.card_element.content.call_args.args[0]
)
self.assertEqual(content_request.request_body.content, "第二帧")
def test_edit_streaming_card_hides_incomplete_markdown_image_alt_text(self):
client = self._build_client()
client._api_client, message_api = self._build_message_api(
patch_response=self._success_response(),
card_content_response=self._success_response(),
)
success = client.edit_message(
message_id="om_stream",
text="第二帧 ![poster",
chat_id="oc_stream",
metadata={
"feishu_streaming": {
"card_id": "card_stream",
"element_id": Feishu.STREAM_CARD_BODY_ELEMENT_ID,
"sequence": 0,
"sent_image_urls": [],
}
},
)
self.assertTrue(success)
message_api.patch.assert_not_called()
client._api_client.im.v1.image.create.assert_not_called()
content_request = (
client._api_client.cardkit.v1.card_element.content.call_args.args[0]
)
self.assertEqual(content_request.request_body.content, "第二帧")
def test_edit_streaming_card_sends_completed_markdown_image_once(self):
client = self._build_client()
image_upload_response = MagicMock()
image_upload_response.success.return_value = True
image_upload_response.data = SimpleNamespace(image_key="img_v2_stream_edit")
client._api_client, message_api = self._build_message_api(
create_response=self._success_response(message_id="om_img", chat_id="oc_stream"),
patch_response=self._success_response(),
card_content_response=self._success_response(),
image_create_response=image_upload_response,
)
metadata = {
"feishu_streaming": {
"card_id": "card_stream",
"element_id": Feishu.STREAM_CARD_BODY_ELEMENT_ID,
"sequence": 0,
"sent_image_urls": [],
}
}
response = MagicMock()
response.content = b"jpg-bytes"
response.headers = {"Content-Type": "image/jpeg"}
with patch("app.modules.feishu.feishu.RequestUtils") as request_utils:
request_utils.return_value.get_res.return_value = response
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,
)
self.assertTrue(first_success)
self.assertTrue(second_success)
self.assertEqual(client._api_client.im.v1.image.create.call_count, 1)
self.assertEqual(
metadata["feishu_streaming"]["sent_image_urls"],
["https://example.com/poster.jpg"],
)
image_request = message_api.create.call_args.args[0]
image_payload = json.loads(image_request.request_body.content)
self.assertEqual(
image_payload["body"]["elements"][0]["img_key"],
"img_v2_stream_edit",
)
def test_edit_streaming_card_skips_non_image_markdown_target(self):
client = self._build_client()
client._api_client, message_api = self._build_message_api(
patch_response=self._success_response(),
card_content_response=self._success_response(),
)
metadata = {
"feishu_streaming": {
"card_id": "card_stream",
"element_id": Feishu.STREAM_CARD_BODY_ELEMENT_ID,
"sequence": 0,
"sent_image_urls": [],
}
}
response = MagicMock()
response.content = b"<html></html>"
response.headers = {"Content-Type": "text/html"}
with patch("app.modules.feishu.feishu.RequestUtils") as request_utils:
request_utils.return_value.get_res.return_value = response
success = client.edit_message(
message_id="om_stream",
text="第二帧 ![link](https://example.com/detail)",
chat_id="oc_stream",
metadata=metadata,
)
self.assertTrue(success)
client._api_client.im.v1.image.create.assert_not_called()
self.assertEqual(metadata["feishu_streaming"]["sent_image_urls"], [])
self.assertEqual(message_api.create.call_count, 0)
def test_close_streaming_card_updates_card_settings(self):
client = self._build_client()
client._api_client, _ = self._build_message_api(
card_settings_response=self._success_response(),
)
success = client.close_streaming_card(card_id="card_stream", sequence=3)
self.assertTrue(success)
settings_request = client._api_client.cardkit.v1.card.settings.call_args.args[0]
self.assertEqual(settings_request.card_id, "card_stream")
settings_payload = json.loads(settings_request.request_body.settings)
self.assertFalse(settings_payload["config"]["streaming_mode"])
def test_parse_message_supports_image_and_file_payloads(self):
client = self._build_client()
with patch("app.modules.feishu.feishu.UserOper.get_name", return_value=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",
},
}
)
self.assertEqual(image_message.images[0].ref, "feishu://image/img_v2_test")
self.assertEqual(file_message.files[0].ref, "feishu://file/file_key/report.pdf")
def test_on_message_wraps_feishu_image_ref_with_message_id(self):
client = self._build_client()
message = SimpleNamespace(
message_id="om_img_evt",
chat_id="oc_chat_evt",
chat_type="p2p",
message_type="image",
content=json.dumps({"image_key": "img_v2_evt"}),
)
sender = SimpleNamespace(
sender_id=SimpleNamespace(open_id="ou_user_evt", user_id=None)
)
event = SimpleNamespace(sender=sender, message=message)
with patch.object(client, "_forward_to_message_chain") as forward:
client._on_message(SimpleNamespace(event=event))
payload = forward.call_args.args[0]
self.assertEqual(
payload["images"][0]["ref"], "feishu://image/om_img_evt/img_v2_evt"
)
def test_on_message_wraps_feishu_audio_ref_with_message_id(self):
client = self._build_client()
message = SimpleNamespace(
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"}
),
)
sender = SimpleNamespace(
sender_id=SimpleNamespace(open_id="ou_user_evt", user_id=None)
)
event = SimpleNamespace(sender=sender, message=message)
with patch.object(client, "_forward_to_message_chain") as forward:
client._on_message(SimpleNamespace(event=event))
payload = forward.call_args.args[0]
self.assertEqual(
payload["audio_refs"],
["feishu://file/om_audio_evt/file_audio_evt/voice.opus"],
)
def test_feishu_channel_capabilities_enable_images_and_files(self):
self.assertTrue(
ChannelCapabilityManager.supports_capability(
MessageChannel.Feishu,
ChannelCapability.IMAGES,
)
)
self.assertTrue(
ChannelCapabilityManager.supports_capability(
MessageChannel.Feishu,
ChannelCapability.FILE_SENDING,
)
)
def test_send_file_uploads_image_then_sends_mixed_card(self):
client = self._build_client()
image_upload_response = MagicMock()
image_upload_response.success.return_value = True
image_upload_response.data = SimpleNamespace(image_key="img_v2_uploaded")
client._api_client, message_api = self._build_message_api(
create_response=self._success_response(message_id="om_image"),
image_create_response=image_upload_response,
)
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="图片说明",
)
self.assertTrue(result["success"])
client._api_client.im.v1.image.create.assert_called_once()
request = message_api.create.call_args.args[0]
self.assertEqual(request.request_body.msg_type, "interactive")
content = json.loads(request.request_body.content)
self.assertEqual(content["body"]["padding"], "0px 0px 0px 0px")
self.assertEqual(content["body"]["elements"][0]["img_key"], "img_v2_uploaded")
self.assertEqual(content["body"]["elements"][1]["content"], "图片标题")
self.assertEqual(content["body"]["elements"][1]["margin"], "12px 12px 0px 12px")
self.assertEqual(content["body"]["elements"][2]["content"], "图片说明")
self.assertEqual(content["body"]["elements"][2]["margin"], "4px 12px 12px 12px")
def test_send_file_keeps_non_image_file_message_and_caption(self):
client = self._build_client()
file_upload_response = MagicMock()
file_upload_response.success.return_value = True
file_upload_response.data = SimpleNamespace(file_key="file_doc")
client._api_client, message_api = self._build_message_api(
create_response=self._success_response(message_id="om_file"),
file_create_response=file_upload_response,
)
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="文件说明",
)
self.assertTrue(result["success"])
client._api_client.im.v1.file.create.assert_called_once()
request = message_api.create.call_args.args[0]
self.assertEqual(request.request_body.msg_type, "file")
self.assertEqual(
json.loads(request.request_body.content)["file_key"], "file_doc"
)
send_text.assert_called_once()
def test_send_voice_uploads_audio_file_and_optionally_sends_caption(self):
client = self._build_client()
file_upload_response = MagicMock()
file_upload_response.success.return_value = True
file_upload_response.data = SimpleNamespace(file_key="file_audio")
client._api_client, message_api = self._build_message_api(
create_response=self._success_response(message_id="om_audio"),
file_create_response=file_upload_response,
)
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="这是说明",
)
self.assertTrue(result["success"])
request = message_api.create.call_args.args[0]
self.assertEqual(request.request_body.msg_type, "audio")
self.assertEqual(
json.loads(request.request_body.content)["file_key"], "file_audio"
)
send_text.assert_called_once()
def test_download_helpers_return_bytes_and_data_url(self):
client = self._build_client()
client._api_client, _ = self._build_message_api(
image_get_response=self._resource_response(
b"image-bytes", file_name="poster.png", content_type="image/png"
),
file_get_response=self._resource_response(
b"file-bytes", file_name="report.txt", content_type="text/plain"
),
message_resource_response=self._resource_response(
b"resource-bytes", file_name="voice.opus", content_type="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"
)
self.assertEqual(image_download[0], b"image-bytes")
self.assertEqual(file_download[0], b"file-bytes")
self.assertEqual(resource_download[0], b"resource-bytes")
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",
original_message_id=None,
)
self.assertTrue(response.success)
self.assertEqual(response.message_id, "om_789")
self.assertEqual(response.chat_id, "oc_789")
def test_run_ws_client_binds_thread_local_event_loop(self):
client = self._build_client()
original_loop = object()
fake_ws_client = MagicMock()
created_loops = []
real_new_event_loop = asyncio.new_event_loop
def _new_loop():
loop = real_new_event_loop()
created_loops.append(loop)
return loop
with (
patch(
"app.modules.feishu.feishu.lark_ws_client_module.loop", original_loop
),
patch(
"app.modules.feishu.feishu.lark_ws_client_module._select",
new=MagicMock(return_value=None),
),
patch(
"app.modules.feishu.feishu.asyncio.new_event_loop",
side_effect=_new_loop,
),
patch(
"app.modules.feishu.feishu.lark.ws.Client", return_value=fake_ws_client
),
patch.object(
fake_ws_client, "start", side_effect=lambda: None
) as mock_start,
):
client._run_ws_client()
self.assertIsNone(client._ws_loop)
mock_start.assert_called_once()
self.assertEqual(len(created_loops), 1)
self.assertTrue(created_loops[0].is_closed())
def test_stop_disconnects_ws_client_via_threadsafe_loop(self):
client = self._build_client()
stop_loop = MagicMock()
stop_loop.is_running.return_value = True
client._ws_loop = stop_loop
client._ws_client = MagicMock()
client._ws_thread = MagicMock()
client._ws_thread.is_alive.return_value = False
future = MagicMock()
future.result.return_value = None
with patch(
"app.modules.feishu.feishu.asyncio.run_coroutine_threadsafe",
return_value=future,
) as runner:
client.stop()
runner.assert_called_once()
future.result.assert_called_once_with(timeout=5)
def test_module_download_helpers_delegate_to_client(self):
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",
)
self.assertTrue(data_url.startswith("data:image/png;base64,"))
self.assertEqual(file_bytes, b"file")
self.assertEqual(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(self):
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"
)
self.assertEqual(reaction_id, "reaction_2")
self.assertTrue(deleted)
def test_module_processing_status_uses_reaction_helpers(self):
module = FeishuModule()
module._channel = MessageChannel.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=MessageChannel.Feishu,
source="feishu-main",
userid="ou_x",
message_id="om_x",
chat_id="oc_x",
text="hello",
)
deleted = module.mark_message_processing_finished(
channel=MessageChannel.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",
)
self.assertEqual(status["metadata"]["reaction_id"], "reaction_processing")
self.assertTrue(deleted)
def test_module_finalize_message_closes_streaming_card(self):
module = FeishuModule()
module._channel = MessageChannel.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=MessageChannel.Feishu,
source="feishu-main",
metadata={
"feishu_streaming": {
"card_id": "card_stream",
"sequence": 2,
}
},
success=True,
)
)
self.assertTrue(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(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(
file_path="/tmp/demo.txt",
text="说明",
title="标题",
userid="ou_user",
)
)
module.post_message(
Notification(
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(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(
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"]
self.assertEqual(sent_message.image, "https://example.com/poster.png")
self.assertIsNone(sent_message.file_path)
client.send_file.assert_called_once()
self.assertIsNone(client.send_file.call_args.kwargs.get("title"))
self.assertIsNone(client.send_file.call_args.kwargs.get("text"))
def test_module_send_direct_message_sends_image_card_before_file_attachment(self):
module = FeishuModule()
module._channel = MessageChannel.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(
Notification(
channel=MessageChannel.Feishu,
source="feishu-main",
file_path="/tmp/demo.txt",
file_name="demo.txt",
image="https://example.com/poster.png",
text="说明",
title="标题",
userid="ou_user",
)
)
self.assertTrue(response.success)
self.assertEqual(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(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_message_id="om_source",
original_chat_id="oc_source",
)
)
client.send_notification.assert_called_once()
self.assertEqual(
client.send_notification.call_args.kwargs["original_message_id"],
"om_source",
)
if __name__ == "__main__":
unittest.main()