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:
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user