"""Tests for Feishu message reply (quote) feature.""" import json from pathlib import Path from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest # Check optional Feishu dependencies before running tests try: from nanobot.channels import feishu FEISHU_AVAILABLE = getattr(feishu, "FEISHU_AVAILABLE", False) except ImportError: FEISHU_AVAILABLE = False if not FEISHU_AVAILABLE: pytest.skip("Feishu dependencies not installed (lark-oapi)", allow_module_level=True) from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.feishu import FeishuChannel, FeishuConfig # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_feishu_channel(reply_to_message: bool = False) -> FeishuChannel: config = FeishuConfig( enabled=True, app_id="cli_test", app_secret="secret", allow_from=["*"], reply_to_message=reply_to_message, ) channel = FeishuChannel(config, MessageBus()) channel._client = MagicMock() # _loop is only used by the WebSocket thread bridge; not needed for unit tests channel._loop = None return channel def _make_feishu_event( *, message_id: str = "om_001", chat_id: str = "oc_abc", chat_type: str = "p2p", msg_type: str = "text", content: str = '{"text": "hello"}', sender_open_id: str = "ou_alice", parent_id: str | None = None, root_id: str | None = None, ): message = SimpleNamespace( message_id=message_id, chat_id=chat_id, chat_type=chat_type, message_type=msg_type, content=content, parent_id=parent_id, root_id=root_id, mentions=[], ) sender = SimpleNamespace( sender_type="user", sender_id=SimpleNamespace(open_id=sender_open_id), ) return SimpleNamespace(event=SimpleNamespace(message=message, sender=sender)) def _make_get_message_response(text: str, msg_type: str = "text", success: bool = True): """Build a fake im.v1.message.get response object.""" body = SimpleNamespace(content=json.dumps({"text": text})) item = SimpleNamespace(msg_type=msg_type, body=body) data = SimpleNamespace(items=[item]) resp = MagicMock() resp.success.return_value = success resp.data = data resp.code = 0 resp.msg = "ok" return resp # --------------------------------------------------------------------------- # Config tests # --------------------------------------------------------------------------- def test_feishu_config_reply_to_message_defaults_false() -> None: assert FeishuConfig().reply_to_message is False def test_feishu_config_reply_to_message_can_be_enabled() -> None: config = FeishuConfig(reply_to_message=True) assert config.reply_to_message is True # --------------------------------------------------------------------------- # _get_message_content_sync tests # --------------------------------------------------------------------------- def test_get_message_content_sync_returns_reply_prefix() -> None: channel = _make_feishu_channel() channel._client.im.v1.message.get.return_value = _make_get_message_response("what time is it?") result = channel._get_message_content_sync("om_parent") assert result == "[Reply to: what time is it?]" def test_get_message_content_sync_truncates_long_text() -> None: channel = _make_feishu_channel() long_text = "x" * (FeishuChannel._REPLY_CONTEXT_MAX_LEN + 50) channel._client.im.v1.message.get.return_value = _make_get_message_response(long_text) result = channel._get_message_content_sync("om_parent") assert result is not None assert result.endswith("...]") inner = result[len("[Reply to: ") : -1] assert len(inner) == FeishuChannel._REPLY_CONTEXT_MAX_LEN + len("...") def test_get_message_content_sync_returns_none_on_api_failure() -> None: channel = _make_feishu_channel() resp = MagicMock() resp.success.return_value = False resp.code = 230002 resp.msg = "bot not in group" channel._client.im.v1.message.get.return_value = resp result = channel._get_message_content_sync("om_parent") assert result is None def test_get_message_content_sync_returns_none_for_non_text_type() -> None: channel = _make_feishu_channel() body = SimpleNamespace(content=json.dumps({"image_key": "img_1"})) item = SimpleNamespace(msg_type="image", body=body) data = SimpleNamespace(items=[item]) resp = MagicMock() resp.success.return_value = True resp.data = data channel._client.im.v1.message.get.return_value = resp result = channel._get_message_content_sync("om_parent") assert result is None def test_get_message_content_sync_returns_none_when_empty_text() -> None: channel = _make_feishu_channel() channel._client.im.v1.message.get.return_value = _make_get_message_response(" ") result = channel._get_message_content_sync("om_parent") assert result is None # --------------------------------------------------------------------------- # _reply_message_sync tests # --------------------------------------------------------------------------- def test_reply_message_sync_returns_true_on_success() -> None: channel = _make_feishu_channel() resp = MagicMock() resp.success.return_value = True channel._client.im.v1.message.reply.return_value = resp ok = channel._reply_message_sync("om_parent", "text", '{"text":"hi"}') assert ok is True channel._client.im.v1.message.reply.assert_called_once() def test_reply_message_sync_returns_false_on_api_error() -> None: channel = _make_feishu_channel() resp = MagicMock() resp.success.return_value = False resp.code = 400 resp.msg = "bad request" resp.get_log_id.return_value = "log_x" channel._client.im.v1.message.reply.return_value = resp ok = channel._reply_message_sync("om_parent", "text", '{"text":"hi"}') assert ok is False def test_reply_message_sync_returns_false_on_exception() -> None: channel = _make_feishu_channel() channel._client.im.v1.message.reply.side_effect = RuntimeError("network error") ok = channel._reply_message_sync("om_parent", "text", '{"text":"hi"}') assert ok is False @pytest.mark.asyncio @pytest.mark.parametrize( ("filename", "expected_msg_type"), [ ("voice.opus", "audio"), ("clip.mp4", "video"), ("report.pdf", "file"), ], ) async def test_send_uses_expected_feishu_msg_type_for_uploaded_files( tmp_path: Path, filename: str, expected_msg_type: str ) -> None: channel = _make_feishu_channel() file_path = tmp_path / filename file_path.write_bytes(b"demo") send_calls: list[tuple[str, str, str, str]] = [] def _record_send(receive_id_type: str, receive_id: str, msg_type: str, content: str) -> None: send_calls.append((receive_id_type, receive_id, msg_type, content)) with patch.object(channel, "_upload_file_sync", return_value="file-key"), patch.object( channel, "_send_message_sync", side_effect=_record_send ): await channel.send( OutboundMessage( channel="feishu", chat_id="oc_test", content="", media=[str(file_path)], metadata={}, ) ) assert len(send_calls) == 1 receive_id_type, receive_id, msg_type, content = send_calls[0] assert receive_id_type == "chat_id" assert receive_id == "oc_test" assert msg_type == expected_msg_type assert json.loads(content) == {"file_key": "file-key"} # --------------------------------------------------------------------------- # send() — reply routing tests # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_send_uses_reply_api_when_configured() -> None: channel = _make_feishu_channel(reply_to_message=True) reply_resp = MagicMock() reply_resp.success.return_value = True channel._client.im.v1.message.reply.return_value = reply_resp await channel.send(OutboundMessage( channel="feishu", chat_id="oc_abc", content="hello", metadata={"message_id": "om_001"}, )) channel._client.im.v1.message.reply.assert_called_once() channel._client.im.v1.message.create.assert_not_called() @pytest.mark.asyncio async def test_send_uses_create_api_when_reply_disabled() -> None: channel = _make_feishu_channel(reply_to_message=False) create_resp = MagicMock() create_resp.success.return_value = True channel._client.im.v1.message.create.return_value = create_resp await channel.send(OutboundMessage( channel="feishu", chat_id="oc_abc", content="hello", metadata={"message_id": "om_001"}, )) channel._client.im.v1.message.create.assert_called_once() channel._client.im.v1.message.reply.assert_not_called() @pytest.mark.asyncio async def test_send_uses_create_api_when_no_message_id() -> None: channel = _make_feishu_channel(reply_to_message=True) create_resp = MagicMock() create_resp.success.return_value = True channel._client.im.v1.message.create.return_value = create_resp await channel.send(OutboundMessage( channel="feishu", chat_id="oc_abc", content="hello", metadata={}, )) channel._client.im.v1.message.create.assert_called_once() channel._client.im.v1.message.reply.assert_not_called() @pytest.mark.asyncio async def test_send_skips_reply_for_progress_messages() -> None: channel = _make_feishu_channel(reply_to_message=True) create_resp = MagicMock() create_resp.success.return_value = True channel._client.im.v1.message.create.return_value = create_resp await channel.send(OutboundMessage( channel="feishu", chat_id="oc_abc", content="thinking...", metadata={"message_id": "om_001", "_progress": True}, )) channel._client.im.v1.message.create.assert_called_once() channel._client.im.v1.message.reply.assert_not_called() @pytest.mark.asyncio async def test_send_fallback_to_create_when_reply_fails() -> None: channel = _make_feishu_channel(reply_to_message=True) reply_resp = MagicMock() reply_resp.success.return_value = False reply_resp.code = 400 reply_resp.msg = "error" reply_resp.get_log_id.return_value = "log_x" channel._client.im.v1.message.reply.return_value = reply_resp create_resp = MagicMock() create_resp.success.return_value = True channel._client.im.v1.message.create.return_value = create_resp await channel.send(OutboundMessage( channel="feishu", chat_id="oc_abc", content="hello", metadata={"message_id": "om_001"}, )) # reply attempted first, then falls back to create channel._client.im.v1.message.reply.assert_called_once() channel._client.im.v1.message.create.assert_called_once() # --------------------------------------------------------------------------- # _on_message — parent_id / root_id metadata tests # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_on_message_captures_parent_and_root_id_in_metadata() -> None: channel = _make_feishu_channel() channel._processed_message_ids.clear() channel._client.im.v1.message.react.return_value = MagicMock(success=lambda: True) captured = [] async def _capture(**kwargs): captured.append(kwargs) channel._handle_message = _capture with patch.object(channel, "_add_reaction", return_value=None): await channel._on_message( _make_feishu_event( parent_id="om_parent", root_id="om_root", ) ) assert len(captured) == 1 meta = captured[0]["metadata"] assert meta["parent_id"] == "om_parent" assert meta["root_id"] == "om_root" assert meta["message_id"] == "om_001" @pytest.mark.asyncio async def test_on_message_parent_and_root_id_none_when_absent() -> None: channel = _make_feishu_channel() channel._processed_message_ids.clear() captured = [] async def _capture(**kwargs): captured.append(kwargs) channel._handle_message = _capture with patch.object(channel, "_add_reaction", return_value=None): await channel._on_message(_make_feishu_event()) assert len(captured) == 1 meta = captured[0]["metadata"] assert meta["parent_id"] is None assert meta["root_id"] is None @pytest.mark.asyncio async def test_on_message_prepends_reply_context_when_parent_id_present() -> None: channel = _make_feishu_channel() channel._processed_message_ids.clear() channel._client.im.v1.message.get.return_value = _make_get_message_response("original question") captured = [] async def _capture(**kwargs): captured.append(kwargs) channel._handle_message = _capture with patch.object(channel, "_add_reaction", return_value=None): await channel._on_message( _make_feishu_event( content='{"text": "my answer"}', parent_id="om_parent", ) ) assert len(captured) == 1 content = captured[0]["content"] assert content.startswith("[Reply to: original question]") assert "my answer" in content @pytest.mark.asyncio async def test_on_message_no_extra_api_call_when_no_parent_id() -> None: channel = _make_feishu_channel() channel._processed_message_ids.clear() captured = [] async def _capture(**kwargs): captured.append(kwargs) channel._handle_message = _capture with patch.object(channel, "_add_reaction", return_value=None): await channel._on_message(_make_feishu_event()) channel._client.im.v1.message.get.assert_not_called() assert len(captured) == 1