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
26 changes: 25 additions & 1 deletion plugins/platforms/feishu/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,10 @@ class FeishuAdapterSettings:
group_rules: Dict[str, FeishuGroupRule] = field(default_factory=dict)
allow_bots: str = "none" # "none" | "mentions" | "all"
require_mention: bool = True
# When True, exec-approval cards prepend <at user_id="ou_xxx">@admin</at>
# for each configured admin so they get a Feishu mention notification.
# Mirrors discord.approval_mentions (commit e0176cbd4). Default off.
approval_mentions: bool = False


@dataclass
Expand Down Expand Up @@ -1600,6 +1604,9 @@ def _load_settings(extra: Dict[str, Any]) -> FeishuAdapterSettings:
require_mention=_to_boolean(
extra.get("require_mention", os.getenv("FEISHU_REQUIRE_MENTION", "true"))
),
approval_mentions=_to_boolean(
extra.get("approval_mentions", os.getenv("FEISHU_APPROVAL_MENTIONS", "false"))
),
)

def _apply_settings(self, settings: FeishuAdapterSettings) -> None:
Expand Down Expand Up @@ -1632,6 +1639,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._approval_mentions = settings.approval_mentions

def _build_event_handler(self) -> Any:
if EventDispatcherHandler is None:
Expand Down Expand Up @@ -2011,6 +2019,22 @@ def _btn(label: str, action_name: str, btn_type: str = "default") -> dict:
actions.append(_btn("✅ Always", "approve_always"))
actions.append(_btn("❌ Deny", "deny", "danger"))
scope_note = "\n\n**Smart DENY:** owner override applies to this one operation only." if smart_denied else ""

# Build <at> mention prefix for configured admins when opt-in.
# Mirrors discord.approval_mentions: default off, only mentions
# allowlist/admin entries that are valid Feishu open_ids (ou_xxx).
mention_prefix = ""
if self._approval_mentions:
admin_open_ids = sorted(
uid for uid in self._admins
if isinstance(uid, str) and uid.startswith("ou_")
)
if admin_open_ids:
mention_parts = [
f'<at user_id="{oid}"></at>' for oid in admin_open_ids
]
mention_prefix = " ".join(mention_parts) + "\n\n"

card = {
"config": {"wide_screen_mode": True},
"header": {
Expand All @@ -2020,7 +2044,7 @@ def _btn(label: str, action_name: str, btn_type: str = "default") -> dict:
"elements": [
{
"tag": "markdown",
"content": f"```\n{cmd_preview}\n```\n**Reason:** {description}{scope_note}",
"content": f"{mention_prefix}```\n{cmd_preview}\n```\n**Reason:** {description}{scope_note}",
},
{
"tag": "action",
Expand Down
114 changes: 114 additions & 0 deletions tests/gateway/test_feishu_approval_buttons.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,120 @@ async def test_multiple_approvals_get_unique_ids(self):
ids = list(adapter._approval_state.keys())
assert ids[0] != ids[1]

# -----------------------------------------------------------------------
# approval_mentions opt-in (mirrors discord.approval_mentions, commit e0176cbd4)
# -----------------------------------------------------------------------

@pytest.mark.asyncio
async def test_no_mention_when_disabled(self):
"""Default off: no <at> tag in card markdown."""
adapter = _make_adapter()
adapter._approval_mentions = False
adapter._admins = {"ou_admin1"}

mock_response = SimpleNamespace(
success=lambda: True,
data=SimpleNamespace(message_id="msg_m1"),
)
with patch.object(
adapter, "_feishu_send_with_retry", new_callable=AsyncMock,
return_value=mock_response,
) as mock_send:
await adapter.send_exec_approval(
chat_id="oc_1", command="rm -rf /tmp", session_key="s1"
)

card = json.loads(mock_send.call_args[1]["payload"])
content = card["elements"][0]["content"]
assert "<at" not in content

@pytest.mark.asyncio
async def test_mention_admins_when_enabled(self):
"""When enabled, <at user_id="ou_xxx"> prepended for each admin."""
adapter = _make_adapter()
adapter._approval_mentions = True
adapter._admins = {"ou_admin1", "ou_admin2", "not_an_open_id"}

mock_response = SimpleNamespace(
success=lambda: True,
data=SimpleNamespace(message_id="msg_m2"),
)
with patch.object(
adapter, "_feishu_send_with_retry", new_callable=AsyncMock,
return_value=mock_response,
) as mock_send:
await adapter.send_exec_approval(
chat_id="oc_1", command="rm -rf /tmp", session_key="s1"
)

card = json.loads(mock_send.call_args[1]["payload"])
content = card["elements"][0]["content"]
# Only ou_ prefixed IDs are mentioned; "not_an_open_id" is skipped.
assert '<at user_id="ou_admin1"></at>' in content
assert '<at user_id="ou_admin2"></at>' in content
assert "not_an_open_id" not in content
# Mention prefix comes before the code block.
assert content.index("<at") < content.index("```")

@pytest.mark.asyncio
async def test_mention_no_admins_is_noop(self):
"""When enabled but no admins configured, no mention is added."""
adapter = _make_adapter()
adapter._approval_mentions = True
adapter._admins = set()

mock_response = SimpleNamespace(
success=lambda: True,
data=SimpleNamespace(message_id="msg_m3"),
)
with patch.object(
adapter, "_feishu_send_with_retry", new_callable=AsyncMock,
return_value=mock_response,
) as mock_send:
await adapter.send_exec_approval(
chat_id="oc_1", command="ls", session_key="s1"
)

card = json.loads(mock_send.call_args[1]["payload"])
content = card["elements"][0]["content"]
assert "<at" not in content

# -----------------------------------------------------------------------
# Config propagation: _load_settings -> _apply_settings
# Mirrors Discord's test_yaml_config_bridges_approval_mentions_to_env.
# -----------------------------------------------------------------------

def test_yaml_config_propagates_approval_mentions(self):
"""YAML extra.approval_mentions=true reaches settings."""
settings = FeishuAdapter._load_settings({"approval_mentions": True, "admins": ["ou_admin1"]})
assert settings.approval_mentions is True

def test_yaml_config_defaults_off(self):
"""Without approval_mentions in YAML, settings.approval_mentions is False."""
settings = FeishuAdapter._load_settings({})
assert settings.approval_mentions is False

def test_apply_settings_propagates_to_instance(self):
"""_apply_settings writes approval_mentions onto the adapter instance."""
adapter = _make_adapter()
assert adapter._approval_mentions is False # default off

from plugins.platforms.feishu.adapter import FeishuAdapterSettings
settings = FeishuAdapterSettings(
app_id="", app_secret="", domain_name="feishu",
connection_mode="websocket", encrypt_key="",
verification_token="", group_policy="allowlist",
allowed_group_users=frozenset(),
bot_open_id="", bot_user_id="", bot_name="",
dedup_cache_size=32, text_batch_delay_seconds=0.5,
text_batch_split_delay_seconds=2.0, text_batch_max_messages=3,
text_batch_max_chars=1500, media_batch_delay_seconds=1.0,
webhook_host="0.0.0.0", webhook_port=9806, webhook_path="/webhook",
approval_mentions=True,
)
adapter._apply_settings(settings)
assert adapter._approval_mentions is True


# ===========================================================================
# send_update_prompt — interactive card with buttons
Expand Down
9 changes: 9 additions & 0 deletions website/docs/user-guide/messaging/feishu.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,13 @@ Card action events are dispatched with `MessageType.COMMAND`, so they flow throu

This is also how **command approval** works — when the agent needs to run a dangerous command, it sends an interactive card with Allow Once / Session / Always / Deny buttons. The user clicks a button, and the card action callback delivers the approval decision back to the agent.


### Approval Mention Notifications

When `approval_mentions` is enabled (default: off), dangerous-command approval cards prepend an `<at user_id="ou_xxx">` mention for each configured admin. This ensures admins receive a Feishu mention notification when the agent is blocked waiting for approval, instead of silently waiting.

Only admins with valid `ou_`-prefixed open_ids are mentioned; other entries are skipped.

### Required Feishu App Configuration

Interactive cards require **three** configuration steps in the Feishu Developer Console. Missing any of them causes error **200340** when users click card buttons.
Expand Down Expand Up @@ -499,6 +506,7 @@ platforms:
default_group_policy: "open" # Default for groups not in group_rules
admins: # Users who can manage bot settings
- "ou_admin_open_id"
approval_mentions: false # When true, approval cards @-mention admins
group_rules:
"oc_group_chat_id_1":
policy: "allowlist" # open | allowlist | blacklist | admin_only | disabled
Expand Down Expand Up @@ -547,6 +555,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_APPROVAL_MENTIONS` | — | `false` | When true, approval cards @-mention configured admins |
| `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
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,13 @@ Gateway 驱动的更新提示使用原生飞书 `Yes` / `No` 卡片,而非回

**命令审批**也通过此机制实现——当 Agent 需要执行危险命令时,会发送一张带有「允许一次 / 本次会话 / 始终允许 / 拒绝」按钮的交互式卡片。用户点击按钮后,卡片操作回调将审批决定传回 Agent。


### 审批提及通知

当 `approval_mentions` 开启时(默认关闭),危险命令审批卡片会在内容前添加 `<at user_id="ou_xxx">` 提及标记,为每个配置的 admin 发送飞书 @通知。这样当 agent 阻塞等待审批时,admin 会收到通知,而不是静默等待。

仅提及 `ou_` 开头的有效 open_id 的 admin;其他条目会被跳过。

### 飞书应用所需配置

交互式卡片需要在飞书开发者控制台完成**三项**配置。缺少任何一项,用户点击卡片按钮时将出现错误 **200340**。
Expand Down Expand Up @@ -442,6 +449,7 @@ platforms:
default_group_policy: "open" # 未在 group_rules 中列出的群的默认策略
admins: # 可管理机器人设置的用户
- "ou_admin_open_id"
approval_mentions: false # 开启后,审批卡片会 @提及 admins
group_rules:
"oc_group_chat_id_1":
policy: "allowlist" # open | allowlist | blacklist | admin_only | disabled
Expand Down Expand Up @@ -490,6 +498,7 @@ platforms:
| `FEISHU_ALLOWED_USERS` | — | _(空)_ | 用户白名单的逗号分隔 open_id 列表 |
| `FEISHU_ALLOW_BOTS` | — | `none` | 接受其他机器人消息:`none`、`mentions` 或 `all` |
| `FEISHU_REQUIRE_MENTION` | — | `true` | 群消息是否必须 @提及 机器人 |
| `FEISHU_APPROVAL_MENTIONS` | — | `false` | 开启后,审批卡片会 @提及已配置的 admins |
| `FEISHU_HOME_CHANNEL` | — | — | cron/通知输出的聊天 ID |
| `FEISHU_ENCRYPT_KEY` | — | _(空)_ | webhook 签名验证的加密密钥 |
| `FEISHU_VERIFICATION_TOKEN` | — | _(空)_ | webhook payload 认证的验证 token |
Expand Down