diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py index ae3f707510463..1f7e7199f38af 100644 --- a/gateway/platforms/feishu.py +++ b/gateway/platforms/feishu.py @@ -2937,7 +2937,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) @@ -3826,11 +3833,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: - 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) @@ -3838,23 +3843,37 @@ 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 - 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, @@ -3869,6 +3888,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/gateway/run.py b/gateway/run.py index 559adae89bf06..dea82040858b8 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6797,7 +6797,7 @@ async def _prepare_inbound_message_text( except Exception: pass - 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 @@ -6828,14 +6828,12 @@ async def _prepare_inbound_message_text( if mtype.startswith("text/"): context_note = ( f"[The user sent a text document: '{display_name}'. " - f"Its content has been included below. " - f"The file is also saved at: {agent_path}]" + f"The file is saved at: {agent_path}.]" ) else: context_note = ( f"[The user sent a document: '{display_name}'. " - f"The file is saved at: {agent_path}. " - f"Ask the user what they'd like you to do with it.]" + f"The file is saved at: {agent_path}.]" ) message_text = f"{context_note}\n\n{message_text}" diff --git a/tests/gateway/test_feishu.py b/tests/gateway/test_feishu.py index 63287d88cb4bd..529b897be4415 100644 --- a/tests/gateway/test_feishu.py +++ b/tests/gateway/test_feishu.py @@ -1895,7 +1895,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, @@ -1920,6 +1922,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): @@ -4572,6 +4576,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 gateway.platforms.feishu import FeishuAdapter diff --git a/tests/gateway/test_reply_to_injection.py b/tests/gateway/test_reply_to_injection.py index f75ec6d68f3f0..73a69d7796c75 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 @@ -114,6 +114,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 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