feat(feishu): add thread-scoped session isolation for group chats

Thread replies (messages with root_id != message_id) in group chats
now get their own session key: feishu:{chat_id}:{root_id}. This
means each Feishu thread has an independent conversation context.

Top-level group messages and all private chat messages keep the
default session key (no override), consistent with Telegram and
Slack channel behavior.

Co-authored-by: shenchengtsi <228445050+shenchengtsi@users.noreply.github.com>
This commit is contained in:
chengyongru 2026-04-16 00:18:28 +08:00 committed by chengyongru
parent 9eff9a70bb
commit 934372d90b
2 changed files with 103 additions and 2 deletions

View File

@ -1624,6 +1624,15 @@ class FeishuChannel(BaseChannel):
if not content and not media_paths: if not content and not media_paths:
return return
# Build topic-scoped session key for conversation isolation.
# Group chat: thread replies (root_id != message_id) get a scoped
# session so each Feishu thread has its own conversation context.
# Private chat: no override — same behavior as Telegram/Slack.
if chat_type == "group" and root_id and root_id != message_id:
session_key = f"feishu:{chat_id}:{root_id}"
else:
session_key = None
# Forward to message bus # Forward to message bus
reply_to = chat_id if chat_type == "group" else sender_id reply_to = chat_id if chat_type == "group" else sender_id
await self._handle_message( await self._handle_message(
@ -1640,6 +1649,7 @@ class FeishuChannel(BaseChannel):
"root_id": root_id, "root_id": root_id,
"thread_id": thread_id, "thread_id": thread_id,
}, },
session_key=session_key,
) )
except Exception as e: except Exception as e:

View File

@ -3,7 +3,7 @@ import asyncio
import json import json
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
@ -26,13 +26,14 @@ from nanobot.channels.feishu import FeishuChannel, FeishuConfig
# Helpers # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _make_feishu_channel(reply_to_message: bool = False) -> FeishuChannel: def _make_feishu_channel(reply_to_message: bool = False, group_policy: str = "mention") -> FeishuChannel:
config = FeishuConfig( config = FeishuConfig(
enabled=True, enabled=True,
app_id="cli_test", app_id="cli_test",
app_secret="secret", app_secret="secret",
allow_from=["*"], allow_from=["*"],
reply_to_message=reply_to_message, reply_to_message=reply_to_message,
group_policy=group_policy,
) )
channel = FeishuChannel(config, MessageBus()) channel = FeishuChannel(config, MessageBus())
channel._client = MagicMock() channel._client = MagicMock()
@ -443,3 +444,93 @@ async def test_on_message_no_extra_api_call_when_no_parent_id() -> None:
channel._client.im.v1.message.get.assert_not_called() channel._client.im.v1.message.get.assert_not_called()
assert len(captured) == 1 assert len(captured) == 1
# ---------------------------------------------------------------------------
# Session key derivation tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_session_key_group_with_root_id_is_thread_scoped() -> None:
"""Group message with root_id gets a thread-scoped session key."""
channel = _make_feishu_channel(group_policy="open")
bus_spy = []
original_publish = channel.bus.publish_inbound
async def capture(msg):
bus_spy.append(msg)
await original_publish(msg)
channel.bus.publish_inbound = capture
channel._download_and_save_media = AsyncMock(return_value=(None, ""))
channel.transcribe_audio = AsyncMock(return_value="")
channel._add_reaction = AsyncMock(return_value=None)
event = _make_feishu_event(
chat_type="group",
content='{"text": "hello"}',
root_id="om_root123",
message_id="om_child456",
)
await channel._on_message(event)
assert len(bus_spy) == 1
assert bus_spy[0].session_key == "feishu:oc_abc:om_root123"
@pytest.mark.asyncio
async def test_session_key_group_no_root_id_uses_default() -> None:
"""Group message without root_id uses default session key (no override)."""
channel = _make_feishu_channel(group_policy="open")
bus_spy = []
original_publish = channel.bus.publish_inbound
async def capture(msg):
bus_spy.append(msg)
await original_publish(msg)
channel.bus.publish_inbound = capture
channel._download_and_save_media = AsyncMock(return_value=(None, ""))
channel.transcribe_audio = AsyncMock(return_value="")
channel._add_reaction = AsyncMock(return_value=None)
event = _make_feishu_event(
chat_type="group",
content='{"text": "hello"}',
root_id=None,
message_id="om_001",
)
await channel._on_message(event)
assert len(bus_spy) == 1
assert bus_spy[0].session_key_override is None
assert bus_spy[0].session_key == "feishu:oc_abc"
@pytest.mark.asyncio
async def test_session_key_private_chat_no_override() -> None:
"""Private chat never overrides session key (consistent with Telegram/Slack)."""
channel = _make_feishu_channel()
bus_spy = []
original_publish = channel.bus.publish_inbound
async def capture(msg):
bus_spy.append(msg)
await original_publish(msg)
channel.bus.publish_inbound = capture
channel._download_and_save_media = AsyncMock(return_value=(None, ""))
channel.transcribe_audio = AsyncMock(return_value="")
channel._add_reaction = AsyncMock(return_value=None)
event = _make_feishu_event(
chat_type="p2p",
content='{"text": "hello"}',
root_id=None,
message_id="om_001",
)
await channel._on_message(event)
assert len(bus_spy) == 1
assert bus_spy[0].session_key_override is None