diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py index 12ad62b5a7e94..7b9cd1e12570f 100644 --- a/gateway/platforms/feishu.py +++ b/gateway/platforms/feishu.py @@ -3065,7 +3065,23 @@ async def _process_inbound_message( or getattr(message, "root_id", None) or None ) - reply_to_text = await self._fetch_message_text(reply_to_message_id) if reply_to_message_id else None + parent_media_urls: List[str] = [] + parent_media_types: List[str] = [] + if reply_to_message_id: + reply_to_text, parent_media_urls, parent_media_types = ( + await self._fetch_parent_message_with_media(reply_to_message_id) + ) + else: + reply_to_text = None + if parent_media_urls: + media_urls = list(media_urls) + parent_media_urls + media_types = list(media_types) + parent_media_types + # If the current message is plain text but the quoted message has media, upgrade type + if inbound_type == MessageType.TEXT and parent_media_types: + try: + inbound_type = MessageType(parent_media_types[0]) + except ValueError: + inbound_type = MessageType.PHOTO sender_primary = ( getattr(sender_id, "open_id", None) @@ -3989,6 +4005,57 @@ async def _fetch_message_text(self, message_id: str) -> Optional[str]: logger.warning("[Feishu] Failed to fetch parent message %s", message_id, exc_info=True) return None + async def _fetch_parent_message_with_media( + self, message_id: str + ) -> tuple[Optional[str], List[str], List[str]]: + """Fetch a parent/quoted message and return (text, media_urls, media_types). + + Unlike _fetch_message_text, this also downloads images and files attached to + the quoted message so Hermes can see them when the user replies to a media message. + """ + if not self._client or not message_id: + return None, [], [] + # Text-only result may already be cached; re-use it (media already empty for pure-text). + if message_id in self._message_text_cache: + return self._message_text_cache[message_id], [], [] + try: + request = self._build_get_message_request(message_id) + response = await asyncio.to_thread(self._client.im.v1.message.get, request) + if not response or getattr(response, "success", lambda: False)() is False: + code = getattr(response, "code", "unknown") + msg = getattr(response, "msg", "message lookup failed") + logger.warning("[Feishu] Failed to fetch parent message %s: [%s] %s", message_id, code, msg) + return None, [], [] + items = getattr(getattr(response, "data", None), "items", None) or [] + parent = items[0] if items else None + body = getattr(parent, "body", None) + msg_type = getattr(parent, "msg_type", "") or "" + raw_content = getattr(body, "content", "") or "" + parent_mentions = getattr(parent, "mentions", None) if parent else None + normalized = normalize_feishu_message( + message_type=msg_type, + raw_content=raw_content, + mentions=parent_mentions, + bot=self._bot_identity(), + ) + text = normalized.text_content + if not text: + placeholder = normalized.metadata.get("placeholder_text") if isinstance(normalized.metadata, dict) else None + text = str(placeholder).strip() or None + self._message_text_cache[message_id] = text + while len(self._message_text_cache) > _FEISHU_MESSAGE_TEXT_CACHE_SIZE: + self._message_text_cache.popitem(last=False) + + # Download images and files from the quoted message + media_urls, media_types = await self._download_feishu_message_resources( + message_id=message_id, + normalized=normalized, + ) + return text, media_urls, media_types + except Exception: + logger.warning("[Feishu] Failed to fetch parent message %s", message_id, exc_info=True) + return None, [], [] + def _extract_text_from_raw_content( self, *, diff --git a/tests/gateway/test_feishu.py b/tests/gateway/test_feishu.py index 56770f55d7613..df61c8ed1ec5c 100644 --- a/tests/gateway/test_feishu.py +++ b/tests/gateway/test_feishu.py @@ -4943,3 +4943,146 @@ async def _run(): held.release() asyncio.run(_run()) + + +class TestFeishuFetchParentMessageWithMedia(unittest.TestCase): + """Tests for _fetch_parent_message_with_media — the fix for quoted-message media passthrough.""" + + def _build_adapter(self): + from collections import OrderedDict + + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter.__new__(FeishuAdapter) + adapter._bot_open_id = "ou_bot" + adapter._bot_user_id = "" + adapter._bot_name = "Hermes" + adapter._message_text_cache = OrderedDict() + adapter._client = Mock() + adapter._build_get_message_request = Mock(return_value=object()) + return adapter + + def _make_response(self, msg_type: str, content: str): + parent = SimpleNamespace( + body=SimpleNamespace(content=content), + msg_type=msg_type, + mentions=[], + ) + response = Mock() + response.success = Mock(return_value=True) + response.data = SimpleNamespace(items=[parent]) + return response + + def test_returns_text_and_empty_media_for_text_message(self): + adapter = self._build_adapter() + adapter._client.im.v1.message.get = Mock( + return_value=self._make_response("text", json.dumps({"text": "hello"})) + ) + adapter._download_feishu_message_resources = AsyncMock(return_value=([], [])) + + text, media_urls, media_types = asyncio.run( + adapter._fetch_parent_message_with_media("om_parent") + ) + self.assertEqual(text, "hello") + self.assertEqual(media_urls, []) + self.assertEqual(media_types, []) + + def test_returns_media_urls_for_image_message(self): + adapter = self._build_adapter() + adapter._client.im.v1.message.get = Mock( + return_value=self._make_response( + "image", json.dumps({"image_key": "img_abc123"}) + ) + ) + adapter._download_feishu_message_resources = AsyncMock( + return_value=(["/tmp/img.jpg"], ["image/jpeg"]) + ) + + text, media_urls, media_types = asyncio.run( + adapter._fetch_parent_message_with_media("om_img") + ) + self.assertEqual(media_urls, ["/tmp/img.jpg"]) + self.assertEqual(media_types, ["image/jpeg"]) + + def test_cache_hit_returns_cached_text_with_empty_media(self): + from collections import OrderedDict + + adapter = self._build_adapter() + adapter._message_text_cache = OrderedDict({"om_cached": "cached text"}) + adapter._download_feishu_message_resources = AsyncMock(return_value=([], [])) + + text, media_urls, media_types = asyncio.run( + adapter._fetch_parent_message_with_media("om_cached") + ) + self.assertEqual(text, "cached text") + self.assertEqual(media_urls, []) + # Should not call the API + adapter._client.im.v1.message.get.assert_not_called() + + def test_returns_none_and_empty_lists_on_api_failure(self): + adapter = self._build_adapter() + response = Mock() + response.success = Mock(return_value=False) + response.code = 404 + response.msg = "not found" + adapter._client.im.v1.message.get = Mock(return_value=response) + + text, media_urls, media_types = asyncio.run( + adapter._fetch_parent_message_with_media("om_missing") + ) + self.assertIsNone(text) + self.assertEqual(media_urls, []) + self.assertEqual(media_types, []) + + def test_returns_none_and_empty_lists_when_no_client(self): + adapter = self._build_adapter() + adapter._client = None + + text, media_urls, media_types = asyncio.run( + adapter._fetch_parent_message_with_media("om_any") + ) + self.assertIsNone(text) + self.assertEqual(media_urls, []) + self.assertEqual(media_types, []) + + def test_process_inbound_message_merges_parent_media(self): + """When replying to an image message, parent media is merged into the event.""" + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter(PlatformConfig()) + adapter._dispatch_inbound_event = AsyncMock() + adapter.get_chat_info = AsyncMock( + return_value={"chat_id": "oc_chat", "name": "DM", "type": "dm"} + ) + adapter._resolve_sender_profile = AsyncMock( + return_value={"user_id": "ou_user", "user_name": "User", "user_id_alt": None} + ) + # Simulate: quoted message is an image + adapter._fetch_parent_message_with_media = AsyncMock( + return_value=("[image]", ["/tmp/parent_img.jpg"], ["image/jpeg"]) + ) + message = SimpleNamespace( + chat_id="oc_chat", + thread_id=None, + parent_id="om_img_parent", + upper_message_id=None, + message_type="text", + content='{"text":"what is in this image?"}', + message_id="om_reply", + ) + + asyncio.run( + adapter._process_inbound_message( + data=SimpleNamespace(event=SimpleNamespace(message=message)), + message=message, + sender_id=SimpleNamespace(open_id="ou_user", user_id=None, union_id=None), + is_bot=False, + chat_type="p2p", + message_id="om_reply", + ) + ) + + event = adapter._dispatch_inbound_event.await_args.args[0] + self.assertIn("/tmp/parent_img.jpg", event.media_urls) + self.assertEqual(event.reply_to_text, "[image]")