From 843e96f09db279a346ea2435b557b32469e985e3 Mon Sep 17 00:00:00 2001 From: yorkhellen Date: Fri, 8 May 2026 23:53:13 +0800 Subject: [PATCH] fix(feishu): send all messages to topic when in thread --- nanobot/channels/feishu.py | 29 ++++++---- tests/channels/test_feishu_reply.py | 83 +++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 9 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 91022b9af..d5943f9a0 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -1539,10 +1539,11 @@ class FeishuChannel(BaseChannel): # same topic automatically when the target message is inside a topic. reply_message_id: str | None = None _msg_id = msg.metadata.get("message_id") + has_thread_id = msg.metadata.get("thread_id") if self.config.reply_to_message and not msg.metadata.get("_progress", False): reply_message_id = _msg_id # For topic group messages, always reply to keep context in thread - elif msg.metadata.get("thread_id"): + elif has_thread_id: reply_message_id = _msg_id first_send = True # tracks whether the reply has already been used @@ -1555,14 +1556,24 @@ class FeishuChannel(BaseChannel): existing topic must not create a new topic. """ nonlocal first_send - if reply_message_id and first_send: - first_send = False - ok = self._reply_message_sync( - reply_message_id, m_type, content, - reply_in_thread=self._should_use_reply_in_thread(msg.metadata), - ) - if ok: - return + if reply_message_id: + # If we're in a topic, always use reply to stay in the topic + if has_thread_id: + ok = self._reply_message_sync( + reply_message_id, m_type, content, + reply_in_thread=self._should_use_reply_in_thread(msg.metadata), + ) + if ok: + return + elif first_send: + # If we're not in a topic but replying to message, only first uses reply + first_send = False + ok = self._reply_message_sync( + reply_message_id, m_type, content, + reply_in_thread=self._should_use_reply_in_thread(msg.metadata), + ) + if ok: + return # Fall back to regular send if reply fails self._send_message_sync(receive_id_type, msg.chat_id, m_type, content) diff --git a/tests/channels/test_feishu_reply.py b/tests/channels/test_feishu_reply.py index cc7e21e5f..b43a177d1 100644 --- a/tests/channels/test_feishu_reply.py +++ b/tests/channels/test_feishu_reply.py @@ -346,6 +346,89 @@ async def test_send_fallback_to_create_when_reply_fails() -> None: channel._client.im.v1.message.create.assert_called_once() +@pytest.mark.asyncio +async def test_send_multiple_messages_all_use_reply_when_in_topic(tmp_path: Path) -> None: + """When in a topic (has thread_id), all messages use reply API to stay in topic.""" + channel = _make_feishu_channel(reply_to_message=False) + + file1 = tmp_path / "file1.png" + file2 = tmp_path / "file2.png" + file1.write_bytes(b"demo1") + file2.write_bytes(b"demo2") + + reply_calls = [] + create_calls = [] + + def _mock_reply(*args, **kwargs) -> bool: + reply_calls.append((args, kwargs)) + return True + + def _mock_create(*args, **kwargs) -> str: + create_calls.append((args, kwargs)) + return "msg_id" + + with patch.object(channel, "_upload_file_sync", return_value="file-key"), \ + patch.object(channel, "_upload_image_sync", return_value="image-key"), \ + patch.object(channel, "_reply_message_sync", side_effect=_mock_reply), \ + patch.object(channel, "_send_message_sync", side_effect=_mock_create): + await channel.send(OutboundMessage( + channel="feishu", + chat_id="oc_abc", + content="hello", + media=[str(file1), str(file2)], + metadata={ + "message_id": "om_001", + "thread_id": "om_thread", + "chat_type": "group", + }, + )) + + # All 3 sends (text + 2 images) should use reply + assert len(reply_calls) == 3 + assert len(create_calls) == 0 + + +@pytest.mark.asyncio +async def test_send_multiple_messages_only_first_uses_reply_when_reply_to_message(tmp_path: Path) -> None: + """When reply_to_message is enabled but not in topic, only first message uses reply.""" + channel = _make_feishu_channel(reply_to_message=True) + + file1 = tmp_path / "file1.png" + file2 = tmp_path / "file2.png" + file1.write_bytes(b"demo1") + file2.write_bytes(b"demo2") + + reply_calls = [] + create_calls = [] + + def _mock_reply(*args, **kwargs) -> bool: + reply_calls.append((args, kwargs)) + return True + + def _mock_create(*args, **kwargs) -> str: + create_calls.append((args, kwargs)) + return "msg_id" + + with patch.object(channel, "_upload_file_sync", return_value="file-key"), \ + patch.object(channel, "_upload_image_sync", return_value="image-key"), \ + patch.object(channel, "_reply_message_sync", side_effect=_mock_reply), \ + patch.object(channel, "_send_message_sync", side_effect=_mock_create): + await channel.send(OutboundMessage( + channel="feishu", + chat_id="oc_abc", + content="hello", + media=[str(file1), str(file2)], + metadata={ + "message_id": "om_001", + "chat_type": "group", + }, + )) + + # Only first send uses reply, rest use create + assert len(reply_calls) == 1 + assert len(create_calls) == 2 + + # --------------------------------------------------------------------------- # _on_message — parent_id / root_id metadata tests # ---------------------------------------------------------------------------