mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
Add a QR-based onboarding flow to `hermes gateway setup` for Feishu / Lark. Users scan a QR code with their phone and the platform creates a fully configured bot application automatically — matching the existing WeChat QR login experience. Setup flow: - Choose between QR scan-to-create (new app) or manual credential input (existing app) - Connection mode selection (WebSocket / Webhook) - DM security policy (pairing / open / allowlist / disabled) - Group chat policy (open with @mention / disabled) Implementation: - Onboard functions (init/begin/poll/QR/probe) in gateway/platforms/feishu.py - _setup_feishu() in hermes_cli/gateway.py with manual fallback - probe_bot uses lark_oapi SDK when available, raw HTTP fallback otherwise - qr_register() catches expected errors (network/protocol), propagates bugs - Poll handles HTTP 4xx JSON responses and feishu/lark domain auto-detection Tests: - 25 tests for onboard module (registration, QR, probe, contract, negative paths) - 16 tests for setup flow (credentials, connection mode, DM policy, group policy, adapter integration verifying env vars produce valid FeishuAdapterSettings) Change-Id: I720591ee84755f32dda95fbac4b26dc82cbcf823
284 lines
12 KiB
Python
284 lines
12 KiB
Python
"""Tests for _setup_feishu() in hermes_cli/gateway.py.
|
|
|
|
Verifies that the interactive setup writes env vars that correctly drive the
|
|
Feishu adapter: credentials, connection mode, DM policy, and group policy.
|
|
"""
|
|
|
|
import os
|
|
from unittest.mock import patch
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _run_setup_feishu(
|
|
*,
|
|
qr_result=None,
|
|
prompt_yes_no_responses=None,
|
|
prompt_choice_responses=None,
|
|
prompt_responses=None,
|
|
existing_env=None,
|
|
):
|
|
"""Run _setup_feishu() with mocked I/O and return the env vars that were saved.
|
|
|
|
Returns a dict of {env_var_name: value} for all save_env_value calls.
|
|
"""
|
|
existing_env = existing_env or {}
|
|
prompt_yes_no_responses = list(prompt_yes_no_responses or [True])
|
|
# QR path: method(0), dm(0), group(0) — 3 choices (no connection mode)
|
|
# Manual path: method(1), domain(0), connection(0), dm(0), group(0) — 5 choices
|
|
prompt_choice_responses = list(prompt_choice_responses or [0, 0, 0])
|
|
prompt_responses = list(prompt_responses or [""])
|
|
|
|
saved_env = {}
|
|
|
|
def mock_save(name, value):
|
|
saved_env[name] = value
|
|
|
|
def mock_get(name):
|
|
return existing_env.get(name, "")
|
|
|
|
with patch("hermes_cli.gateway.save_env_value", side_effect=mock_save), \
|
|
patch("hermes_cli.gateway.get_env_value", side_effect=mock_get), \
|
|
patch("hermes_cli.gateway.prompt_yes_no", side_effect=prompt_yes_no_responses), \
|
|
patch("hermes_cli.gateway.prompt_choice", side_effect=prompt_choice_responses), \
|
|
patch("hermes_cli.gateway.prompt", side_effect=prompt_responses), \
|
|
patch("hermes_cli.gateway.print_info"), \
|
|
patch("hermes_cli.gateway.print_success"), \
|
|
patch("hermes_cli.gateway.print_warning"), \
|
|
patch("hermes_cli.gateway.print_error"), \
|
|
patch("hermes_cli.gateway.color", side_effect=lambda t, c: t), \
|
|
patch("gateway.platforms.feishu.qr_register", return_value=qr_result):
|
|
|
|
from hermes_cli.gateway import _setup_feishu
|
|
_setup_feishu()
|
|
|
|
return saved_env
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# QR scan-to-create path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestSetupFeishuQrPath:
|
|
"""Tests for the QR scan-to-create happy path."""
|
|
|
|
def test_qr_success_saves_core_credentials(self):
|
|
env = _run_setup_feishu(
|
|
qr_result={
|
|
"app_id": "cli_test",
|
|
"app_secret": "secret_test",
|
|
"domain": "feishu",
|
|
"open_id": "ou_owner",
|
|
"bot_name": "TestBot",
|
|
"bot_open_id": "ou_bot",
|
|
},
|
|
prompt_yes_no_responses=[True], # Start QR
|
|
prompt_choice_responses=[0, 0, 0], # method=QR, dm=pairing, group=open
|
|
prompt_responses=[""], # home channel: skip
|
|
)
|
|
assert env["FEISHU_APP_ID"] == "cli_test"
|
|
assert env["FEISHU_APP_SECRET"] == "secret_test"
|
|
assert env["FEISHU_DOMAIN"] == "feishu"
|
|
|
|
def test_qr_success_does_not_persist_bot_identity(self):
|
|
"""Bot identity is discovered at runtime by _hydrate_bot_identity — not persisted
|
|
in env, so it stays fresh if the user renames the bot later."""
|
|
env = _run_setup_feishu(
|
|
qr_result={
|
|
"app_id": "cli_test",
|
|
"app_secret": "secret_test",
|
|
"domain": "feishu",
|
|
"open_id": "ou_owner",
|
|
"bot_name": "TestBot",
|
|
"bot_open_id": "ou_bot",
|
|
},
|
|
prompt_yes_no_responses=[True],
|
|
prompt_choice_responses=[0, 0, 0],
|
|
prompt_responses=[""],
|
|
)
|
|
assert "FEISHU_BOT_OPEN_ID" not in env
|
|
assert "FEISHU_BOT_NAME" not in env
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Connection mode
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestSetupFeishuConnectionMode:
|
|
"""Connection mode: QR always websocket, manual path lets user choose."""
|
|
|
|
def test_qr_path_defaults_to_websocket(self):
|
|
env = _run_setup_feishu(
|
|
qr_result={
|
|
"app_id": "cli_test", "app_secret": "s", "domain": "feishu",
|
|
"open_id": None, "bot_name": None, "bot_open_id": None,
|
|
},
|
|
prompt_choice_responses=[0, 0, 0], # method=QR, dm=pairing, group=open
|
|
prompt_responses=[""],
|
|
)
|
|
assert env["FEISHU_CONNECTION_MODE"] == "websocket"
|
|
|
|
@patch("gateway.platforms.feishu.probe_bot", return_value=None)
|
|
def test_manual_path_websocket(self, _mock_probe):
|
|
env = _run_setup_feishu(
|
|
qr_result=None,
|
|
prompt_choice_responses=[1, 0, 0, 0, 0], # method=manual, domain=feishu, connection=ws, dm=pairing, group=open
|
|
prompt_responses=["cli_manual", "secret_manual", ""], # app_id, app_secret, home_channel
|
|
)
|
|
assert env["FEISHU_CONNECTION_MODE"] == "websocket"
|
|
|
|
@patch("gateway.platforms.feishu.probe_bot", return_value=None)
|
|
def test_manual_path_webhook(self, _mock_probe):
|
|
env = _run_setup_feishu(
|
|
qr_result=None,
|
|
prompt_choice_responses=[1, 0, 1, 0, 0], # method=manual, domain=feishu, connection=webhook, dm=pairing, group=open
|
|
prompt_responses=["cli_manual", "secret_manual", ""], # app_id, app_secret, home_channel
|
|
)
|
|
assert env["FEISHU_CONNECTION_MODE"] == "webhook"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DM security policy
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestSetupFeishuDmPolicy:
|
|
"""DM policy must use platform-scoped FEISHU_ALLOW_ALL_USERS, not the global flag."""
|
|
|
|
def _run_with_dm_choice(self, dm_choice_idx, prompt_responses=None):
|
|
return _run_setup_feishu(
|
|
qr_result={
|
|
"app_id": "cli_test", "app_secret": "s", "domain": "feishu",
|
|
"open_id": "ou_owner", "bot_name": None, "bot_open_id": None,
|
|
},
|
|
prompt_yes_no_responses=[True],
|
|
prompt_choice_responses=[0, dm_choice_idx, 0], # method=QR, dm=<choice>, group=open
|
|
prompt_responses=prompt_responses or [""],
|
|
)
|
|
|
|
def test_pairing_sets_feishu_allow_all_false(self):
|
|
env = self._run_with_dm_choice(0)
|
|
assert env["FEISHU_ALLOW_ALL_USERS"] == "false"
|
|
assert env["FEISHU_ALLOWED_USERS"] == ""
|
|
assert "GATEWAY_ALLOW_ALL_USERS" not in env
|
|
|
|
def test_allow_all_sets_feishu_allow_all_true(self):
|
|
env = self._run_with_dm_choice(1)
|
|
assert env["FEISHU_ALLOW_ALL_USERS"] == "true"
|
|
assert env["FEISHU_ALLOWED_USERS"] == ""
|
|
assert "GATEWAY_ALLOW_ALL_USERS" not in env
|
|
|
|
def test_allowlist_sets_feishu_allow_all_false_with_list(self):
|
|
env = self._run_with_dm_choice(2, prompt_responses=["ou_user1,ou_user2", ""])
|
|
assert env["FEISHU_ALLOW_ALL_USERS"] == "false"
|
|
assert env["FEISHU_ALLOWED_USERS"] == "ou_user1,ou_user2"
|
|
assert "GATEWAY_ALLOW_ALL_USERS" not in env
|
|
|
|
def test_allowlist_prepopulates_with_scan_owner_open_id(self):
|
|
"""When open_id is available from QR scan, it should be the default allowlist value."""
|
|
# We return the owner's open_id from prompt (+ empty home channel).
|
|
env = self._run_with_dm_choice(2, prompt_responses=["ou_owner", ""])
|
|
assert env["FEISHU_ALLOWED_USERS"] == "ou_owner"
|
|
|
|
def test_disabled_sets_feishu_allow_all_false(self):
|
|
env = self._run_with_dm_choice(3)
|
|
assert env["FEISHU_ALLOW_ALL_USERS"] == "false"
|
|
assert env["FEISHU_ALLOWED_USERS"] == ""
|
|
assert "GATEWAY_ALLOW_ALL_USERS" not in env
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Group policy
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestSetupFeishuGroupPolicy:
|
|
|
|
def test_open_with_mention(self):
|
|
env = _run_setup_feishu(
|
|
qr_result={
|
|
"app_id": "cli_test", "app_secret": "s", "domain": "feishu",
|
|
"open_id": None, "bot_name": None, "bot_open_id": None,
|
|
},
|
|
prompt_yes_no_responses=[True],
|
|
prompt_choice_responses=[0, 0, 0], # method=QR, dm=pairing, group=open
|
|
prompt_responses=[""],
|
|
)
|
|
assert env["FEISHU_GROUP_POLICY"] == "open"
|
|
|
|
def test_disabled(self):
|
|
env = _run_setup_feishu(
|
|
qr_result={
|
|
"app_id": "cli_test", "app_secret": "s", "domain": "feishu",
|
|
"open_id": None, "bot_name": None, "bot_open_id": None,
|
|
},
|
|
prompt_yes_no_responses=[True],
|
|
prompt_choice_responses=[0, 0, 1], # method=QR, dm=pairing, group=disabled
|
|
prompt_responses=[""],
|
|
)
|
|
assert env["FEISHU_GROUP_POLICY"] == "disabled"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Adapter integration: env vars → FeishuAdapterSettings
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestSetupFeishuAdapterIntegration:
|
|
"""Verify that env vars written by _setup_feishu() produce a valid adapter config.
|
|
|
|
This bridges the gap between 'setup wrote the right env vars' and
|
|
'the adapter will actually initialize correctly from those vars'.
|
|
"""
|
|
|
|
def _make_env_from_setup(self, dm_idx=0, group_idx=0):
|
|
"""Run _setup_feishu via QR path and return the env vars it would write."""
|
|
return _run_setup_feishu(
|
|
qr_result={
|
|
"app_id": "cli_test_app",
|
|
"app_secret": "test_secret_value",
|
|
"domain": "feishu",
|
|
"open_id": "ou_owner",
|
|
"bot_name": "IntegrationBot",
|
|
"bot_open_id": "ou_bot_integration",
|
|
},
|
|
prompt_yes_no_responses=[True],
|
|
prompt_choice_responses=[0, dm_idx, group_idx], # method=QR, dm, group
|
|
prompt_responses=[""],
|
|
)
|
|
|
|
@patch.dict(os.environ, {}, clear=True)
|
|
def test_qr_env_produces_valid_adapter_settings(self):
|
|
"""QR setup → adapter initializes with websocket mode."""
|
|
env = self._make_env_from_setup()
|
|
|
|
with patch.dict(os.environ, env, clear=True):
|
|
from gateway.config import PlatformConfig
|
|
from gateway.platforms.feishu import FeishuAdapter
|
|
adapter = FeishuAdapter(PlatformConfig())
|
|
assert adapter._app_id == "cli_test_app"
|
|
assert adapter._app_secret == "test_secret_value"
|
|
assert adapter._domain_name == "feishu"
|
|
assert adapter._connection_mode == "websocket"
|
|
|
|
@patch.dict(os.environ, {}, clear=True)
|
|
def test_open_dm_env_sets_correct_adapter_state(self):
|
|
"""Setup with 'allow all DMs' → adapter sees allow-all flag."""
|
|
env = self._make_env_from_setup(dm_idx=1)
|
|
|
|
with patch.dict(os.environ, env, clear=True):
|
|
from gateway.platforms.feishu import FeishuAdapter
|
|
from gateway.config import PlatformConfig
|
|
# Verify adapter initializes without error and env var is correct.
|
|
FeishuAdapter(PlatformConfig())
|
|
assert os.getenv("FEISHU_ALLOW_ALL_USERS") == "true"
|
|
|
|
@patch.dict(os.environ, {}, clear=True)
|
|
def test_group_open_env_sets_adapter_group_policy(self):
|
|
"""Setup with 'open groups' → adapter group_policy is 'open'."""
|
|
env = self._make_env_from_setup(group_idx=0)
|
|
|
|
with patch.dict(os.environ, env, clear=True):
|
|
from gateway.config import PlatformConfig
|
|
from gateway.platforms.feishu import FeishuAdapter
|
|
adapter = FeishuAdapter(PlatformConfig())
|
|
assert adapter._group_policy == "open"
|