Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 74 additions & 3 deletions plugins/platforms/feishu/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 "", ""
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
114 changes: 114 additions & 0 deletions tests/gateway/test_feishu.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading