Skip to content
Closed
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
14 changes: 11 additions & 3 deletions gateway/platforms/feishu.py
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,7 @@ class FeishuAdapterSettings:
group_rules: Dict[str, FeishuGroupRule] = field(default_factory=dict)
allow_bots: str = "none" # "none" | "mentions" | "all"
require_mention: bool = True
reply_in_thread: bool = True


@dataclass
Expand Down Expand Up @@ -1569,6 +1570,9 @@ def _load_settings(extra: Dict[str, Any]) -> FeishuAdapterSettings:
require_mention=_to_boolean(
extra.get("require_mention", os.getenv("FEISHU_REQUIRE_MENTION", "true"))
),
reply_in_thread=_to_boolean(
extra.get("reply_in_thread", os.getenv("FEISHU_REPLY_IN_THREAD", "true"))
),
)

def _apply_settings(self, settings: FeishuAdapterSettings) -> None:
Expand Down Expand Up @@ -1601,6 +1605,7 @@ def _apply_settings(self, settings: FeishuAdapterSettings) -> None:
self._ws_ping_timeout = settings.ws_ping_timeout
self._allow_bots = settings.allow_bots
self._require_mention = settings.require_mention
self._reply_in_thread = settings.reply_in_thread

def _build_event_handler(self) -> Any:
if EventDispatcherHandler is None:
Expand Down Expand Up @@ -4368,10 +4373,13 @@ async def _send_raw_message(
reply_to: Optional[str],
metadata: Optional[Dict[str, Any]],
) -> Any:
thread_id = (metadata or {}).get("thread_id")
effective_reply_to = reply_to
if not effective_reply_to and metadata and metadata.get("thread_id"):
if thread_id and not self._reply_in_thread:
effective_reply_to = None
elif not effective_reply_to and thread_id:
effective_reply_to = metadata.get("reply_to_message_id")
reply_in_thread = bool((metadata or {}).get("thread_id"))
reply_in_thread = bool(thread_id and self._reply_in_thread)
if effective_reply_to:
body = self._build_reply_message_body(
content=payload,
Expand All @@ -4385,7 +4393,7 @@ async def _send_raw_message(
# For topic/thread messages that fell back from reply→create, use
# thread_id as receive_id so the message lands in the topic instead of
# the main chat.
_thread_id = (metadata or {}).get("thread_id")
_thread_id = thread_id if self._reply_in_thread else None
if _thread_id:
body = self._build_create_message_body(
receive_id=_thread_id,
Expand Down
43 changes: 43 additions & 0 deletions tests/gateway/test_feishu.py
Original file line number Diff line number Diff line change
Expand Up @@ -2030,6 +2030,49 @@ async def _direct(func, *args, **kwargs):
self.assertEqual(captured["request"].message_id, "om_trigger")
self.assertTrue(captured["request"].request_body.reply_in_thread)

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

adapter = FeishuAdapter(PlatformConfig(extra={"reply_in_thread": False}))
captured = {}

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

def reply(self, request): # pragma: no cover - should not be called
raise AssertionError("thread reply should be disabled")

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_chat",
content="status update",
reply_to="om_parent",
metadata={
"thread_id": "omt-thread",
"reply_to_message_id": "om_trigger",
},
)
)

self.assertTrue(result.success)
self.assertEqual(result.message_id, "om_top_level")
self.assertEqual(captured["request"].request_body.receive_id, "oc_chat")

@patch.dict(os.environ, {}, clear=True)
def test_send_retries_transient_failure(self):
from gateway.config import PlatformConfig
Expand Down
1 change: 1 addition & 0 deletions website/docs/reference/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI
| `FEISHU_ALLOWED_USERS` | Comma-separated Feishu user IDs allowed to message the bot |
| `FEISHU_ALLOW_BOTS` | `none` (default) / `mentions` / `all` — accept inbound messages from other bots. See [bot-to-bot messaging](../user-guide/messaging/feishu.md#bot-to-bot-messaging) |
| `FEISHU_REQUIRE_MENTION` | `true` (default) / `false` — whether group messages must @mention the bot. Override per-chat via `group_rules.<chat_id>.require_mention`. |
| `FEISHU_REPLY_IN_THREAD` | `true` (default) / `false` — whether topic/thread messages should receive topic/thread replies. |
| `FEISHU_HOME_CHANNEL` | Feishu chat ID for cron delivery and notifications |
| `WECOM_BOT_ID` | WeCom AI Bot ID from admin console |
| `WECOM_SECRET` | WeCom AI Bot secret |
Expand Down
11 changes: 11 additions & 0 deletions website/docs/user-guide/messaging/feishu.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,16 @@ FEISHU_REQUIRE_MENTION=false

For per-chat control, set `require_mention` on a `group_rules` entry — see [Per-Group Access Control](#per-group-access-control) below.

### Topic Reply Behavior

When a Feishu/Lark message arrives from a topic or thread, Hermes replies in that same topic by default. Set `FEISHU_REPLY_IN_THREAD=false` to post responses directly to the parent chat instead:

```bash
FEISHU_REPLY_IN_THREAD=false
```

This is also configurable as `feishu.reply_in_thread` in `config.yaml` (env wins when both are set).

### Bot Identity

Hermes auto-detects the bot's `open_id` and display name on startup. You only need to set these manually when auto-detection cannot reach the Feishu API, or when your app uses tenant-scoped user IDs:
Expand Down Expand Up @@ -490,6 +500,7 @@ Inbound messages are deduplicated using message IDs with a 24-hour TTL. The dedu
| `FEISHU_ALLOWED_USERS` | — | _(empty)_ | Comma-separated open_id list for user allowlist |
| `FEISHU_ALLOW_BOTS` | — | `none` | Accept messages from other bots: `none`, `mentions`, or `all` |
| `FEISHU_REQUIRE_MENTION` | — | `true` | Whether group messages must @mention the bot |
| `FEISHU_REPLY_IN_THREAD` | — | `true` | Whether topic/thread messages should receive topic/thread replies |
| `FEISHU_HOME_CHANNEL` | — | — | Chat ID for cron/notification output |
| `FEISHU_ENCRYPT_KEY` | — | _(empty)_ | Encrypt key for webhook signature verification |
| `FEISHU_VERIFICATION_TOKEN` | — | _(empty)_ | Verification token for webhook payload auth |
Expand Down