diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 3f8e4349..b552f1c4 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -25,6 +25,7 @@ from nanobot.bus.events import OutboundMessage from nanobot.bus.outbound_events import ProgressEvent from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel +from nanobot.command.router import normalize_command_text from nanobot.config.paths import get_media_dir from nanobot.config.schema import Base from nanobot.utils.helpers import safe_filename @@ -555,6 +556,10 @@ def _qr_register_inner( _STREAM_ELEMENT_ID = "streaming_md" +_NEW_SESSION_DIVIDER_CONTENT = json.dumps({ + "type": "divider", + "params": {"divider_text": {"text": "New session started."}}, +}) @dataclass @@ -2010,6 +2015,14 @@ class FeishuChannel(BaseChannel): ) return + if ( + msg.content.strip() == "New session started." + and msg.metadata.get("chat_type") == "p2p" + and not msg.media + and not msg.buttons + ): + return + # Determine whether the first message should quote the user's message. # Only the very first send (media or text) in this call uses reply; subsequent # chunks/media fall back to plain create to avoid redundant quote bubbles. @@ -2260,6 +2273,17 @@ class FeishuChannel(BaseChannel): if not content and not media_paths: return + if chat_type == "p2p" and normalize_command_text(content).lower() == "/new": + loop = asyncio.get_running_loop() + await loop.run_in_executor( + None, + self._send_message_sync, + "open_id", + sender_id, + "system", + _NEW_SESSION_DIVIDER_CONTENT, + ) + # Build session key for conversation isolation. # If topic_isolation is True: each topic gets its own session via root_id/message_id. # If topic_isolation is False: all messages in group share the same session. diff --git a/tests/channels/test_feishu_reply.py b/tests/channels/test_feishu_reply.py index 71e09826..6606739c 100644 --- a/tests/channels/test_feishu_reply.py +++ b/tests/channels/test_feishu_reply.py @@ -550,6 +550,64 @@ async def test_on_message_no_extra_api_call_when_no_parent_id() -> None: assert len(captured) == 1 +@pytest.mark.parametrize(("chat_type", "group_policy", "should_send"), [ + ("p2p", "mention", True), + ("group", "open", False), +]) +@pytest.mark.asyncio +async def test_on_message_new_system_divider_only_in_p2p( + chat_type: str, + group_policy: str, + should_send: bool, +) -> None: + channel = _make_feishu_channel(group_policy=group_policy) + channel._processed_message_ids.clear() + channel._send_message_sync = MagicMock(return_value="om_system") + channel._handle_message = AsyncMock() + + with patch.object(channel, "_add_reaction", return_value=None): + await channel._on_message(_make_feishu_event( + chat_type=chat_type, + content='{"text": "/new"}', + )) + + channel._handle_message.assert_awaited_once() + assert channel._handle_message.call_args.kwargs["content"] == "/new" + if not should_send: + channel._send_message_sync.assert_not_called() + return + _, receive_id, msg_type, content = channel._send_message_sync.call_args.args + assert receive_id == "ou_alice" + assert msg_type == "system" + assert json.loads(content)["type"] == "divider" + + +@pytest.mark.asyncio +async def test_send_new_session_text_suppressed_in_p2p_only() -> None: + p2p = _make_feishu_channel() + p2p._send_message_sync = MagicMock() + + await p2p.send(OutboundMessage( + channel="feishu", + chat_id="ou_alice", + content="New session started.", + metadata={"chat_type": "p2p"}, + )) + + group = _make_feishu_channel() + group._send_message_sync = MagicMock(return_value="om_text") + + await group.send(OutboundMessage( + channel="feishu", + chat_id="oc_group", + content="New session started.", + metadata={"chat_type": "group"}, + )) + + p2p._send_message_sync.assert_not_called() + assert group._send_message_sync.call_args.args[2] == "text" + + @pytest.mark.asyncio async def test_on_message_strips_required_leading_bot_mention_for_commands() -> None: channel = _make_feishu_channel(group_policy="mention")