Skip to content
Closed
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
2 changes: 1 addition & 1 deletion gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
46 changes: 34 additions & 12 deletions plugins/platforms/feishu/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This lookup occurs after the empty-text guard at lines 3116-3119. A user who quote-replies to an attachment with only an @bot mention is stripped to empty and returned before this runs; move hydration before that guard (or make the guard parent-media-aware) and add that regression case.

if reply_media_urls:
media_urls.extend(reply_media_urls)
media_types.extend(reply_media_types)

sender_primary = (
getattr(sender_id, "open_id", None)
Expand Down Expand Up @@ -4023,38 +4030,49 @@ 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]]:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a bounded parent-context cache here. Repeat replies to the same parent re-run the download at line 4057, and the cache helpers create UUID-named files for each download, so repeated replies duplicate cached media until cleanup.

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)
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
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,
Expand All @@ -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
Expand Down
38 changes: 37 additions & 1 deletion tests/gateway/test_feishu.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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):
Expand Down Expand Up @@ -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

Expand Down
29 changes: 28 additions & 1 deletion tests/gateway/test_reply_to_injection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
Loading