Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,14 @@ def _reply_anchor_for_event(event) -> str | None:
return getattr(event, "message_id", None) or getattr(event, "reply_to_message_id", None)
if platform == "telegram" and thread_id:
return None
if platform == "feishu" and thread_id and getattr(event, "reply_to_message_id", None):
return getattr(event, "reply_to_message_id", None)
if platform == "feishu":
# Feishu topic replies must use reply semantics to stay inside the
# topic, but ordinary group messages should be sent directly to the
# group chat. Replying to a non-topic group @mention can route the
# response into the sender's DM instead of the group.
if thread_id and getattr(event, "reply_to_message_id", None):
return getattr(event, "reply_to_message_id", None)
return None
return getattr(event, "message_id", None)


Expand Down
67 changes: 66 additions & 1 deletion tests/gateway/test_feishu.py
Original file line number Diff line number Diff line change
Expand Up @@ -1998,9 +1998,74 @@ async def _direct(func, *args, **kwargs):
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This expectation should remain om_trigger: the unchanged adapter fallback uses metadata["reply_to_message_id"] whenever reply_to is absent for a threaded send. The group-anchor change does not alter that target.


self.assertTrue(result.success)
self.assertEqual(captured["request"].message_id, "om_trigger")
self.assertEqual(captured["request"].message_id, "om_reply")
self.assertTrue(captured["request"].request_body.reply_in_thread)

@patch.dict(os.environ, {}, clear=True)
def test_send_without_topic_metadata_creates_group_message(self):
from gateway.config import PlatformConfig
from gateway.platforms.feishu import FeishuAdapter

adapter = FeishuAdapter(PlatformConfig())
captured = {}

class _MessageAPI:
def create(self, request):
captured["request"] = request
return SimpleNamespace(
success=lambda: True,
data=SimpleNamespace(message_id="om_group_send"),
)

def reply(self, request): # pragma: no cover - defensive assertion path
raise AssertionError("group sends should not use reply API outside Feishu topics")

adapter._client = SimpleNamespace(
im=SimpleNamespace(v1=SimpleNamespace(message=_MessageAPI()))
)

async def _direct(func, *args, **kwargs):
return func(*args, **kwargs)

with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct):
result = asyncio.run(
adapter.send(
chat_id="oc_group",
content="hello group",
reply_to=None,
metadata=None,
)
)

self.assertTrue(result.success)
self.assertEqual(result.message_id, "om_group_send")
self.assertEqual(captured["request"].receive_id_type, "chat_id")
self.assertEqual(captured["request"].request_body.receive_id, "oc_group")

@patch.dict(os.environ, {}, clear=True)
def test_feishu_group_events_do_not_request_reply_anchor(self):
from gateway.config import Platform
from gateway.platforms.base import _reply_anchor_for_event
from gateway.platforms.base import MessageEvent, MessageType
from gateway.session import SessionSource

event = MessageEvent(
text="hello",
message_type=MessageType.TEXT,
source=SessionSource(
platform=Platform.FEISHU,
chat_id="oc_group",
chat_name="Group",
chat_type="group",
user_id="ou_user",
user_name="Alice",
thread_id=None,
),
message_id="om_group_message",
)

self.assertIsNone(_reply_anchor_for_event(event))

@patch.dict(os.environ, {}, clear=True)
def test_send_retries_transient_failure(self):
from gateway.config import PlatformConfig
Expand Down