mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-20 16:42:25 +00:00
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:
parent
9eff9a70bb
commit
934372d90b
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user