From 2ac3b50ec13ae445e0a8f94deff326d6e6d6d825 Mon Sep 17 00:00:00 2001 From: luxuguang-leo Date: Wed, 29 Jul 2026 00:10:44 +0800 Subject: [PATCH] fix(feishu): add forwarded resource download fallback When a user forwards a file or image in a Feishu group chat, the resource (file_key/image_key) belongs to the original message, not the forwarded copy. Download fails because message_resource.get associates resources with the message they were first uploaded in. This adds a fallback that resolves the source message_id via message.get API and retries the download with it. - _resolve_forwarded_source_message_id: finds root_id/parent_id/ upper_message_id from the forwarded message - _retry_with_forwarded_source: shared helper called from both download paths on failure - Both _download_feishu_image and _download_feishu_message_resource now retry with the resolved source message_id Addresses all three review points from #63110: 1. Image path now has the same fallback 2. Uses self._run_blocking() (standard pattern after b296915c) 3. Full test coverage: 6 resolve + 4 retry integration tests --- plugins/platforms/feishu/adapter.py | 77 ++++++++++++++++++- tests/gateway/test_feishu.py | 114 ++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+), 3 deletions(-) diff --git a/plugins/platforms/feishu/adapter.py b/plugins/platforms/feishu/adapter.py index 751c2bf76dfde..a1bb070cf14ad 100644 --- a/plugins/platforms/feishu/adapter.py +++ b/plugins/platforms/feishu/adapter.py @@ -2405,6 +2405,68 @@ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: logger.warning("[Feishu] Failed to get chat info for %s", chat_id, exc_info=True) return fallback + async def _resolve_forwarded_source_message_id(self, message_id: str) -> Optional[str]: + """For forwarded messages, find the original source message_id. + + Feishu creates a new message when a user forwards content; the new + message's file_key/image_key points to the original message's resource. + Downloading via the new message_id fails — we need the original. + """ + if not self._client or not message_id: + return None + try: + request = self._build_get_message_request(message_id) + response = await self._run_blocking(self._client.im.v1.message.get, request) + if not response or getattr(response, "success", lambda: False)() is False: + return None + items = getattr(getattr(response, "data", None), "items", None) or [] + if not items: + return None + msg = items[0] + alt_id = ( + getattr(msg, "root_id", None) + or getattr(msg, "parent_id", None) + or getattr(msg, "upper_message_id", None) + ) + if alt_id and str(alt_id).strip() and str(alt_id).strip() != message_id: + return str(alt_id).strip() + except Exception: + logger.debug("[Feishu] Failed to resolve forwarded source for %s", message_id, exc_info=True) + return None + + async def _retry_with_forwarded_source( + self, + resource_type: str, + message_id: str, + *, + file_key: Optional[str] = None, + image_key: Optional[str] = None, + fallback_filename: str = "", + ) -> tuple[str, str]: + """Try resolving a forwarded message's source and retry the download. + + Called when a direct resource download fails — may be a forwarded + message whose resource belongs to the original. Returns the same + shape as the downloaders: (cached_path, media_type) or ("", ""). + """ + alt_id = await self._resolve_forwarded_source_message_id(message_id) + if not alt_id or alt_id == message_id: + return "", "" + logger.info( + "[Feishu] Retrying forwarded resource %s via source message %s (forwarded %s)", + file_key or image_key or "?", alt_id, message_id, + ) + if image_key: + return await self._download_feishu_image( + message_id=alt_id, image_key=image_key, + ) + return await self._download_feishu_message_resource( + message_id=alt_id, + file_key=file_key or "", + resource_type=resource_type, + fallback_filename=fallback_filename, + ) + def format_message(self, content: str) -> str: """Feishu text messages are plain text by default.""" return content.strip() @@ -3912,7 +3974,9 @@ async def _download_feishu_image(self, *, message_id: str, image_key: str) -> tu getattr(response, "code", "unknown"), getattr(response, "msg", "request failed"), ) - return "", "" + return await self._retry_with_forwarded_source( + "image", message_id, image_key=image_key, + ) raw_bytes = self._read_binary_response(response) if not raw_bytes: return "", "" @@ -3924,7 +3988,10 @@ async def _download_feishu_image(self, *, message_id: str, image_key: str) -> tu return cached_path, media_type except Exception: logger.warning("[Feishu] Failed to cache image resource %s", image_key, exc_info=True) - return "", "" + # ── Forwarded message fallback ────────────────────────────────── + return await self._retry_with_forwarded_source( + "image", message_id, image_key=image_key, + ) async def _download_feishu_message_resource( self, @@ -4002,7 +4069,11 @@ async def _download_feishu_message_resource( file_key, exc_info=True, ) - return "", "" + # ── Forwarded message fallback ────────────────────────────────── + return await self._retry_with_forwarded_source( + resource_type, message_id, + file_key=file_key, fallback_filename=fallback_filename, + ) # ========================================================================= # Static helpers — extension / media-type guessing diff --git a/tests/gateway/test_feishu.py b/tests/gateway/test_feishu.py index 0e411b8cf6002..8a9a64a09bb98 100644 --- a/tests/gateway/test_feishu.py +++ b/tests/gateway/test_feishu.py @@ -4954,6 +4954,120 @@ def test_pure_self_mention_message_is_ignored(self): adapter._dispatch_inbound_event.assert_not_called() +class TestForwardedResourceFallback(unittest.TestCase): + """Tests for forwarded resource download fallback (_retry_with_forwarded_source).""" + + def _build_adapter(self): + from plugins.platforms.feishu.adapter import FeishuAdapter + from unittest.mock import Mock + adapter = FeishuAdapter.__new__(FeishuAdapter) + adapter._client = Mock() + return adapter + + def _make_msg_response(self, root_id=None, parent_id=None, upper_message_id=None): + from unittest.mock import Mock + from types import SimpleNamespace + msg = SimpleNamespace( + root_id=root_id, + parent_id=parent_id, + upper_message_id=upper_message_id, + ) + resp = Mock() + resp.success = lambda: True + resp.data = SimpleNamespace(items=[msg]) + return resp + + # ── _resolve_forwarded_source_message_id ─────────────────────────── + + def test_resolve_finds_root_id(self): + adapter = self._build_adapter() + resp = self._make_msg_response(root_id="om_original_123") + adapter._client.im.v1.message.get = Mock(return_value=resp) + result = asyncio.run(adapter._resolve_forwarded_source_message_id("om_forwarded_456")) + self.assertEqual(result, "om_original_123") + + def test_resolve_prefers_root_over_parent(self): + adapter = self._build_adapter() + resp = self._make_msg_response(root_id="om_root", parent_id="om_parent", upper_message_id="om_upper") + adapter._client.im.v1.message.get = Mock(return_value=resp) + result = asyncio.run(adapter._resolve_forwarded_source_message_id("om_current")) + self.assertEqual(result, "om_root") + + def test_resolve_uses_parent_when_no_root(self): + adapter = self._build_adapter() + resp = self._make_msg_response(root_id=None, parent_id="om_parent", upper_message_id="om_upper") + adapter._client.im.v1.message.get = Mock(return_value=resp) + result = asyncio.run(adapter._resolve_forwarded_source_message_id("om_current")) + self.assertEqual(result, "om_parent") + + def test_resolve_returns_none_when_no_source(self): + adapter = self._build_adapter() + resp = self._make_msg_response() + adapter._client.im.v1.message.get = Mock(return_value=resp) + result = asyncio.run(adapter._resolve_forwarded_source_message_id("om_normal")) + self.assertIsNone(result) + + def test_resolve_returns_none_for_self_reference(self): + adapter = self._build_adapter() + resp = self._make_msg_response(root_id="om_self") + adapter._client.im.v1.message.get = Mock(return_value=resp) + result = asyncio.run(adapter._resolve_forwarded_source_message_id("om_self")) + self.assertIsNone(result) + + def test_resolve_returns_none_on_api_failure(self): + adapter = self._build_adapter() + adapter._client.im.v1.message.get = Mock(side_effect=Exception("API unavailable")) + result = asyncio.run(adapter._resolve_forwarded_source_message_id("om_fail")) + self.assertIsNone(result) + + # ── _retry_with_forwarded_source ─────────────────────────────────── + + def test_retry_image_fallback_resolves_and_succeeds(self): + adapter = self._build_adapter() + adapter._resolve_forwarded_source_message_id = AsyncMock(return_value="om_source") + adapter._download_feishu_image = AsyncMock(return_value=("/tmp/img.jpg", "image/jpeg")) + result = asyncio.run(adapter._retry_with_forwarded_source( + "image", "om_fwd", image_key="img_v3_xxx", + )) + self.assertEqual(result, ("/tmp/img.jpg", "image/jpeg")) + adapter._download_feishu_image.assert_called_once_with( + message_id="om_source", image_key="img_v3_xxx", + ) + + def test_retry_file_fallback_resolves_and_succeeds(self): + adapter = self._build_adapter() + adapter._resolve_forwarded_source_message_id = AsyncMock(return_value="om_source") + adapter._download_feishu_message_resource = AsyncMock(return_value=("/tmp/doc.pdf", "application/pdf")) + result = asyncio.run(adapter._retry_with_forwarded_source( + "file", "om_fwd", file_key="file_v3_xxx", fallback_filename="doc.pdf", + )) + self.assertEqual(result, ("/tmp/doc.pdf", "application/pdf")) + adapter._download_feishu_message_resource.assert_called_once_with( + message_id="om_source", file_key="file_v3_xxx", + resource_type="file", fallback_filename="doc.pdf", + ) + + def test_retry_returns_empty_when_no_alt_source(self): + adapter = self._build_adapter() + adapter._resolve_forwarded_source_message_id = AsyncMock(return_value=None) + with patch.object(adapter, '_download_feishu_image') as mock_img: + result = asyncio.run(adapter._retry_with_forwarded_source( + "image", "om_fwd", image_key="img_v3_xxx", + )) + self.assertEqual(result, ("", "")) + mock_img.assert_not_called() + + def test_retry_returns_empty_when_alt_equals_self(self): + adapter = self._build_adapter() + adapter._resolve_forwarded_source_message_id = AsyncMock(return_value="om_self") + with patch.object(adapter, '_download_feishu_image') as mock_img: + result = asyncio.run(adapter._retry_with_forwarded_source( + "image", "om_self", image_key="img_v3_xxx", + )) + self.assertEqual(result, ("", "")) + mock_img.assert_not_called() + + class TestFeishuFetchMessageText(unittest.TestCase): def _build_adapter(self): from plugins.platforms.feishu.adapter import FeishuAdapter