Files
MoviePilot/tests/test_feishu.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

1938 lines
70 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
验证飞书渠道的消息解析、长连接、卡片流式更新和媒体发送。
"""
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"<html></html>", "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<img src=x>"
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,
)