feat(feishu): add reply_in_thread for visual topic grouping

When reply_to_message config is enabled, the bot's first reply now
uses reply_in_thread=True to create a visual topic/thread in the
Feishu client. Subsequent chunks fall back to regular create.

The reply_to_message default remains False for backward compatibility.
Failed replies still fall back to regular send — messages are never
silently dropped.
This commit is contained in:
chengyongru 2026-04-16 00:26:01 +08:00 committed by chengyongru
parent 934372d90b
commit a0e97e360e
2 changed files with 92 additions and 7 deletions

View File

@ -1101,17 +1101,23 @@ class FeishuChannel(BaseChannel):
logger.debug("Feishu: error fetching parent message {}: {}", message_id, e) logger.debug("Feishu: error fetching parent message {}: {}", message_id, e)
return None return None
def _reply_message_sync(self, parent_message_id: str, msg_type: str, content: str) -> bool: 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).""" """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 from lark_oapi.api.im.v1 import ReplyMessageRequest, ReplyMessageRequestBody
try: try:
body_builder = ReplyMessageRequestBody.builder().msg_type(msg_type).content(content)
if reply_in_thread:
body_builder = body_builder.reply_in_thread(True)
request = ( request = (
ReplyMessageRequest.builder() ReplyMessageRequest.builder()
.message_id(parent_message_id) .message_id(parent_message_id)
.request_body( .request_body(body_builder.build())
ReplyMessageRequestBody.builder().msg_type(msg_type).content(content).build()
)
.build() .build()
) )
response = self._client.im.v1.message.reply(request) 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 first_send = True # tracks whether the reply has already been used
def _do_send(m_type: str, content: str) -> None: 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 nonlocal first_send
if reply_message_id and first_send: if reply_message_id and first_send:
first_send = False 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: if ok:
return return
# Fall back to regular send if reply fails # Fall back to regular send if reply fails

View File

@ -534,3 +534,75 @@ async def test_session_key_private_chat_no_override() -> None:
assert len(bus_spy) == 1 assert len(bus_spy) == 1
assert bus_spy[0].session_key_override is None 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()