diff --git a/gateway/run.py b/gateway/run.py index bcee403b1ce9a..65b61ef606077 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -8942,7 +8942,7 @@ async def _prepare_inbound_message_text( ) message_text = f"{_note}\n\n{message_text}" - if event.media_urls and event.message_type == MessageType.DOCUMENT: + if event.media_urls: import mimetypes as _mimetypes from tools.credential_files import to_agent_visible_cache_path diff --git a/plugins/platforms/feishu/adapter.py b/plugins/platforms/feishu/adapter.py index bf3c49d3b8679..099246c38b436 100644 --- a/plugins/platforms/feishu/adapter.py +++ b/plugins/platforms/feishu/adapter.py @@ -3130,7 +3130,14 @@ 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 + reply_to_text = None + reply_media_urls: List[str] = [] + reply_media_types: List[str] = [] + if reply_to_message_id: + reply_to_text, reply_media_urls, reply_media_types = await self._fetch_message_context(reply_to_message_id) + if reply_media_urls: + media_urls.extend(reply_media_urls) + media_types.extend(reply_media_types) sender_primary = ( getattr(sender_id, "open_id", None) @@ -4023,12 +4030,9 @@ async def _fetch_bot_names(self, bot_ids: List[str]) -> Optional[Dict[str, str]] logger.debug("[Feishu] Failed to fetch bot names for %s", bot_ids, exc_info=True) return None - async def _fetch_message_text(self, message_id: str) -> Optional[str]: + async def _fetch_message_context(self, message_id: str) -> tuple[Optional[str], List[str], List[str]]: if not self._client or not message_id: - return None - if message_id in self._message_text_cache: - self._message_text_cache.move_to_end(message_id) - return self._message_text_cache[message_id] + return None, [], [] try: request = self._build_get_message_request(message_id) response = await asyncio.to_thread(self._client.im.v1.message.get, request) @@ -4036,25 +4040,39 @@ async def _fetch_message_text(self, message_id: str) -> Optional[str]: 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 + 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 "" + msg_type = getattr(parent, "msg_type", "") or getattr(parent, "message_type", "") or "" + raw_content = getattr(body, "content", "") or getattr(parent, "content", "") or "" parent_mentions = getattr(parent, "mentions", None) if parent else None - text = self._extract_text_from_raw_content( - msg_type=msg_type, + normalized = normalize_feishu_message( + message_type=msg_type, raw_content=raw_content, mentions=parent_mentions, + bot=self._bot_identity(), + ) + text = self._extract_text_from_normalized(normalized) + media_urls, media_types = await self._download_feishu_message_resources( + message_id=message_id, + normalized=normalized, ) 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) - return text + return text, media_urls, media_types except Exception: logger.warning("[Feishu] Failed to fetch parent message %s", message_id, exc_info=True) + return None, [], [] + + async def _fetch_message_text(self, message_id: str) -> Optional[str]: + if not message_id: return None + if message_id in self._message_text_cache: + return self._message_text_cache[message_id] + text, _, _ = await self._fetch_message_context(message_id) + return text def _extract_text_from_raw_content( self, @@ -4069,6 +4087,10 @@ def _extract_text_from_raw_content( mentions=mentions, bot=self._bot_identity(), ) + return self._extract_text_from_normalized(normalized) + + @staticmethod + def _extract_text_from_normalized(normalized: FeishuNormalizedMessage) -> Optional[str]: if normalized.text_content: return normalized.text_content placeholder = normalized.metadata.get("placeholder_text") if isinstance(normalized.metadata, dict) else None diff --git a/tests/gateway/test_feishu.py b/tests/gateway/test_feishu.py index bb97c7e72be17..025061424d9c8 100644 --- a/tests/gateway/test_feishu.py +++ b/tests/gateway/test_feishu.py @@ -1926,7 +1926,9 @@ def test_process_inbound_message_fetches_reply_to_text(self): adapter._resolve_sender_profile = AsyncMock( return_value={"user_id": "ou_user", "user_name": "张三", "user_id_alt": None} ) - adapter._fetch_message_text = AsyncMock(return_value="父消息内容") + adapter._fetch_message_context = AsyncMock( + return_value=("父消息内容", ["/tmp/doc_123_parent.md"], ["text/markdown"]) + ) message = SimpleNamespace( chat_id="oc_chat", thread_id=None, @@ -1951,6 +1953,8 @@ def test_process_inbound_message_fetches_reply_to_text(self): event = adapter._dispatch_inbound_event.await_args.args[0] self.assertEqual(event.reply_to_message_id, "om_parent") self.assertEqual(event.reply_to_text, "父消息内容") + self.assertEqual(event.media_urls, ["/tmp/doc_123_parent.md"]) + self.assertEqual(event.media_types, ["text/markdown"]) @patch.dict(os.environ, {}, clear=True) def test_send_replies_in_thread_when_thread_metadata_present(self): @@ -4634,6 +4638,38 @@ def test_fetch_message_text_renders_mentions_without_hint_prefix(self): # No [Mentioned:] wrapper — reply-context path intentionally skips the hint. self.assertNotIn("[Mentioned:", result) + def test_fetch_message_context_downloads_parent_file_resources(self): + adapter = self._build_adapter() + adapter._download_feishu_message_resources = AsyncMock( + return_value=(["/tmp/doc_lark-cli-shared-app-setup.md"], ["text/markdown"]) + ) + parent = SimpleNamespace( + body=SimpleNamespace( + content=json.dumps( + { + "file_key": "file_doc", + "file_name": "lark-cli-shared-app-setup.md", + } + ) + ), + msg_type="file", + mentions=None, + ) + response = Mock() + response.success = Mock(return_value=True) + response.data = SimpleNamespace(items=[parent]) + adapter._client.im.v1.message.get = Mock(return_value=response) + + text, media_urls, media_types = asyncio.run(adapter._fetch_message_context("m_parent")) + + self.assertEqual(text, "[Attachment: lark-cli-shared-app-setup.md]") + self.assertEqual(media_urls, ["/tmp/doc_lark-cli-shared-app-setup.md"]) + self.assertEqual(media_types, ["text/markdown"]) + adapter._download_feishu_message_resources.assert_awaited_once() + kwargs = adapter._download_feishu_message_resources.await_args.kwargs + self.assertEqual(kwargs["message_id"], "m_parent") + self.assertEqual(kwargs["normalized"].media_refs[0].file_key, "file_doc") + def test_extract_text_from_raw_content_accepts_mentions_kwarg(self): from plugins.platforms.feishu.adapter import FeishuAdapter diff --git a/tests/gateway/test_reply_to_injection.py b/tests/gateway/test_reply_to_injection.py index 311a18cc06b5a..99a6bcd534193 100644 --- a/tests/gateway/test_reply_to_injection.py +++ b/tests/gateway/test_reply_to_injection.py @@ -10,7 +10,7 @@ import pytest from gateway.config import GatewayConfig, Platform, PlatformConfig -from gateway.platforms.base import MessageEvent +from gateway.platforms.base import MessageEvent, MessageType from gateway.run import GatewayRunner from gateway.session import SessionSource @@ -137,6 +137,33 @@ async def test_no_prefix_without_reply_context(): assert result == "hello" +@pytest.mark.asyncio +async def test_reply_to_document_media_gets_agent_visible_path_note(): + runner = _make_runner() + source = _source() + event = MessageEvent( + text="装一下这个吧", + message_type=MessageType.TEXT, + source=source, + reply_to_message_id="om_parent", + reply_to_text="[Attachment: lark-cli-shared-app-setup.md]", + media_urls=["/tmp/doc_123_lark-cli-shared-app-setup.md"], + media_types=["text/markdown"], + ) + + result = await runner._prepare_inbound_message_text( + event=event, + source=source, + history=[], + ) + + assert result is not None + assert result.startswith('[Replying to: "[Attachment: lark-cli-shared-app-setup.md]"]') + assert "The user sent a text document: 'lark-cli-shared-app-setup.md'" in result + assert "The file is also saved at: /tmp/doc_123_lark-cli-shared-app-setup.md" in result + assert result.endswith("装一下这个吧") + + @pytest.mark.asyncio async def test_no_prefix_when_reply_to_text_is_empty(): """reply_to_message_id alone without text (e.g. a reply to a media-only