diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 9f2d13975..ef290766d 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -1101,17 +1101,23 @@ class FeishuChannel(BaseChannel): logger.debug("Feishu: error fetching parent message {}: {}", message_id, e) return None - def _reply_message_sync(self, parent_message_id: str, msg_type: str, content: str) -> bool: - """Reply to an existing Feishu message using the Reply API (synchronous).""" + def _reply_message_sync(self, parent_message_id: str, msg_type: str, content: str, *, reply_in_thread: bool = False) -> bool: + """Reply to an existing Feishu message using the Reply API (synchronous). + + Args: + reply_in_thread: If True, reply as a thread/topic message + in the Feishu client. + """ from lark_oapi.api.im.v1 import ReplyMessageRequest, ReplyMessageRequestBody try: + body_builder = ReplyMessageRequestBody.builder().msg_type(msg_type).content(content) + if reply_in_thread: + body_builder = body_builder.reply_in_thread(True) request = ( ReplyMessageRequest.builder() .message_id(parent_message_id) - .request_body( - ReplyMessageRequestBody.builder().msg_type(msg_type).content(content).build() - ) + .request_body(body_builder.build()) .build() ) response = self._client.im.v1.message.reply(request) @@ -1430,11 +1436,18 @@ class FeishuChannel(BaseChannel): first_send = True # tracks whether the reply has already been used def _do_send(m_type: str, content: str) -> None: - """Send via reply (first message) or create (subsequent).""" + """Send via reply (first message) or create (subsequent). + + When reply_to_message is enabled, the first message uses + reply_in_thread=True to create a visual topic thread. + """ nonlocal first_send if reply_message_id and first_send: first_send = False - ok = self._reply_message_sync(reply_message_id, m_type, content) + ok = self._reply_message_sync( + reply_message_id, m_type, content, + reply_in_thread=self.config.reply_to_message, + ) if ok: return # Fall back to regular send if reply fails diff --git a/tests/channels/test_feishu_reply.py b/tests/channels/test_feishu_reply.py index 15495fcd1..f40ffabc9 100644 --- a/tests/channels/test_feishu_reply.py +++ b/tests/channels/test_feishu_reply.py @@ -534,3 +534,75 @@ async def test_session_key_private_chat_no_override() -> None: assert len(bus_spy) == 1 assert bus_spy[0].session_key_override is None + + +# --------------------------------------------------------------------------- +# reply_in_thread tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_reply_uses_reply_in_thread_when_enabled() -> None: + """When reply_to_message is True, reply includes reply_in_thread=True.""" + channel = _make_feishu_channel(reply_to_message=True) + + reply_resp = MagicMock() + reply_resp.success.return_value = True + channel._client.im.v1.message.reply.return_value = reply_resp + + await channel.send(OutboundMessage( + channel="feishu", + chat_id="oc_abc", + content="hello", + metadata={"message_id": "om_001"}, + )) + + channel._client.im.v1.message.reply.assert_called_once() + call_args = channel._client.im.v1.message.reply.call_args + request = call_args[0][0] + assert request.request_body.reply_in_thread is True + + +@pytest.mark.asyncio +async def test_reply_without_reply_in_thread_when_disabled() -> None: + """When reply_to_message is False, reply does NOT use reply_in_thread.""" + channel = _make_feishu_channel(reply_to_message=False) + + create_resp = MagicMock() + create_resp.success.return_value = True + channel._client.im.v1.message.create.return_value = create_resp + + await channel.send(OutboundMessage( + channel="feishu", + chat_id="oc_abc", + content="hello", + )) + + # No message_id in metadata → no reply attempt, direct create + channel._client.im.v1.message.create.assert_called_once() + + +@pytest.mark.asyncio +async def test_reply_keeps_fallback_when_reply_fails() -> None: + """Even with reply_to_message=True, fallback to create on reply failure.""" + channel = _make_feishu_channel(reply_to_message=True) + + reply_resp = MagicMock() + reply_resp.success.return_value = False + reply_resp.code = 99991400 + reply_resp.msg = "rate limited" + channel._client.im.v1.message.reply.return_value = reply_resp + + create_resp = MagicMock() + create_resp.success.return_value = True + channel._client.im.v1.message.create.return_value = create_resp + + await channel.send(OutboundMessage( + channel="feishu", + chat_id="oc_abc", + content="hello", + metadata={"message_id": "om_001"}, + )) + + channel._client.im.v1.message.reply.assert_called() + channel._client.im.v1.message.create.assert_called()