feat(webui): add guided setup flows

* feat(channels): add guided setup flows

* test(channels): preserve setup config values

* fix(channels): reflect saved setup state

* refactor(channels): simplify setup state metadata

* fix(channels): harden setup lifecycle

* refactor(channels): centralize setup contracts

* fix(channels): route setup actions through webui shim

* fix(channels): adapt settings for compact screens

* fix(models): preserve default preset display

* feat(models): add curated Codex catalog

* fix(webui): stop attached gateway on interrupt

* fix(webui): simplify apps catalog

* docs(webui): clarify apps and runtime features

* feat(settings): add guided capability setup

* fix(webui): harden setup and managed services

* test: keep managed runtime checks portable

* test: scope POSIX runtime coverage

* fix(webui): simplify file settings

* feat(files): bundle document reading

* fix(webui): harden setup request boundaries

* fix(webui): prevent channel setup status squeeze

* fix(settings): group provider compatibility aliases

* refactor(settings): remove redundant setup surfaces

* fix(webui): harden guided setup lifecycle

* fix(webui): preserve channel setup compatibility
This commit is contained in:
Xubin Ren
2026-07-13 13:11:46 +08:00
committed by GitHub
parent 791c7fd505
commit fe0717b385
92 changed files with 15058 additions and 1311 deletions
@@ -0,0 +1,114 @@
from __future__ import annotations
import asyncio
import pytest
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.channels.manager import ChannelManager
from nanobot.config.schema import Config
class _HotChannel(BaseChannel):
name = "hot"
display_name = "Hot"
def __init__(self, config, bus):
super().__init__(config, bus)
self.started = asyncio.Event()
self.stopped = asyncio.Event()
async def start(self):
self._running = True
self.started.set()
await self.stopped.wait()
async def stop(self):
self._running = False
self.stopped.set()
async def send(self, msg): # pragma: no cover - not used by this test
raise AssertionError("send should not be called")
@pytest.mark.asyncio
async def test_apply_channel_feature_action_starts_and_stops_channel(monkeypatch):
disabled = Config.model_validate({
"channels": {
"websocket": {"enabled": False},
"hot": {"enabled": False},
}
})
enabled = Config.model_validate({
"channels": {
"websocket": {"enabled": False},
"hot": {"enabled": True},
}
})
import nanobot.channels.registry as registry
def discover_enabled(enabled_names, **_kwargs):
return {"hot": _HotChannel} if "hot" in enabled_names else {}
configs = iter([enabled, disabled])
monkeypatch.setattr(registry, "discover_channel_names", lambda: ["hot"])
monkeypatch.setattr(registry, "discover_plugins", lambda enabled_names=None: {})
monkeypatch.setattr(registry, "discover_enabled", discover_enabled)
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: next(configs))
manager = ChannelManager(disabled, MessageBus())
manager._started = True
enabled_result = await manager.apply_channel_feature_action("enable", "hot")
assert enabled_result["handled"] is True
assert enabled_result["requires_restart"] is False
channel = manager.channels["hot"]
await asyncio.wait_for(channel.started.wait(), timeout=1)
assert channel.is_running is True
disabled_result = await manager.apply_channel_feature_action("disable", "hot")
assert disabled_result["handled"] is True
assert disabled_result["requires_restart"] is False
assert "hot" not in manager.channels
assert channel.is_running is False
@pytest.mark.asyncio
async def test_apply_channel_feature_action_keeps_running_channel_when_rebuild_fails(monkeypatch):
enabled = Config.model_validate({
"channels": {
"websocket": {"enabled": False},
"hot": {"enabled": True},
}
})
import nanobot.channels.registry as registry
monkeypatch.setattr(registry, "discover_channel_names", lambda: ["hot"])
monkeypatch.setattr(registry, "discover_plugins", lambda enabled_names=None: {})
monkeypatch.setattr(
registry,
"discover_enabled",
lambda enabled_names, **_kwargs: {"hot": _HotChannel},
)
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: enabled)
manager = ChannelManager(enabled, MessageBus())
old_channel = manager.channels["hot"]
old_channel._running = True
def fail_build(*_args, **_kwargs):
raise RuntimeError("invalid replacement config")
monkeypatch.setattr(manager, "_build_channel", fail_build)
result = await manager.apply_channel_feature_action("enable", "hot")
assert result["requires_restart"] is True
assert manager.channels["hot"] is old_channel
assert old_channel.is_running is True
assert not old_channel.stopped.is_set()
+486 -4
View File
@@ -72,6 +72,34 @@ class _FakeTelegram(BaseChannel):
pass
class _FakeFeishu(BaseChannel):
name = "feishu"
display_name = "Feishu"
@classmethod
def default_config(cls) -> dict:
return {
"instanceId": "default",
"name": "nanobot",
"enabled": False,
"appId": "",
"appSecret": "",
"domain": "feishu",
"groupPolicy": "mention",
"topicIsolation": True,
"allowFrom": [],
}
async def start(self) -> None:
pass
async def stop(self) -> None:
pass
async def send(self, msg: OutboundMessage) -> None:
pass
def _make_entry_point(name: str, cls: type):
"""Create a mock entry point that returns *cls* on load()."""
ep = SimpleNamespace(name=name, load=lambda _cls=cls: _cls)
@@ -136,6 +164,49 @@ def test_channels_config_extract_document_text_accepts_camel_alias():
assert cfg.extract_document_text is False
def test_channel_manager_expands_feishu_instances(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["feishu"])
monkeypatch.setattr(
"nanobot.channels.registry.discover_enabled",
lambda enabled, _names=None, warn_import_errors=True: {"feishu": _FakeFeishu}
if "feishu" in enabled
else {},
)
cfg = Config.model_validate({
"channels": {
"feishu": {
"instances": [
{
"id": "default",
"enabled": True,
"appId": "cli_default",
"appSecret": "secret",
},
{
"id": "product",
"enabled": True,
"appId": "cli_product",
"appSecret": "secret",
},
{
"id": "off",
"enabled": False,
"appId": "cli_off",
"appSecret": "secret",
},
]
}
}
})
manager = ChannelManager(cfg, MessageBus())
assert set(manager.channels) == {"feishu", "feishu.product"}
assert manager.channels["feishu"].name == "feishu"
assert manager.channels["feishu.product"].name == "feishu.product"
# ---------------------------------------------------------------------------
# discover_plugins
# ---------------------------------------------------------------------------
@@ -201,6 +272,17 @@ def test_discover_all_includes_builtins():
assert name in discover_channel_names()
def test_discover_channel_names_excludes_internal_helpers():
from nanobot.channels.registry import discover_channel_names
names = discover_channel_names()
assert "_feishu_ws" not in names
assert "_setup" not in names
assert "setup" not in names
assert "_feishu_instances" not in names
def test_discover_all_includes_external_plugin():
from nanobot.channels.registry import discover_all
@@ -891,6 +973,25 @@ def test_enable_optional_feature_skips_install_when_dependency_present(
assert not config_path.exists()
def test_enable_optional_feature_lazy_reader_does_not_require_restart(monkeypatch, tmp_path):
from nanobot.optional_features import enable_optional_feature
config_path = tmp_path / "config.json"
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: [])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr(
"nanobot.optional_features.optional_dependency_groups",
lambda: {"documents": ["pypdf>=5.0.0,<6.0.0"]},
)
monkeypatch.setattr("nanobot.optional_features.extra_installed", lambda _name, _deps: True)
payload = enable_optional_feature("documents", config_path=config_path)
assert payload["requires_restart"] is False
assert payload["last_action"]["message"] == "Feature 'documents' is included with nanobot"
def test_enable_optional_feature_reports_install_failure(monkeypatch, tmp_path):
from nanobot.optional_features import (
InstallResult,
@@ -1009,6 +1110,370 @@ def test_optional_features_payload_counts_enabled_channel_with_missing_dependenc
assert payload["enabled_count"] == 1
def test_optional_features_payload_reflects_saved_channel_config(monkeypatch):
from nanobot.optional_features import optional_features_payload
config = Config.model_validate({
"channels": {
"discord": {
"enabled": False,
"token": "discord-secret-token",
"allowChannels": ["123", "456"],
"groupPolicy": "open",
}
}
})
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["discord"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
payload = optional_features_payload(config=config)
discord = payload["features"][0]
assert discord["name"] == "discord"
assert discord["enabled"] is False
assert discord["configured"] is True
assert discord["config_values"] == {
"channels.discord.allowChannels": "123, 456",
"channels.discord.groupPolicy": "open",
}
assert discord["configured_fields"] == [
"channels.discord.token",
"channels.discord.allowChannels",
"channels.discord.groupPolicy",
]
assert "discord-secret-token" not in json.dumps(payload)
def test_optional_features_payload_marks_enabled_channel_missing_credentials(monkeypatch):
from nanobot.optional_features import optional_features_payload
config = Config.model_validate({"channels": {"discord": {"enabled": True}}})
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["discord"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
payload = optional_features_payload(config=config)
discord = payload["features"][0]
assert discord["enabled"] is True
assert discord["configured"] is False
assert "config_values" not in discord
assert "configured_fields" not in discord
def test_optional_features_payload_detects_saved_weixin_login_state(tmp_path, monkeypatch):
from nanobot.optional_features import optional_features_payload
state_dir = tmp_path / "weixin-state"
state_dir.mkdir()
(state_dir / "account.json").write_text(
json.dumps({"token": "saved-weixin-token"}),
encoding="utf-8",
)
config = Config.model_validate({
"channels": {
"weixin": {
"enabled": True,
"stateDir": str(state_dir),
}
}
})
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["weixin"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
payload = optional_features_payload(config=config)
weixin = payload["features"][0]
assert weixin["enabled"] is True
assert weixin["configured"] is True
def test_optional_features_payload_detects_legacy_default_weixin_state(tmp_path, monkeypatch):
from nanobot.config import loader
from nanobot.optional_features import optional_features_payload
config_path = tmp_path / "config.json"
loader.save_config(Config(), config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
state_dir = tmp_path / "weixin"
state_dir.mkdir()
(state_dir / "account.json").write_text(
json.dumps({"token": "legacy-weixin-token"}),
encoding="utf-8",
)
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["weixin"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
payload = optional_features_payload(config=Config())
weixin = payload["features"][0]
assert weixin["enabled"] is False
assert weixin["configured"] is True
@pytest.mark.parametrize("device_id", ["", "DEVICE-ID"])
def test_optional_features_payload_requires_matrix_device_id_for_token_login(
monkeypatch,
device_id,
):
from nanobot.optional_features import optional_features_payload
config = Config.model_validate({
"channels": {
"matrix": {
"enabled": False,
"homeserver": "https://matrix.example",
"userId": "@nanobot:matrix.example",
"accessToken": "saved-token",
"deviceId": device_id,
}
}
})
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["matrix"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
payload = optional_features_payload(config=config)
assert payload["features"][0]["configured"] is bool(device_id)
def test_optional_features_payload_marks_disabled_feishu_as_configured(monkeypatch):
from nanobot.optional_features import optional_features_payload
config = Config.model_validate({
"channels": {
"feishu": {
"enabled": False,
"appId": "cli_test",
"appSecret": "secret",
}
}
})
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["feishu"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
payload = optional_features_payload(config=config)
feishu = payload["features"][0]
assert feishu["name"] == "feishu"
assert feishu["enabled"] is False
assert feishu["configured"] is True
assert feishu["ready"] is False
assert payload["enabled_count"] == 0
def test_optional_features_payload_lists_feishu_instances(monkeypatch):
from nanobot.optional_features import optional_features_payload
config = Config.model_validate({
"channels": {
"feishu": {
"instances": [
{
"id": "default",
"name": "nanobot",
"displayName": "Voraflare Bot",
"avatarUrl": "https://example.com/bot.png",
"enabled": True,
"appId": "cli_default",
"appSecret": "secret",
},
{
"id": "product",
"name": "Product bot",
"enabled": False,
"appId": "cli_product",
"appSecret": "secret",
},
]
}
}
})
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["feishu"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
payload = optional_features_payload(config=config)
feishu = payload["features"][0]
assert feishu["name"] == "feishu"
assert feishu["enabled"] is True
assert feishu["configured"] is True
assert payload["enabled_count"] == 1
assert feishu["instances"] == [
{
"id": "default",
"name": "nanobot",
"display_name": "Voraflare Bot",
"avatar_url": "https://example.com/bot.png",
"domain": "feishu",
"enabled": True,
"configured": True,
"app_id": "cli_default",
"group_policy": "mention",
"allow_from": [],
},
{
"id": "product",
"name": "Product bot",
"display_name": "Product bot",
"avatar_url": "",
"domain": "feishu",
"enabled": False,
"configured": True,
"app_id": "cli_product",
"group_policy": "mention",
"allow_from": [],
},
]
def test_optional_features_payload_backfills_saved_feishu_identity(monkeypatch, tmp_path):
from nanobot.channels import feishu as feishu_module
from nanobot.config import loader
from nanobot.optional_features import optional_features_payload
config_path = tmp_path / "config.json"
save_config(
Config.model_validate({
"channels": {
"feishu": {
"instances": [{
"id": "default",
"name": "nanobot",
"enabled": True,
"appId": "cli_default",
"appSecret": "secret",
}]
}
}
}),
config_path,
)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["feishu"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
monkeypatch.setattr(feishu_module, "FEISHU_AVAILABLE", True)
monkeypatch.setattr(
feishu_module,
"fetch_feishu_app_identity",
lambda app_id, app_secret, domain: {
"displayName": "Xubin Ren的智能助手",
"avatarUrl": "https://example.com/assistant.png",
"identityFetchedAt": "2026-07-06T00:00:00Z",
},
)
payload = optional_features_payload()
instance = payload["features"][0]["instances"][0]
assert instance["display_name"] == "Xubin Ren的智能助手"
assert instance["avatar_url"] == "https://example.com/assistant.png"
data = json.loads(config_path.read_text(encoding="utf-8"))
saved = data["channels"]["feishu"]["instances"][0]
assert saved["displayName"] == "Xubin Ren的智能助手"
assert saved["avatarUrl"] == "https://example.com/assistant.png"
assert saved["identityFetchedAt"] == "2026-07-06T00:00:00Z"
def test_optional_features_payload_records_feishu_identity_attempt_on_empty_result(
monkeypatch,
tmp_path,
):
from nanobot.channels import feishu as feishu_module
from nanobot.config import loader
from nanobot.optional_features import optional_features_payload
config_path = tmp_path / "config.json"
save_config(
Config.model_validate({
"channels": {
"feishu": {
"instances": [{
"id": "default",
"name": "nanobot",
"enabled": True,
"appId": "cli_default",
"appSecret": "secret",
}]
}
}
}),
config_path,
)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["feishu"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
monkeypatch.setattr(feishu_module, "FEISHU_AVAILABLE", True)
monkeypatch.setattr(feishu_module, "fetch_feishu_app_identity", lambda *args: {})
monkeypatch.setattr(feishu_module, "_identity_timestamp", lambda: "2026-07-06T00:00:00Z")
payload = optional_features_payload()
instance = payload["features"][0]["instances"][0]
assert instance["display_name"] == "nanobot"
assert instance["avatar_url"] == ""
data = json.loads(config_path.read_text(encoding="utf-8"))
saved = data["channels"]["feishu"]["instances"][0]
assert saved["identityFetchedAt"] == "2026-07-06T00:00:00Z"
assert "displayName" not in saved
assert "avatarUrl" not in saved
def test_optional_features_payload_preserves_legacy_flat_feishu_config(monkeypatch, tmp_path):
from nanobot.channels import feishu as feishu_module
from nanobot.config import loader
from nanobot.optional_features import optional_features_payload
config_path = tmp_path / "config.json"
save_config(
Config.model_validate({
"channels": {
"feishu": {
"enabled": True,
"appId": "cli_legacy",
"appSecret": "legacy-secret",
"groupPolicy": "mention",
}
}
}),
config_path,
)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["feishu"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
monkeypatch.setattr(feishu_module, "FEISHU_AVAILABLE", True)
monkeypatch.setattr(
feishu_module,
"fetch_feishu_app_identity",
lambda *_args: {
"displayName": "Legacy assistant",
"avatarUrl": "https://example.com/legacy.png",
"identityFetchedAt": "2026-07-06T00:00:00Z",
},
)
payload = optional_features_payload()
assert payload["features"][0]["instances"][0]["display_name"] == "Legacy assistant"
saved = json.loads(config_path.read_text(encoding="utf-8"))["channels"]["feishu"]
assert saved["appId"] == "cli_legacy"
assert saved["appSecret"] == "legacy-secret"
assert saved["displayName"] == "Legacy assistant"
assert saved["avatarUrl"] == "https://example.com/legacy.png"
assert "instances" not in saved
def test_enable_bootstraps_pip_with_ensurepip(monkeypatch):
from nanobot import optional_features
@@ -1066,6 +1531,8 @@ def test_run_install_command_returns_failure_on_timeout(monkeypatch):
def test_optional_dependency_metadata_for_enable():
from nanobot import optional_features
data = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
deps = data["project"]["optional-dependencies"]
required = data["project"]["dependencies"]
@@ -1077,25 +1544,32 @@ def test_optional_dependency_metadata_for_enable():
"dingtalk-stream",
"lark-oapi",
"msgpack",
"openpyxl",
"pypdf",
"python-telegram-bot",
"python-docx",
"python-pptx",
"python-socketio",
"qq-botpy",
"slack-sdk",
"slackify-markdown",
):
assert not any(dep.startswith(dep_name) for dep in required)
for dependency in (
"defusedxml>=0.7.1,<1.0.0",
"pypdf>=5.0.0,<6.0.0",
"python-docx>=1.1.0,<2.0.0",
"openpyxl>=3.1.0,<4.0.0",
"python-pptx>=1.0.0,<2.0.0",
):
assert dependency in required
assert deps["dingtalk"] == ["dingtalk-stream>=0.24.0,<1.0.0"]
assert deps["documents"] == [
"defusedxml>=0.7.1,<1.0.0",
"pypdf>=5.0.0,<6.0.0",
"python-docx>=1.1.0,<2.0.0",
"openpyxl>=3.1.0,<4.0.0",
"python-pptx>=1.0.0,<2.0.0",
]
assert deps["pdf"] == ["pypdf>=5.0.0,<6.0.0"]
assert deps["feishu"] == ["lark-oapi>=1.5.0,<2.0.0"]
assert deps["langfuse"] == ["langfuse>=3.0.0,<4.0.0"]
assert deps["mochat"] == [
"python-socketio>=5.16.0,<6.0.0",
"msgpack>=1.1.0,<2.0.0",
@@ -1107,6 +1581,10 @@ def test_optional_dependency_metadata_for_enable():
"slack-sdk>=3.39.0,<4.0.0",
"slackify-markdown>=0.2.0,<1.0.0",
]
visible = optional_features.optional_dependency_groups()
assert "documents" not in visible
assert "pdf" not in visible
assert any(dep.startswith("python-telegram-bot") for dep in deps["telegram"])
assert any(
dep.startswith("matrix-nio>=0.25.2") and "sys_platform == 'win32'" in dep
@@ -1986,6 +2464,7 @@ async def test_stop_all_cancels_dispatcher_and_stops_channels():
ch = _StartableChannel(fake_config, mgr.bus)
mgr.channels = {"startable": ch}
mgr._channel_tasks = {}
# Create a real cancelled task
async def dummy_task():
@@ -2063,6 +2542,7 @@ async def test_stop_all_handles_channel_exception():
mgr.config = fake_config
mgr.bus = MessageBus()
mgr.channels = {"stopfailing": _StopFailingChannel(fake_config, mgr.bus)}
mgr._channel_tasks = {}
mgr._dispatch_task = None
# Should not raise even if channel.stop() raises
@@ -2101,6 +2581,7 @@ async def test_stop_all_handles_channel_stop_cancelled_task():
"stopcancelled": _StopCancelledChannel(fake_config, mgr.bus),
"next": next_channel,
}
mgr._channel_tasks = {}
mgr._dispatch_task = None
await mgr.stop_all()
@@ -2142,6 +2623,7 @@ async def test_start_all_creates_dispatch_task():
ch = _StartableChannel(fake_config, mgr.bus)
mgr.channels = {"startable": ch}
mgr._channel_tasks = {}
mgr._dispatch_task = None
# Cancel immediately after start to avoid running forever
+54
View File
@@ -0,0 +1,54 @@
from nanobot.channels._setup import channel_setup_spec
def test_channel_setup_spec_derives_route_and_secret_metadata() -> None:
slack = channel_setup_spec("slack")
assert slack is not None
assert slack.secrets == {"appToken", "botToken"}
assert slack.route_field_types == {
"appToken": "secret",
"botToken": "secret",
"groupPolicy": ("enum", {"mention", "open", "allowlist"}),
}
assert slack.simple_required_fields == ("appToken", "botToken")
def test_matrix_setup_requires_one_complete_login_method() -> None:
matrix = channel_setup_spec("matrix")
assert matrix is not None
base = {
"homeserver": "https://matrix.example",
"userId": "@nanobot:matrix.example",
}
assert matrix.is_configured(base | {"password": "secret"})
assert matrix.is_configured(base | {"accessToken": "token", "deviceId": "DEVICE"})
assert not matrix.is_configured(base | {"accessToken": "token"})
def test_channel_setup_spec_separates_writable_and_snapshot_fields() -> None:
matrix = channel_setup_spec("matrix")
discord = channel_setup_spec("discord")
assert matrix is not None
assert discord is not None
assert "allowFrom" not in matrix.route_field_types
assert "allowFrom" in matrix.snapshot_fields
assert "allowFrom" in discord.route_field_types
assert "allowFrom" not in discord.snapshot_fields
def test_webui_forms_have_writable_mattermost_and_whatsapp_contracts() -> None:
mattermost = channel_setup_spec("mattermost")
whatsapp = channel_setup_spec("whatsapp")
assert mattermost is not None
assert whatsapp is not None
assert mattermost.route_field_types["serverUrl"] == "string"
assert mattermost.route_field_types["token"] == "secret"
assert whatsapp.route_field_types["allowFrom"] == "list"
assert whatsapp.route_field_types["groupPolicy"] == (
"enum",
{"mention", "open"},
)
+319 -5
View File
@@ -7,6 +7,11 @@ from nanobot.channels import feishu as feishu_module
from nanobot.channels.feishu import FeishuChannel
from nanobot.config import loader
from nanobot.config.schema import Config
from nanobot.pairing import store as pairing_store
def _default_feishu_instance(data: dict) -> dict:
return data["channels"]["feishu"]["instances"][0]
@pytest.mark.asyncio
@@ -25,15 +30,30 @@ async def test_feishu_login_writes_credentials_to_active_config(monkeypatch, tmp
"domain": "lark",
},
)
monkeypatch.setattr(
feishu_module,
"fetch_feishu_app_identity",
lambda app_id, app_secret, domain: {
"displayName": "Voraflare Bot",
"avatarUrl": "https://example.com/avatar.png",
"identityFetchedAt": "2026-07-06T00:00:00Z",
},
)
channel = FeishuChannel({"enabled": False, "domain": "feishu"}, None)
assert await channel.login() is True
data = json.loads(config_path.read_text(encoding="utf-8"))
assert data["channels"]["feishu"]["appId"] == "cli_app"
assert data["channels"]["feishu"]["appSecret"] == "secret"
assert data["channels"]["feishu"]["domain"] == "lark"
assert data["channels"]["feishu"]["enabled"] is True
instance = _default_feishu_instance(data)
assert instance["id"] == "default"
assert instance["appId"] == "cli_app"
assert instance["appSecret"] == "secret"
assert instance["domain"] == "lark"
assert instance["identityKey"] == "lark:cli_app"
assert instance["enabled"] is True
assert instance["displayName"] == "Voraflare Bot"
assert instance["avatarUrl"] == "https://example.com/avatar.png"
assert instance["identityFetchedAt"] == "2026-07-06T00:00:00Z"
def test_begin_registration_requires_login_url(monkeypatch):
@@ -70,6 +90,298 @@ def test_qr_register_returns_none_on_network_error(monkeypatch):
assert feishu_module.qr_register() is None
def test_save_registration_result_keeps_credentials_when_identity_fetch_fails(monkeypatch, tmp_path):
config_path = tmp_path / "config.json"
loader.save_config(Config(), config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
def fail_identity(_app_id, _app_secret, _domain):
raise RuntimeError("metadata unavailable")
monkeypatch.setattr(feishu_module, "fetch_feishu_app_identity", fail_identity)
feishu_module.save_registration_result({
"app_id": "cli_app",
"app_secret": "secret",
"domain": "feishu",
})
data = json.loads(config_path.read_text(encoding="utf-8"))
instance = _default_feishu_instance(data)
assert instance["appId"] == "cli_app"
assert instance["appSecret"] == "secret"
assert instance["identityKey"] == "feishu:cli_app"
assert "displayName" not in instance
assert "avatarUrl" not in instance
def test_save_registration_result_resets_access_when_instance_app_changes(
monkeypatch,
tmp_path,
):
config_path = tmp_path / "config.json"
pairing_path = tmp_path / "pairing.json"
config = Config()
config.channels.feishu = {
"instances": [
{
"id": "assistant-test",
"instanceId": "assistant-test",
"name": "old assistant",
"enabled": True,
"appId": "cli_old",
"appSecret": "old-secret",
"identityKey": "feishu:cli_old",
"allowFrom": ["old-open-id"],
"allow_from": ["old-snake-open-id"],
}
]
}
loader.save_config(config, config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr(pairing_store, "_store_path", lambda: pairing_path)
monkeypatch.setattr(feishu_module, "fetch_feishu_app_identity", lambda *_args: {})
approved_code = pairing_store.generate_code("feishu.assistant-test", "paired-user")
pairing_store.approve_code(approved_code)
pending_code = pairing_store.generate_code("feishu.assistant-test", "pending-user")
feishu_module.save_registration_result(
{
"app_id": "cli_new",
"app_secret": "new-secret",
"domain": "feishu",
},
instance_id="assistant-test",
name="new assistant",
)
data = json.loads(config_path.read_text(encoding="utf-8"))
instance = data["channels"]["feishu"]["instances"][0]
assert instance["appId"] == "cli_new"
assert instance["appSecret"] == "new-secret"
assert instance["identityKey"] == "feishu:cli_new"
assert instance["allowFrom"] == []
assert instance["allow_from"] == []
assert pairing_store.is_approved("feishu.assistant-test", "paired-user") is False
assert pairing_store.approve_code(pending_code) is None
def test_save_registration_result_keeps_access_when_only_secret_rotates(
monkeypatch,
tmp_path,
):
config_path = tmp_path / "config.json"
pairing_path = tmp_path / "pairing.json"
config = Config()
config.channels.feishu = {
"instances": [
{
"id": "assistant-test",
"instanceId": "assistant-test",
"name": "same assistant",
"enabled": True,
"appId": "cli_same",
"appSecret": "old-secret",
"domain": "feishu",
"identityKey": "feishu:cli_same",
"allowFrom": ["old-open-id"],
}
]
}
loader.save_config(config, config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr(pairing_store, "_store_path", lambda: pairing_path)
monkeypatch.setattr(feishu_module, "fetch_feishu_app_identity", lambda *_args: {})
approved_code = pairing_store.generate_code("feishu.assistant-test", "paired-user")
pairing_store.approve_code(approved_code)
pending_code = pairing_store.generate_code("feishu.assistant-test", "pending-user")
feishu_module.save_registration_result(
{
"app_id": "cli_same",
"app_secret": "new-secret",
"domain": "feishu",
},
instance_id="assistant-test",
name="same assistant",
)
data = json.loads(config_path.read_text(encoding="utf-8"))
instance = data["channels"]["feishu"]["instances"][0]
assert instance["appSecret"] == "new-secret"
assert instance["identityKey"] == "feishu:cli_same"
assert instance["allowFrom"] == ["old-open-id"]
assert pairing_store.is_approved("feishu.assistant-test", "paired-user") is True
assert pairing_store.approve_code(pending_code) == (
"feishu.assistant-test",
"pending-user",
)
def test_save_registration_result_resets_access_when_domain_changes(
monkeypatch,
tmp_path,
):
config_path = tmp_path / "config.json"
pairing_path = tmp_path / "pairing.json"
config = Config()
config.channels.feishu = {
"instances": [
{
"id": "assistant-test",
"instanceId": "assistant-test",
"name": "lark assistant",
"enabled": True,
"appId": "cli_same",
"appSecret": "old-secret",
"domain": "lark",
"identityKey": "lark:cli_same",
"allowFrom": ["old-open-id"],
}
]
}
loader.save_config(config, config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr(pairing_store, "_store_path", lambda: pairing_path)
monkeypatch.setattr(feishu_module, "fetch_feishu_app_identity", lambda *_args: {})
approved_code = pairing_store.generate_code("feishu.assistant-test", "paired-user")
pairing_store.approve_code(approved_code)
feishu_module.save_registration_result(
{
"app_id": "cli_same",
"app_secret": "new-secret",
"domain": "feishu",
},
instance_id="assistant-test",
name="feishu assistant",
)
data = json.loads(config_path.read_text(encoding="utf-8"))
instance = data["channels"]["feishu"]["instances"][0]
assert instance["domain"] == "feishu"
assert instance["identityKey"] == "feishu:cli_same"
assert instance["allowFrom"] == []
assert pairing_store.is_approved("feishu.assistant-test", "paired-user") is False
def test_sync_saved_identity_boundary_resets_access_after_manual_app_change(
monkeypatch,
tmp_path,
):
config_path = tmp_path / "config.json"
pairing_path = tmp_path / "pairing.json"
config = Config()
config.channels.feishu = {
"instances": [
{
"id": "assistant-test",
"instanceId": "assistant-test",
"name": "manual assistant",
"enabled": True,
"appId": "cli_new",
"appSecret": "secret",
"domain": "feishu",
"identityKey": "feishu:cli_old",
"allowFrom": ["old-open-id"],
"allow_from": ["old-snake-open-id"],
}
]
}
loader.save_config(config, config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr(pairing_store, "_store_path", lambda: pairing_path)
approved_code = pairing_store.generate_code("feishu.assistant-test", "paired-user")
pairing_store.approve_code(approved_code)
pending_code = pairing_store.generate_code("feishu.assistant-test", "pending-user")
assert feishu_module.sync_saved_feishu_identity_boundary(
instance_id="assistant-test",
app_id="cli_new",
domain="feishu",
) is True
data = json.loads(config_path.read_text(encoding="utf-8"))
instance = data["channels"]["feishu"]["instances"][0]
assert instance["identityKey"] == "feishu:cli_new"
assert instance["allowFrom"] == []
assert instance["allow_from"] == []
assert pairing_store.is_approved("feishu.assistant-test", "paired-user") is False
assert pairing_store.approve_code(pending_code) is None
def test_sync_saved_identity_boundary_backfills_marker_without_resetting_access(
monkeypatch,
tmp_path,
):
config_path = tmp_path / "config.json"
pairing_path = tmp_path / "pairing.json"
config = Config()
config.channels.feishu = {
"instances": [
{
"id": "assistant-test",
"instanceId": "assistant-test",
"name": "existing assistant",
"enabled": True,
"appId": "cli_existing",
"appSecret": "secret",
"domain": "feishu",
"allowFrom": ["old-open-id"],
}
]
}
loader.save_config(config, config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr(pairing_store, "_store_path", lambda: pairing_path)
approved_code = pairing_store.generate_code("feishu.assistant-test", "paired-user")
pairing_store.approve_code(approved_code)
assert feishu_module.sync_saved_feishu_identity_boundary(
instance_id="assistant-test",
app_id="cli_existing",
domain="feishu",
) is False
data = json.loads(config_path.read_text(encoding="utf-8"))
instance = data["channels"]["feishu"]["instances"][0]
assert instance["identityKey"] == "feishu:cli_existing"
assert instance["allowFrom"] == ["old-open-id"]
assert pairing_store.is_approved("feishu.assistant-test", "paired-user") is True
def test_sync_saved_identity_boundary_preserves_legacy_flat_config(monkeypatch, tmp_path):
config_path = tmp_path / "config.json"
config = Config()
config.channels.feishu = {
"enabled": True,
"appId": "cli_existing",
"appSecret": "secret",
"domain": "feishu",
"allowFrom": ["old-open-id"],
}
loader.save_config(config, config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
assert feishu_module.sync_saved_feishu_identity_boundary(
instance_id="default",
app_id="cli_existing",
domain="feishu",
) is False
saved = json.loads(config_path.read_text(encoding="utf-8"))["channels"]["feishu"]
assert saved["appId"] == "cli_existing"
assert saved["appSecret"] == "secret"
assert saved["identityKey"] == "feishu:cli_existing"
assert saved["allowFrom"] == ["old-open-id"]
assert "instances" not in saved
@pytest.mark.asyncio
async def test_feishu_login_creates_missing_active_config(monkeypatch, tmp_path):
missing_config = tmp_path / "missing.json"
@@ -89,4 +401,6 @@ async def test_feishu_login_creates_missing_active_config(monkeypatch, tmp_path)
assert await channel.login() is True
assert missing_config.exists()
data = json.loads(missing_config.read_text(encoding="utf-8"))
assert data["channels"]["feishu"]["appId"] == "cli_app"
instance = _default_feishu_instance(data)
assert instance["id"] == "default"
assert instance["appId"] == "cli_app"
+118
View File
@@ -44,6 +44,7 @@ def _make_feishu_channel(
channel._client = MagicMock()
# _loop is only used by the WebSocket thread bridge; not needed for unit tests
channel._loop = None
channel._running = True
return channel
@@ -209,6 +210,35 @@ def test_reply_message_sync_returns_false_on_api_error() -> None:
assert ok is False
def test_reply_message_sync_falls_back_to_text_for_interactive_error() -> None:
channel = _make_feishu_channel()
interactive_resp = MagicMock()
interactive_resp.success.return_value = False
interactive_resp.code = 230099
interactive_resp.msg = "cardid is invalid"
interactive_resp.get_log_id.return_value = "log_x"
text_resp = MagicMock()
text_resp.success.return_value = True
channel._client.im.v1.message.reply.side_effect = [interactive_resp, text_resp]
ok = channel._reply_message_sync(
"om_parent",
"interactive",
json.dumps(
{
"config": {"wide_screen_mode": True},
"elements": [{"tag": "markdown", "content": "fallback body"}],
},
ensure_ascii=False,
),
)
assert ok is True
assert channel._client.im.v1.message.reply.call_count == 2
def test_reply_message_sync_returns_false_on_exception() -> None:
channel = _make_feishu_channel()
channel._client.im.v1.message.reply.side_effect = RuntimeError("network error")
@@ -368,6 +398,37 @@ async def test_send_fallback_to_create_when_reply_fails() -> None:
channel._client.im.v1.message.create.assert_called_once()
def test_send_message_sync_falls_back_to_text_for_interactive_error() -> None:
channel = _make_feishu_channel()
interactive_resp = MagicMock()
interactive_resp.success.return_value = False
interactive_resp.code = 230099
interactive_resp.msg = "cardid is invalid"
interactive_resp.get_log_id.return_value = "log_x"
text_resp = MagicMock()
text_resp.success.return_value = True
text_resp.data = SimpleNamespace(message_id="om_fallback")
channel._client.im.v1.message.create.side_effect = [interactive_resp, text_resp]
message_id = channel._send_message_sync(
"chat_id",
"oc_abc",
"interactive",
json.dumps(
{
"config": {"wide_screen_mode": True},
"elements": [{"tag": "markdown", "content": "fallback body"}],
},
ensure_ascii=False,
),
)
assert message_id == "om_fallback"
assert channel._client.im.v1.message.create.call_count == 2
@pytest.mark.asyncio
async def test_send_multiple_messages_all_use_reply_when_in_topic(tmp_path: Path) -> None:
"""When in a topic (has thread_id), all messages use reply API to stay in topic."""
@@ -786,6 +847,36 @@ async def test_session_key_group_no_root_id_uses_message_id() -> None:
assert bus_spy[0].session_key == "feishu:oc_abc:om_001"
@pytest.mark.asyncio
async def test_session_key_named_instance_uses_runtime_channel_namespace() -> None:
"""Named Feishu assistant instances keep group sessions separate."""
channel = _make_feishu_channel(group_policy="open")
channel.name = "feishu.product"
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].channel == "feishu.product"
assert bus_spy[0].session_key == "feishu.product:oc_abc:om_root123"
@pytest.mark.asyncio
async def test_session_key_private_chat_no_override() -> None:
"""Private chat never overrides session key (consistent with Telegram/Slack)."""
@@ -1036,6 +1127,33 @@ def test_on_background_task_done_removes_from_set() -> None:
assert task not in channel._background_tasks
def test_on_message_sync_ignores_events_after_channel_stops() -> None:
"""Late WebSocket callbacks should not schedule work after the assistant is off."""
channel = _make_feishu_channel()
channel._running = False
channel._loop = MagicMock()
channel._loop.is_running.return_value = True
with patch("asyncio.run_coroutine_threadsafe") as schedule:
channel._on_message_sync(_make_feishu_event())
schedule.assert_not_called()
@pytest.mark.asyncio
async def test_on_message_ignores_events_after_channel_stops() -> None:
"""Stopped assistants must not react, pair, or publish stale Feishu events."""
channel = _make_feishu_channel(group_policy="open")
channel._running = False
channel._add_reaction = AsyncMock()
channel._handle_message = AsyncMock()
await channel._on_message(_make_feishu_event())
channel._add_reaction.assert_not_awaited()
channel._handle_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_on_message_unauthorized_dm_sends_pairing_code_without_side_effects() -> None:
"""Unauthorized DM sender gets a pairing code but no media side effects."""
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
import asyncio
import threading
from nanobot.channels._feishu_ws import FeishuWsRunner
def test_concurrent_loop_initialization_starts_one_thread(monkeypatch) -> None:
runner = FeishuWsRunner()
created_loops: list[asyncio.AbstractEventLoop] = []
release_start = threading.Event()
def fake_run_loop() -> None:
loop = asyncio.new_event_loop()
created_loops.append(loop)
assert release_start.wait(timeout=2)
runner._loop = loop
runner._ready.set()
monkeypatch.setattr(runner, "_run_loop", fake_run_loop)
loops: list[asyncio.AbstractEventLoop] = []
threads = [threading.Thread(target=lambda: loops.append(runner._ensure_loop())) for _ in range(2)]
for thread in threads:
thread.start()
release_start.set()
for thread in threads:
thread.join(timeout=2)
assert len(created_loops) == 1
assert loops == [created_loops[0], created_loops[0]]
created_loops[0].close()
@@ -76,6 +76,7 @@ def _make_handler(
local_trigger_store: LocalTriggerStore | None = None,
cron_pending_job_ids: Any | None = None,
local_trigger_pending_ids: Any | None = None,
channel_feature_action: Any | None = None,
) -> GatewayServices:
config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg
workspace = workspace_path or Path.cwd()
@@ -93,6 +94,7 @@ def _make_handler(
local_trigger_store=local_trigger_store,
cron_pending_job_ids=cron_pending_job_ids,
local_trigger_pending_ids=local_trigger_pending_ids,
channel_feature_action=channel_feature_action,
)
@@ -108,6 +110,7 @@ def _ch(
local_trigger_store: LocalTriggerStore | None = None,
cron_pending_job_ids: Any | None = None,
local_trigger_pending_ids: Any | None = None,
channel_feature_action: Any | None = None,
**extra: Any,
) -> WebSocketChannel:
cfg: dict[str, Any] = {
@@ -129,6 +132,7 @@ def _ch(
local_trigger_store=local_trigger_store,
cron_pending_job_ids=cron_pending_job_ids,
local_trigger_pending_ids=local_trigger_pending_ids,
channel_feature_action=channel_feature_action,
)
return WebSocketChannel(cfg, bus, gateway=gateway)
@@ -650,6 +654,128 @@ async def test_nanobot_feature_routes_require_token_and_enable(
await server_task
@pytest.mark.asyncio
async def test_pairing_routes_require_token_and_approve_or_deny(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
pending = [
{
"code": "ABCD-EFGH",
"channel": "feishu",
"sender_id": "ou_123",
"created_at": 1_000.0,
"expires_at": 1_600.0,
}
]
approved: list[str] = []
denied: list[str] = []
monkeypatch.setattr("nanobot.webui.settings_routes.list_pending", lambda: list(pending))
monkeypatch.setattr(
"nanobot.webui.settings_routes.approve_code",
lambda code: approved.append(code) or ("feishu", "ou_123") if code == "ABCD-EFGH" else None,
)
monkeypatch.setattr(
"nanobot.webui.settings_routes.deny_code",
lambda code: denied.append(code) or code == "ABCD-EFGH",
)
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port())
token = channel.gateway.tokens.issue_api_token(300)
denied_response = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(path="/api/settings/pairing"),
"/api/settings/pairing",
)
assert denied_response is not None
assert denied_response.status_code == 401
auth = {"Authorization": f"Bearer {token}"}
listed = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(auth, path="/api/settings/pairing"),
"/api/settings/pairing",
)
assert listed is not None
assert listed.status_code == 200
body = json.loads(listed.body.decode())
assert body["requests"][0]["code"] == "ABCD-EFGH"
assert body["requests"][0]["channel"] == "feishu"
assert body["requests"][0]["sender_id"] == "ou_123"
assert body["requests"][0]["created_at_ms"] == 1_000_000
assert body["requests"][0]["expires_at_ms"] == 1_600_000
approved_response = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(auth, path="/api/settings/pairing/approve?code=ABCD-EFGH"),
"/api/settings/pairing/approve",
)
assert approved_response is not None
assert approved_response.status_code == 200
body = json.loads(approved_response.body.decode())
assert body["last_action"]["action"] == "approve"
assert body["last_action"]["sender_id"] == "ou_123"
assert approved == ["ABCD-EFGH"]
denied_action = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(auth, path="/api/settings/pairing/deny?code=ABCD-EFGH"),
"/api/settings/pairing/deny",
)
assert denied_action is not None
assert denied_action.status_code == 200
assert json.loads(denied_action.body.decode())["last_action"]["action"] == "deny"
assert denied == ["ABCD-EFGH"]
missing_code = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(auth, path="/api/settings/pairing/approve"),
"/api/settings/pairing/approve",
)
assert missing_code is not None
assert missing_code.status_code == 400
assert "Missing pairing code" in missing_code.body.decode()
def test_api_service_settings_read_api_key_from_private_header(bus: MagicMock) -> None:
channel = _ch(bus)
request = _FakeReq(
{"X-Nanobot-API-Service-Values": json.dumps({"api_key": "secret-token"})},
path="/api/settings/api-service/start?host=0.0.0.0&port=8900&timeout=120",
)
query = channel.gateway.http.settings_routes._parse_api_service_settings_query(request)
assert query == {
"host": ["0.0.0.0"],
"port": ["8900"],
"timeout": ["120"],
"api_key": ["secret-token"],
}
def test_api_service_settings_reject_invalid_private_header(bus: MagicMock) -> None:
from nanobot.webui.settings_api import WebUISettingsError
channel = _ch(bus)
request = _FakeReq(
{"X-Nanobot-API-Service-Values": json.dumps({"api_key": 123})},
path="/api/settings/api-service/start?host=127.0.0.1",
)
with pytest.raises(WebUISettingsError, match="API key must be a string"):
channel.gateway.http.settings_routes._parse_api_service_settings_query(request)
query_secret = _FakeReq(
path="/api/settings/api-service/start?host=127.0.0.1&api_key=secret-token",
)
with pytest.raises(WebUISettingsError, match="private header"):
channel.gateway.http.settings_routes._parse_api_service_settings_query(query_secret)
@pytest.mark.asyncio
async def test_nanobot_feature_remote_install_requires_opt_in(
bus: MagicMock,
@@ -733,6 +859,506 @@ async def test_nanobot_feature_local_install_allowed_by_default(
] is True
@pytest.mark.asyncio
async def test_nanobot_feature_channel_action_can_apply_without_restart(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
_stub_matrix_feature(monkeypatch, config_path, deps=["matrix-nio>=0.25.2"])
calls: list[tuple[str, str]] = []
async def channel_feature_action(action: str, name: str) -> dict[str, Any]:
calls.append((action, name))
return {
"handled": True,
"ok": True,
"requires_restart": False,
"message": "Matrix channel applied without restart.",
}
channel = _ch(
bus,
session_manager=_seed_session(tmp_path),
port=_free_port(),
channel_feature_action=channel_feature_action,
)
token = channel.gateway.tokens.issue_api_token(300)
request = _FakeReq(
{"Authorization": f"Bearer {token}", "Host": "127.0.0.1:8765"},
path="/api/settings/nanobot-features/enable?name=matrix",
)
response = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
request,
"/api/settings/nanobot-features/enable",
)
assert response is not None
assert response.status_code == 200
body = json.loads(response.body.decode())
assert calls == [("enable", "matrix")]
assert body["requires_restart"] is False
assert body["restart_required_sections"] == []
assert body["last_action"]["hot_reload"] is True
assert body["last_action"]["message"].endswith("Matrix channel applied without restart.")
@pytest.mark.asyncio
async def test_feishu_connect_routes_write_config_and_hot_reload(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from nanobot.channels import feishu as feishu_module
from nanobot.config import loader
from nanobot.config.schema import Config
config_path = tmp_path / "config.json"
loader.save_config(Config(), config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr(feishu_module, "_init_registration", lambda _domain: None)
monkeypatch.setattr(
feishu_module,
"_begin_registration",
lambda _domain: {
"device_code": "device",
"qr_url": "https://accounts.feishu.cn/login?device_code=device",
"interval": 2,
"expire_in": 600,
},
)
monkeypatch.setattr(
feishu_module,
"poll_registration_once",
lambda *, device_code, domain: {
"status": "succeeded",
"app_id": "cli_app",
"app_secret": "secret",
"domain": "feishu",
},
)
monkeypatch.setattr(
feishu_module,
"fetch_feishu_app_identity",
lambda app_id, app_secret, domain: {
"displayName": "Voraflare Bot",
"avatarUrl": "https://example.com/feishu.png",
"identityFetchedAt": "2026-07-06T00:00:00Z",
},
)
monkeypatch.setattr(
"nanobot.webui.settings_routes.nanobot_features_action",
lambda _action, _query, *, allow_install=True: {
"features": [{
"name": "feishu",
"display_name": "Feishu",
"type": "channel",
"enabled": True,
"installed": True,
"ready": True,
"status": "enabled",
"install_supported": True,
"requires_restart": True,
}],
"enabled_count": 1,
"requires_restart": True,
"last_action": {"ok": True, "message": "Enabled channel 'feishu'", "enabled": True},
},
)
calls: list[tuple[str, str]] = []
async def channel_feature_action(action: str, name: str) -> dict[str, Any]:
calls.append((action, name))
return {
"handled": True,
"ok": True,
"requires_restart": False,
"message": "Feishu channel applied without restart.",
}
channel = _ch(
bus,
session_manager=_seed_session(tmp_path),
port=_free_port(),
channel_feature_action=channel_feature_action,
)
token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}", "Host": "127.0.0.1:8765"}
started = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(
auth,
path="/api/settings/channels/feishu/connect/start?domain=feishu&instance_id=default",
),
"/api/settings/channels/feishu/connect/start",
)
assert started is not None
assert started.status_code == 200
start_body = json.loads(started.body.decode())
assert start_body["status"] == "pending"
assert start_body["instance_id"] == "default"
assert start_body["qr_url"].startswith("https://accounts.feishu.cn/")
polled = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(
auth,
path=f"/api/settings/channels/feishu/connect/poll?session_id={start_body['session_id']}",
),
"/api/settings/channels/feishu/connect/poll",
)
assert polled is not None
assert polled.status_code == 200
body = json.loads(polled.body.decode())
assert body["status"] == "succeeded"
assert body["instance_id"] == "default"
assert "app_secret" not in body
assert calls == [("enable", "feishu")]
assert body["nanobot_features"]["requires_restart"] is False
data = json.loads(config_path.read_text(encoding="utf-8"))
assert data["channels"]["feishu"]["instances"][0]["id"] == "default"
assert data["channels"]["feishu"]["instances"][0]["appId"] == "cli_app"
assert data["channels"]["feishu"]["instances"][0]["appSecret"] == "secret"
assert data["channels"]["feishu"]["instances"][0]["enabled"] is True
assert data["channels"]["feishu"]["instances"][0]["displayName"] == "Voraflare Bot"
assert data["channels"]["feishu"]["instances"][0]["avatarUrl"] == "https://example.com/feishu.png"
def test_feishu_connect_create_appends_instance(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from nanobot.channels import feishu as feishu_module
from nanobot.config import loader
from nanobot.webui.channel_connect import FeishuConnectStore
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps({
"channels": {
"feishu": {
"instances": [{
"id": "default",
"name": "nanobot",
"enabled": True,
"appId": "cli_default",
"appSecret": "default-secret",
}]
}
}
}),
encoding="utf-8",
)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr(feishu_module, "_init_registration", lambda _domain: None)
monkeypatch.setattr(
feishu_module,
"_begin_registration",
lambda _domain: {
"device_code": "device",
"qr_url": "https://accounts.feishu.cn/login?device_code=device",
"interval": 2,
"expire_in": 600,
},
)
monkeypatch.setattr(
feishu_module,
"poll_registration_once",
lambda *, device_code, domain: {
"status": "succeeded",
"app_id": "cli_new",
"app_secret": "new-secret",
"domain": "feishu",
},
)
monkeypatch.setattr(
feishu_module,
"fetch_feishu_app_identity",
lambda app_id, app_secret, domain: {
"displayName": f"Assistant {app_id}",
"avatarUrl": f"https://example.com/{app_id}.png",
"identityFetchedAt": "2026-07-06T00:00:00Z",
},
)
store = FeishuConnectStore()
started = store.start(mode="create")
polled = store.poll(started["session_id"])
assert polled["status"] == "succeeded"
assert polled["instance_id"] != "default"
data = json.loads(config_path.read_text(encoding="utf-8"))
instances = data["channels"]["feishu"]["instances"]
assert [item["id"] for item in instances] == ["default", polled["instance_id"]]
assert instances[0]["appId"] == "cli_default"
assert instances[1]["appId"] == "cli_new"
assert instances[0].get("displayName") is None
assert instances[1]["displayName"] == "Assistant cli_new"
assert instances[1]["avatarUrl"] == "https://example.com/cli_new.png"
@pytest.mark.asyncio
async def test_channel_configure_route_saves_discord_config_and_hot_reloads(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from nanobot.config import loader
from nanobot.config.schema import Config
config_path = tmp_path / "config.json"
loader.save_config(Config(), config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
def fake_feature_action(
action: str,
query: dict[str, list[str]],
*,
allow_install: bool = True,
) -> dict[str, Any]:
assert action == "enable"
assert query == {"name": ["discord"]}
cfg = loader.load_config()
section = dict(getattr(cfg.channels, "discord", {}) or {})
section["enabled"] = True
setattr(cfg.channels, "discord", section)
loader.save_config(cfg)
return {
"features": [{
"name": "discord",
"display_name": "Discord",
"type": "channel",
"enabled": True,
"installed": True,
"ready": True,
"status": "enabled",
"install_supported": True,
"requires_restart": True,
}],
"enabled_count": 1,
"requires_restart": True,
"last_action": {"ok": True, "message": "Enabled channel 'discord'", "enabled": True},
}
monkeypatch.setattr("nanobot.webui.settings_routes.nanobot_features_action", fake_feature_action)
calls: list[tuple[str, str]] = []
async def channel_feature_action(action: str, name: str) -> dict[str, Any]:
calls.append((action, name))
cfg = loader.load_config()
assert getattr(cfg.channels, "discord")["token"] == "discord-token"
return {
"handled": True,
"ok": True,
"requires_restart": False,
"message": "Discord channel applied without restart.",
}
channel = _ch(
bus,
session_manager=_seed_session(tmp_path),
port=_free_port(),
channel_feature_action=channel_feature_action,
)
token = channel.gateway.tokens.issue_api_token(300)
response = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(
{
"Authorization": f"Bearer {token}",
"Host": "127.0.0.1:8765",
"X-Nanobot-Channel-Values": json.dumps(
{
"channels.discord.token": "discord-token",
"channels.discord.allowChannels": "123, 456",
"channels.discord.groupPolicy": "open",
}
),
},
path="/api/settings/channels/configure?name=discord&enable=true",
),
"/api/settings/channels/configure",
)
assert response is not None
assert response.status_code == 200
body = json.loads(response.body.decode())
assert body["saved"] is True
assert body["name"] == "discord"
assert "discord-token" not in response.body.decode()
assert calls == [("enable", "discord")]
assert body["nanobot_features"]["requires_restart"] is False
data = json.loads(config_path.read_text(encoding="utf-8"))
assert data["channels"]["discord"] == {
"token": "discord-token",
"allowChannels": ["123", "456"],
"groupPolicy": "open",
"enabled": True,
}
@pytest.mark.asyncio
async def test_channel_configure_route_preserves_existing_channel_values(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from nanobot.config import loader
from nanobot.config.schema import Config
config_path = tmp_path / "config.json"
config = Config()
setattr(
config.channels,
"discord",
{
"enabled": True,
"token": "old-discord-token",
"allowChannels": ["old-channel"],
"groupPolicy": "mention",
"customExtra": "keep-me",
"nested": {"value": 42},
},
)
loader.save_config(config, config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port())
token = channel.gateway.tokens.issue_api_token(300)
response = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(
{
"Authorization": f"Bearer {token}",
"Host": "127.0.0.1:8765",
"X-Nanobot-Channel-Values": json.dumps(
{
"channels.discord.token": "",
"channels.discord.allowChannels": "new-channel",
}
),
},
path="/api/settings/channels/configure?name=discord",
),
"/api/settings/channels/configure",
)
assert response is not None
assert response.status_code == 200
body = json.loads(response.body.decode())
assert body["saved_keys"] == ["channels.discord.allowChannels"]
data = json.loads(config_path.read_text(encoding="utf-8"))
assert data["channels"]["discord"] == {
"enabled": True,
"token": "old-discord-token",
"allowChannels": ["new-channel"],
"groupPolicy": "mention",
"customExtra": "keep-me",
"nested": {"value": 42},
}
@pytest.mark.asyncio
async def test_channel_configure_route_saves_matrix_device_id_without_replacing_token(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from nanobot.config import loader
from nanobot.config.schema import Config
config_path = tmp_path / "config.json"
config = Config()
setattr(
config.channels,
"matrix",
{
"enabled": False,
"homeserver": "https://matrix.example",
"userId": "@nanobot:matrix.example",
"accessToken": "saved-token",
},
)
loader.save_config(config, config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port())
token = channel.gateway.tokens.issue_api_token(300)
response = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(
{
"Authorization": f"Bearer {token}",
"Host": "127.0.0.1:8765",
"X-Nanobot-Channel-Values": json.dumps(
{
"channels.matrix.accessToken": "",
"channels.matrix.deviceId": "DEVICE-ID",
}
),
},
path="/api/settings/channels/configure?name=matrix",
),
"/api/settings/channels/configure",
)
assert response is not None
assert response.status_code == 200
data = json.loads(config_path.read_text(encoding="utf-8"))
assert data["channels"]["matrix"]["accessToken"] == "saved-token"
assert data["channels"]["matrix"]["deviceId"] == "DEVICE-ID"
@pytest.mark.asyncio
async def test_channel_configure_route_saves_mattermost_setup(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from nanobot.config import loader
from nanobot.config.schema import Config
config_path = tmp_path / "config.json"
loader.save_config(Config(), config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port())
token = channel.gateway.tokens.issue_api_token(300)
response = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(
{
"Authorization": f"Bearer {token}",
"Host": "127.0.0.1:8765",
"X-Nanobot-Channel-Values": json.dumps(
{
"channels.mattermost.serverUrl": "https://chat.example.com",
"channels.mattermost.token": "mattermost-token",
"channels.mattermost.teamId": "platform",
}
),
},
path="/api/settings/channels/configure?name=mattermost",
),
"/api/settings/channels/configure",
)
assert response is not None
assert response.status_code == 200
data = json.loads(config_path.read_text(encoding="utf-8"))
assert data["channels"]["mattermost"] == {
"serverUrl": "https://chat.example.com",
"token": "mattermost-token",
"teamId": "platform",
}
@pytest.mark.asyncio
async def test_nanobot_feature_loopback_reverse_proxy_install_requires_opt_in(
bus: MagicMock,