Files
nanobot/nanobot/webui/settings_routes.py
T
Xubin RenandGitHub fe0717b385 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
2026-07-13 13:11:46 +08:00

1120 lines
47 KiB
Python

"""HTTP route adapter for WebUI Settings APIs.
Keep WebUI Settings route handlers here, not in ``channels/websocket.py``.
The websocket channel owns transport concerns; this module owns WebUI Settings
request mapping and response shaping.
"""
from __future__ import annotations
import asyncio
import inspect
import json
import time
from collections.abc import Callable
from typing import Any
from websockets.http11 import Request as WsRequest
from websockets.http11 import Response
from nanobot.agent.tools.mcp import request_mcp_reload
from nanobot.api.runtime import ApiRuntime, ApiStartOptions, api_runtime_paths
from nanobot.bus.queue import MessageBus
from nanobot.channels._setup import channel_setup_spec
from nanobot.config.loader import get_config_path, load_config, save_config
from nanobot.optional_features import (
OptionalFeatureError,
extra_installed,
optional_dependency_groups,
)
from nanobot.pairing import approve_code, deny_code, list_pending
from nanobot.webui.channel_connect import (
ChannelConnectError,
FeishuConnectStore,
WeixinConnectStore,
)
from nanobot.webui.channel_validation import validate_channel_config
from nanobot.webui.cli_apps_api import cli_apps_action, cli_apps_payload
from nanobot.webui.http_utils import is_local_browser_request as _is_local_browser_request
from nanobot.webui.http_utils import query_first as _query_first
from nanobot.webui.mcp_presets_api import mcp_presets_settings_action
from nanobot.webui.nanobot_features_api import nanobot_features_action, nanobot_features_payload
from nanobot.webui.settings_api import (
WebUISettingsError,
create_model_configuration,
decorate_settings_payload,
login_oauth_provider,
logout_oauth_provider,
provider_models_payload,
settings_payload,
settings_usage_payload,
update_agent_settings,
update_api_settings,
update_image_generation_settings,
update_model_configuration,
update_network_safety_settings,
update_provider_settings,
update_transcription_settings,
update_web_search_settings,
)
from nanobot.webui.version_check import check_for_update
QueryParams = dict[str, list[str]]
_MCP_VALUES_HEADER = "X-Nanobot-MCP-Values"
_MCP_VALUES_HEADER_MAX_BYTES = 64 * 1024
_CHANNEL_VALUES_HEADER = "X-Nanobot-Channel-Values"
_CHANNEL_VALUES_HEADER_MAX_BYTES = 64 * 1024
_API_SERVICE_VALUES_HEADER = "X-Nanobot-API-Service-Values"
_API_SERVICE_VALUES_HEADER_MAX_BYTES = 8 * 1024
_SKIP_FIELD = object()
_MCP_PRESET_ACTIONS_BY_PATH = {
"/api/settings/mcp-presets/enable": "enable",
"/api/settings/mcp-presets/remove": "remove",
"/api/settings/mcp-presets/test": "test",
"/api/settings/mcp-presets/custom": "custom",
"/api/settings/mcp-presets/import": "import",
"/api/settings/mcp-presets/import-cursor": "import-cursor",
"/api/settings/mcp-presets/tools": "tools",
}
class WebUISettingsRouter:
"""Route WebUI Settings HTTP requests behind a transport-neutral boundary."""
def __init__(
self,
*,
bus: MessageBus,
logger: Any,
check_api_token: Callable[[WsRequest], bool],
parse_query: Callable[[str], QueryParams],
json_response: Callable[[dict[str, Any]], Response],
error_response: Callable[[int, str | None], Response],
runtime_surface: str,
runtime_capabilities: dict[str, Any],
channel_feature_action: Callable[..., Any] | None = None,
) -> None:
self.bus = bus
self.logger = logger
self._check_api_token = check_api_token
self._parse_query = parse_query
self._json_response = json_response
self._error_response = error_response
self._runtime_surface = runtime_surface
self._runtime_capabilities = runtime_capabilities
self._channel_feature_action = channel_feature_action
self._restart_sections: set[str] = set()
self._feishu_connect = FeishuConnectStore()
self._weixin_connect = WeixinConnectStore()
async def dispatch(self, connection: Any, request: WsRequest, path: str) -> Response | None:
if path == "/api/settings":
return self._handle_settings(request)
if path == "/api/settings/usage":
return self._handle_settings_usage(request)
if path == "/api/settings/update":
return self._handle_settings_update(request)
if path == "/api/settings/model-configurations/create":
return self._handle_settings_model_configuration_create(request)
if path == "/api/settings/model-configurations/update":
return self._handle_settings_model_configuration_update(request)
if path == "/api/settings/provider/update":
return self._handle_settings_provider_update(request)
if path == "/api/settings/provider-models":
return await self._handle_settings_provider_models(request)
if path == "/api/settings/provider/oauth-login":
return await self._handle_settings_provider_oauth(request, "login")
if path == "/api/settings/provider/oauth-logout":
return await self._handle_settings_provider_oauth(request, "logout")
if path == "/api/settings/web-search/update":
return self._handle_settings_web_search_update(request)
if path == "/api/settings/api-service":
return self._handle_settings_api_service(request)
if path == "/api/settings/api-service/start":
return await self._handle_settings_api_service_start(connection, request)
if path == "/api/settings/api-service/stop":
return await self._handle_settings_api_service_stop(request)
if path == "/api/settings/image-generation/update":
return self._handle_settings_image_generation_update(request)
if path == "/api/settings/transcription/update":
return self._handle_settings_transcription_update(request)
if path == "/api/settings/network-safety/update":
return self._handle_settings_network_safety_update(request)
if path == "/api/settings/cli-apps":
return await self._handle_settings_cli_apps(request)
if path == "/api/settings/cli-apps/install":
return await self._handle_settings_cli_apps_action(request, "install")
if path == "/api/settings/cli-apps/update":
return await self._handle_settings_cli_apps_action(request, "update")
if path == "/api/settings/cli-apps/uninstall":
return await self._handle_settings_cli_apps_action(request, "uninstall")
if path == "/api/settings/cli-apps/test":
return await self._handle_settings_cli_apps_action(request, "test")
if path == "/api/settings/nanobot-features":
return await self._handle_settings_nanobot_features(request)
if path == "/api/settings/nanobot-features/enable":
return await self._handle_settings_nanobot_features_action(connection, request, "enable")
if path == "/api/settings/nanobot-features/disable":
return await self._handle_settings_nanobot_features_action(connection, request, "disable")
if path == "/api/settings/channels/feishu/connect/start":
return await self._handle_settings_feishu_connect_start(request)
if path == "/api/settings/channels/feishu/connect/poll":
return await self._handle_settings_feishu_connect_poll(connection, request)
if path == "/api/settings/channels/feishu/connect/cancel":
return self._handle_settings_feishu_connect_cancel(request)
if path == "/api/settings/channels/weixin/connect/start":
return await self._handle_settings_weixin_connect_start(connection, request)
if path == "/api/settings/channels/weixin/connect/poll":
return await self._handle_settings_weixin_connect_poll(connection, request)
if path == "/api/settings/channels/weixin/connect/cancel":
return await self._handle_settings_weixin_connect_cancel(request)
if path == "/api/settings/channels/validate":
return await self._handle_settings_channel_validate(request)
if path == "/api/settings/channels/configure":
return await self._handle_settings_channel_configure(connection, request)
if path == "/api/settings/pairing":
return self._handle_settings_pairing(request)
if path == "/api/settings/pairing/approve":
return self._handle_settings_pairing_action(request, "approve")
if path == "/api/settings/pairing/deny":
return self._handle_settings_pairing_action(request, "deny")
if path == "/api/settings/mcp-presets":
return await self._handle_settings_mcp_presets(request)
if path == "/api/settings/version-check":
return await self._handle_settings_version_check(request)
mcp_action = _MCP_PRESET_ACTIONS_BY_PATH.get(path)
if mcp_action is not None:
return await self._handle_settings_mcp_presets(request, mcp_action)
return None
def _query(self, request: WsRequest) -> QueryParams:
return self._parse_query(request.path)
def _authorized(self, request: WsRequest) -> bool:
return self._check_api_token(request)
def _unauthorized(self) -> Response:
return self._error_response(401, "Unauthorized")
def _with_restart_state(
self,
payload: dict[str, Any],
*,
section: str | None = None,
) -> dict[str, Any]:
"""Keep restart-required state alive for this gateway process."""
if section and payload.get("requires_restart"):
self._restart_sections.add(section)
sections = sorted(self._restart_sections)
payload = dict(payload)
if sections:
payload["requires_restart"] = True
return decorate_settings_payload(
payload,
surface=self._runtime_surface,
runtime_capability_overrides=self._runtime_capabilities,
restart_required_sections=sections,
)
def _parse_mcp_settings_query(self, request: WsRequest) -> QueryParams:
query = self._query(request)
raw = request.headers.get(_MCP_VALUES_HEADER)
if not raw:
return query
if len(raw.encode("utf-8")) > _MCP_VALUES_HEADER_MAX_BYTES:
raise WebUISettingsError("MCP settings payload is too large")
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise WebUISettingsError("invalid MCP settings payload") from exc
if not isinstance(payload, dict):
raise WebUISettingsError("MCP settings payload must be a JSON object")
merged = {key: list(values) for key, values in query.items()}
for key, value in payload.items():
if not isinstance(key, str) or not key:
raise WebUISettingsError("MCP settings payload contains an invalid key")
if value is None:
continue
if isinstance(value, str):
text = value.strip()
else:
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
if text:
merged[key] = [text]
return merged
def _handle_settings(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
return self._json_response(
self._with_restart_state(
settings_payload(
surface=self._runtime_surface,
runtime_capability_overrides=self._runtime_capabilities,
)
)
)
def _handle_settings_usage(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
return self._json_response(settings_usage_payload())
def _handle_settings_pairing(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
return self._json_response(_pairing_payload())
def _handle_settings_pairing_action(self, request: WsRequest, action: str) -> Response:
if not self._authorized(request):
return self._unauthorized()
query = self._query(request)
code = (_query_first(query, "code") or "").strip()
if not code:
return self._error_response(400, "Missing pairing code")
if action == "approve":
result = approve_code(code)
if result is None:
return self._error_response(404, "Pairing code not found or expired")
channel, sender_id = result
return self._json_response(
_pairing_payload({
"ok": True,
"action": "approve",
"message": f"Approved {sender_id} for {channel}",
"channel": channel,
"sender_id": sender_id,
"code": code,
})
)
if not deny_code(code):
return self._error_response(404, "Pairing code not found or expired")
return self._json_response(
_pairing_payload({
"ok": True,
"action": "deny",
"message": f"Denied pairing code {code}",
"code": code,
})
)
def _handle_settings_update(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
payload = update_agent_settings(self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
return self._json_response(self._with_restart_state(payload, section="runtime"))
def _handle_settings_model_configuration_create(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
payload = create_model_configuration(self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
return self._json_response(self._with_restart_state(payload))
def _handle_settings_model_configuration_update(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
payload = update_model_configuration(self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
return self._json_response(self._with_restart_state(payload))
def _handle_settings_provider_update(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
payload = update_provider_settings(self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
return self._json_response(self._with_restart_state(payload, section="image"))
async def _handle_settings_provider_models(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
payload = await asyncio.to_thread(provider_models_payload, self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
except Exception:
self.logger.exception("failed to load provider model list")
return self._error_response(500, "failed to load provider model list")
return self._json_response(payload)
async def _handle_settings_provider_oauth(
self,
request: WsRequest,
action: str,
) -> Response:
if not self._authorized(request):
return self._unauthorized()
query = self._query(request)
try:
if action == "login":
payload = await asyncio.to_thread(login_oauth_provider, query)
else:
payload = await asyncio.to_thread(logout_oauth_provider, query)
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
return self._json_response(self._with_restart_state(payload))
def _handle_settings_web_search_update(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
payload = update_web_search_settings(self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
return self._json_response(self._with_restart_state(payload, section="browser"))
def _handle_settings_api_service(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
return self._json_response(self._api_service_payload())
async def _handle_settings_api_service_start(
self,
connection: Any,
request: WsRequest,
) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
await asyncio.to_thread(
nanobot_features_action,
"enable",
{"name": ["api"]},
allow_install=self._allow_feature_package_install(connection, request),
)
update_api_settings(self._parse_api_service_settings_query(request))
config = load_config()
runtime = self._api_runtime()
options = ApiStartOptions(
host=config.api.host,
port=config.api.port,
workspace=str(config.workspace_path),
config_path=str(get_config_path().expanduser().resolve(strict=False)),
)
current = runtime.status()
result = await asyncio.to_thread(
runtime.restart if current.running else runtime.start_background,
options,
)
if not result.ok:
return self._error_response(500, self._api_runtime_message(result.message))
except (WebUISettingsError, OptionalFeatureError) as e:
return self._error_response(getattr(e, "status", 400), getattr(e, "message", str(e)))
except Exception as e:
self.logger.exception("failed to start managed API service")
return self._error_response(500, str(e))
return self._json_response(self._api_service_payload(last_action="started"))
def _parse_api_service_settings_query(self, request: WsRequest) -> QueryParams:
query = self._query(request)
if "api_key" in query or "apiKey" in query:
raise WebUISettingsError("API service API key must be provided in the private header")
raw = request.headers.get(_API_SERVICE_VALUES_HEADER)
if not raw:
return query
if len(raw.encode("utf-8")) > _API_SERVICE_VALUES_HEADER_MAX_BYTES:
raise WebUISettingsError("API service settings payload is too large")
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise WebUISettingsError("invalid API service settings payload") from exc
if not isinstance(payload, dict):
raise WebUISettingsError("API service settings payload must be a JSON object")
unknown = set(payload) - {"api_key"}
if unknown:
raise WebUISettingsError("API service settings payload contains an invalid key")
api_key = payload.get("api_key")
if api_key is not None and not isinstance(api_key, str):
raise WebUISettingsError("API service API key must be a string")
merged = {key: list(values) for key, values in query.items() if key != "api_key"}
if api_key is not None:
merged["api_key"] = [api_key]
return merged
async def _handle_settings_api_service_stop(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
result = await asyncio.to_thread(self._api_runtime().stop)
except Exception as e:
self.logger.exception("failed to stop managed API service")
return self._error_response(500, str(e))
if not result.ok and result.message != "api_not_running":
return self._error_response(500, self._api_runtime_message(result.message))
return self._json_response(self._api_service_payload(last_action="stopped"))
@staticmethod
def _api_runtime() -> ApiRuntime:
config_path = get_config_path().expanduser().resolve(strict=False)
return ApiRuntime(paths=api_runtime_paths(config_path))
def _api_service_payload(self, *, last_action: str | None = None) -> dict[str, Any]:
config = load_config()
status = self._api_runtime().status()
extras = optional_dependency_groups()
connect_host = "127.0.0.1" if config.api.host in {"0.0.0.0", "::"} else config.api.host
payload = {
"installed": extra_installed("api", extras.get("api")),
"running": status.running,
"managed": status.running,
"host": config.api.host,
"port": config.api.port,
"timeout": config.api.timeout,
"api_key_hint": self._masked_secret(config.api.api_key),
"endpoint": f"http://{connect_host}:{config.api.port}/v1",
"command": "nanobot serve",
"log_path": str(status.log_path),
}
if last_action:
payload["last_action"] = last_action
return payload
@staticmethod
def _masked_secret(value: str) -> str | None:
value = value.strip()
if not value:
return None
return f"{value[:3]}...{value[-4:]}" if len(value) > 8 else "configured"
@staticmethod
def _api_runtime_message(message: str) -> str:
known = {
"api_exited_during_startup": "API server exited during startup. Check its log for details.",
"api_stop_timeout": "API server did not stop in time.",
"api_state_stale": "API server state was stale; try starting it again.",
}
if message in known:
return known[message]
if message.startswith("api_"):
return f"API server {message.removeprefix('api_').replace('_', ' ')}"
return message.replace("_", " ")
def _handle_settings_image_generation_update(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
payload = update_image_generation_settings(self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
return self._json_response(self._with_restart_state(payload, section="image"))
def _handle_settings_transcription_update(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
payload = update_transcription_settings(self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
return self._json_response(self._with_restart_state(payload))
def _handle_settings_network_safety_update(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
payload = update_network_safety_settings(self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
return self._json_response(self._with_restart_state(payload, section="runtime"))
async def _handle_settings_cli_apps(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
installed_only = (_query_first(self._query(request), "installed_only") or "").lower() in {
"1",
"true",
"yes",
}
try:
payload = await cli_apps_payload(installed_only=installed_only)
except Exception:
self.logger.exception("failed to load CLI Apps payload")
return self._error_response(500, "failed to load CLI Apps")
return self._json_response(payload)
async def _handle_settings_cli_apps_action(
self,
request: WsRequest,
action: str,
) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
payload = await asyncio.to_thread(cli_apps_action, action, self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
except Exception as e:
status = getattr(e, "status", 500)
message = getattr(e, "message", str(e))
if status >= 500:
self.logger.exception("CLI Apps action '{}' failed", action)
return self._error_response(status, message)
return self._json_response(payload)
async def _handle_settings_nanobot_features(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
payload = await asyncio.to_thread(nanobot_features_payload)
except Exception:
self.logger.exception("failed to load nanobot features")
return self._error_response(500, "failed to load nanobot features")
return self._json_response(payload)
async def _handle_settings_nanobot_features_action(
self,
connection: Any,
request: WsRequest,
action: str,
) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
payload = await asyncio.to_thread(
nanobot_features_action,
action,
self._query(request),
allow_install=action != "enable"
or self._allow_feature_package_install(connection, request),
)
except OptionalFeatureError as e:
return self._error_response(e.status, e.message)
except Exception as e:
status = getattr(e, "status", 500)
message = getattr(e, "message", str(e))
if status >= 500:
self.logger.exception("nanobot feature action '{}' failed", action)
return self._error_response(status, message)
payload = await self._apply_nanobot_feature_runtime_change(
action,
self._query(request),
payload,
)
return self._json_response(self._with_restart_state(payload, section="runtime"))
async def _apply_nanobot_feature_runtime_change(
self,
action: str,
query: QueryParams,
payload: dict[str, Any],
) -> dict[str, Any]:
if self._channel_feature_action is None:
return payload
name = (_query_first(query, "name") or "").strip()
if not name:
return payload
try:
instance_id = (_query_first(query, "instance_id") or "").strip()
runtime_name = name
if name == "feishu" and instance_id and instance_id != "default":
runtime_name = f"feishu.{instance_id}"
result = self._channel_feature_action(action, runtime_name)
if inspect.isawaitable(result):
result = await result
except Exception as exc:
self.logger.exception("failed to apply channel '{}' without restart", name)
return self._feature_runtime_fallback(
payload,
message=f"{name} channel config was saved, but hot reload failed: {exc}",
)
if not isinstance(result, dict) or not result.get("handled"):
return payload
payload = dict(payload)
if result.get("requires_restart"):
payload["requires_restart"] = True
else:
payload["requires_restart"] = False
message = result.get("message")
if isinstance(message, str) and message:
last_action = dict(payload.get("last_action") or {})
previous = last_action.get("message")
if isinstance(previous, str) and previous:
last_action["message"] = f"{previous}. {message}"
else:
last_action["message"] = message
last_action["hot_reload"] = not payload["requires_restart"]
payload["last_action"] = last_action
return payload
@staticmethod
def _feature_runtime_fallback(payload: dict[str, Any], *, message: str) -> dict[str, Any]:
payload = dict(payload)
payload["requires_restart"] = True
last_action = dict(payload.get("last_action") or {})
previous = last_action.get("message")
last_action["message"] = f"{previous}. {message}" if isinstance(previous, str) and previous else message
last_action["hot_reload"] = False
payload["last_action"] = last_action
return payload
async def _handle_settings_channel_configure(
self,
connection: Any,
request: WsRequest,
) -> Response:
if not self._authorized(request):
return self._unauthorized()
query = self._query(request)
name = (_query_first(query, "name") or "").strip()
instance_id = (_query_first(query, "instance_id") or "default").strip()
enable = (_query_first(query, "enable") or "").strip().lower() in {"1", "true", "yes"}
try:
saved = await asyncio.to_thread(
self._save_channel_config_values,
name,
self._parse_channel_values_header(request),
instance_id,
)
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
except Exception:
self.logger.exception("failed to save channel '{}' settings", name)
return self._error_response(500, "failed to save channel settings")
payload: dict[str, Any] = {
"name": name,
"saved": True,
"saved_keys": saved,
}
if not enable:
return self._json_response(payload)
feature_query = {"name": [name]}
if name == "feishu":
feature_query["instance_id"] = [instance_id]
try:
features = await asyncio.to_thread(
nanobot_features_action,
"enable",
feature_query,
allow_install=self._allow_feature_package_install(connection, request),
)
except OptionalFeatureError as e:
return self._error_response(e.status, f"Settings saved, but {e.message}")
except Exception as e:
self.logger.exception("failed to enable channel '{}' after settings save", name)
return self._error_response(500, f"Settings saved, but enabling {name} failed: {e}")
features = await self._apply_nanobot_feature_runtime_change(
"enable",
feature_query,
features,
)
payload["nanobot_features"] = self._with_restart_state(features, section="runtime")
return self._json_response(payload)
async def _handle_settings_channel_validate(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
query = self._query(request)
name = (_query_first(query, "name") or "").strip()
instance_id = (_query_first(query, "instance_id") or "default").strip()
try:
payload = await asyncio.to_thread(
validate_channel_config,
name,
self._parse_channel_values_header(request),
instance_id=instance_id,
)
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
except Exception:
self.logger.exception("failed to validate channel '{}' settings", name)
return self._error_response(500, "failed to validate channel settings")
return self._json_response(payload)
def _parse_channel_values_header(self, request: WsRequest) -> dict[str, Any]:
raw = request.headers.get(_CHANNEL_VALUES_HEADER)
if not raw:
return {}
if len(raw.encode("utf-8")) > _CHANNEL_VALUES_HEADER_MAX_BYTES:
raise WebUISettingsError("channel settings payload is too large")
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise WebUISettingsError("invalid channel settings payload") from exc
if not isinstance(payload, dict):
raise WebUISettingsError("channel settings payload must be a JSON object")
return payload
def _save_channel_config_values(
self,
name: str,
raw_values: dict[str, Any],
instance_id: str = "default",
) -> list[str]:
if not name:
raise WebUISettingsError("missing channel name")
setup_spec = channel_setup_spec(name)
if setup_spec is None:
raise WebUISettingsError(f"channel '{name}' cannot be configured from WebUI", status=404)
field_types = setup_spec.route_field_types
if not raw_values:
return []
config = load_config()
section = getattr(config.channels, name, None)
if name == "feishu":
from nanobot.channels._feishu_instances import feishu_instance_specs
from nanobot.channels.feishu import FeishuChannel
specs = feishu_instance_specs(section, FeishuChannel.default_config())
selected = next((spec for spec in specs if spec.instance_id == instance_id), None)
channel_config = dict(selected.config) if selected is not None else {}
elif hasattr(section, "model_dump"):
channel_config = section.model_dump(mode="json", by_alias=True)
elif isinstance(section, dict):
channel_config = dict(section)
else:
channel_config = {}
saved: list[str] = []
prefix = f"channels.{name}."
for raw_key, raw_value in raw_values.items():
if not isinstance(raw_key, str) or not raw_key:
raise WebUISettingsError("channel settings payload contains an invalid key")
field = raw_key[len(prefix):] if raw_key.startswith(prefix) else raw_key
value_type = field_types.get(field)
if value_type is None:
raise WebUISettingsError(f"'{raw_key}' cannot be configured from WebUI")
value = self._coerce_channel_value(raw_key, raw_value, value_type)
if value is _SKIP_FIELD:
continue
self._assign_channel_config_value(channel_config, field, value)
saved.append(raw_key)
if name == "feishu":
from nanobot.channels._feishu_instances import upsert_feishu_instance
from nanobot.channels.feishu import FeishuChannel
existing = getattr(config.channels, name, None)
channel_config = upsert_feishu_instance(
existing if isinstance(existing, dict) else {},
FeishuChannel.default_config(),
instance_id,
channel_config,
)
setattr(config.channels, name, channel_config)
save_config(config)
return saved
@staticmethod
def _coerce_channel_value(raw_key: str, raw_value: Any, value_type: Any) -> Any:
if isinstance(value_type, tuple):
kind = value_type[0]
allowed = value_type[1]
else:
kind = value_type
allowed = None
if kind in {"string", "secret"}:
value = raw_value.strip() if isinstance(raw_value, str) else str(raw_value)
if kind == "secret" and not value:
return _SKIP_FIELD
return value
if kind == "list":
if raw_value is None:
return []
if isinstance(raw_value, str):
return [item.strip() for item in raw_value.split(",") if item.strip()]
if isinstance(raw_value, list):
return [str(item).strip() for item in raw_value if str(item).strip()]
raise WebUISettingsError(f"'{raw_key}' must be a comma-separated list")
if kind == "int":
if raw_value in (None, ""):
return _SKIP_FIELD
try:
return int(raw_value)
except (TypeError, ValueError) as exc:
raise WebUISettingsError(f"'{raw_key}' must be a number") from exc
if kind == "bool":
if isinstance(raw_value, bool):
return raw_value
value = str(raw_value).strip().lower()
if value in {"true", "1", "yes", "on"}:
return True
if value in {"false", "0", "no", "off"}:
return False
raise WebUISettingsError(f"'{raw_key}' must be true or false")
if kind == "enum":
value = raw_value.strip() if isinstance(raw_value, str) else str(raw_value)
if not value:
return _SKIP_FIELD
if value not in allowed:
options = ", ".join(sorted(allowed))
raise WebUISettingsError(f"'{raw_key}' must be one of: {options}")
return value
raise WebUISettingsError(f"'{raw_key}' has an unsupported field type")
@staticmethod
def _assign_channel_config_value(channel_config: dict[str, Any], field: str, value: Any) -> None:
target = channel_config
parts = field.split(".")
for part in parts[:-1]:
current = target.get(part)
if not isinstance(current, dict):
current = {}
target[part] = current
target = current
target[parts[-1]] = value
async def _handle_settings_feishu_connect_start(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
query = self._query(request)
domain = (_query_first(query, "domain") or "feishu").strip()
instance_id = (_query_first(query, "instance_id") or "default").strip()
mode = (_query_first(query, "mode") or "replace").strip()
try:
payload = await asyncio.to_thread(
self._feishu_connect.start,
domain=domain,
instance_id=instance_id,
mode=mode,
)
except ChannelConnectError as e:
return self._error_response(e.status, e.message)
except Exception:
self.logger.exception("failed to start Feishu WebUI connect")
return self._error_response(500, "failed to start Feishu connection")
return self._json_response(payload)
async def _handle_settings_feishu_connect_poll(
self,
connection: Any,
request: WsRequest,
) -> Response:
if not self._authorized(request):
return self._unauthorized()
session_id = (_query_first(self._query(request), "session_id") or "").strip()
if not session_id:
return self._error_response(400, "missing Feishu connect session")
try:
payload = await asyncio.to_thread(self._feishu_connect.poll, session_id)
except Exception:
self.logger.exception("failed to poll Feishu WebUI connect")
return self._error_response(500, "failed to poll Feishu connection")
if payload.get("status") == "succeeded":
try:
features = await asyncio.to_thread(
nanobot_features_action,
"enable",
{
"name": ["feishu"],
"instance_id": [str(payload.get("instance_id") or "default")],
},
allow_install=self._allow_feature_package_install(connection, request),
)
except OptionalFeatureError as exc:
features = self._feature_runtime_fallback(
nanobot_features_payload(),
message=f"Feishu connected, but enabling channel support failed: {exc.message}",
)
else:
features = await self._apply_nanobot_feature_runtime_change(
"enable",
{
"name": ["feishu"],
"instance_id": [str(payload.get("instance_id") or "default")],
},
features,
)
payload = dict(payload)
payload["nanobot_features"] = self._with_restart_state(features, section="runtime")
return self._json_response(payload)
def _handle_settings_feishu_connect_cancel(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
session_id = (_query_first(self._query(request), "session_id") or "").strip()
if not session_id:
return self._error_response(400, "missing Feishu connect session")
return self._json_response(self._feishu_connect.cancel(session_id))
async def _handle_settings_weixin_connect_start(
self,
connection: Any,
request: WsRequest,
) -> Response:
if not self._authorized(request):
return self._unauthorized()
force = (_query_first(self._query(request), "force") or "").strip().lower() in {
"1",
"true",
"yes",
}
try:
payload = await self._weixin_connect.start(force=force)
except ChannelConnectError as e:
return self._error_response(e.status, e.message)
except Exception:
self.logger.exception("failed to start WeChat WebUI connect")
return self._error_response(500, "failed to start WeChat connection")
if payload.get("status") == "succeeded":
payload = await self._with_channel_connect_success(
connection,
request,
"weixin",
payload,
)
return self._json_response(payload)
async def _handle_settings_weixin_connect_poll(
self,
connection: Any,
request: WsRequest,
) -> Response:
if not self._authorized(request):
return self._unauthorized()
session_id = (_query_first(self._query(request), "session_id") or "").strip()
if not session_id:
return self._error_response(400, "missing WeChat connect session")
try:
payload = await self._weixin_connect.poll(session_id)
except Exception:
self.logger.exception("failed to poll WeChat WebUI connect")
return self._error_response(500, "failed to poll WeChat connection")
if payload.get("status") == "succeeded":
payload = await self._with_channel_connect_success(
connection,
request,
"weixin",
payload,
)
return self._json_response(payload)
async def _handle_settings_weixin_connect_cancel(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
session_id = (_query_first(self._query(request), "session_id") or "").strip()
if not session_id:
return self._error_response(400, "missing WeChat connect session")
return self._json_response(await self._weixin_connect.cancel(session_id))
async def _with_channel_connect_success(
self,
connection: Any,
request: WsRequest,
channel_name: str,
payload: dict[str, Any],
) -> dict[str, Any]:
try:
features = await asyncio.to_thread(
nanobot_features_action,
"enable",
{"name": [channel_name]},
allow_install=self._allow_feature_package_install(connection, request),
)
except OptionalFeatureError as exc:
features = self._feature_runtime_fallback(
nanobot_features_payload(),
message=(
f"{channel_name} connected, but enabling channel support failed: "
f"{exc.message}"
),
)
else:
features = await self._apply_nanobot_feature_runtime_change(
"enable",
{"name": [channel_name]},
features,
)
payload = dict(payload)
payload["nanobot_features"] = self._with_restart_state(features, section="runtime")
return payload
def _allow_feature_package_install(self, connection: Any, request: WsRequest) -> bool:
if _is_local_browser_request(connection, request.headers):
return True
try:
return bool(load_config().tools.webui_allow_remote_package_install)
except Exception:
self.logger.exception("failed to load remote package install policy")
return False
async def _handle_settings_mcp_presets(
self,
request: WsRequest,
action: str | None = None,
) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
payload = await mcp_presets_settings_action(
action,
self._parse_mcp_settings_query(request),
reload_mcp=lambda: request_mcp_reload(self.bus),
)
except Exception as e:
status = getattr(e, "status", 500)
message = getattr(e, "message", str(e))
if status >= 500:
self.logger.exception("MCP preset action '{}' failed", action or "list")
return self._error_response(status, message)
if action is None:
return self._json_response(payload)
return self._json_response(self._with_restart_state(payload, section="runtime"))
async def _handle_settings_version_check(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
update_info = await asyncio.to_thread(check_for_update)
except Exception:
self.logger.exception("version check failed")
return self._error_response(500, "version check failed")
return self._json_response({
"updateAvailable": update_info,
})
def _pairing_payload(last_action: dict[str, Any] | None = None) -> dict[str, Any]:
now = time.time()
requests = []
for item in list_pending():
expires_at = float(item.get("expires_at", 0) or 0)
created_at = float(item.get("created_at", 0) or 0)
requests.append({
"code": str(item.get("code", "")),
"channel": str(item.get("channel", "")),
"sender_id": str(item.get("sender_id", "")),
"created_at_ms": int(created_at * 1000) if created_at else None,
"expires_at_ms": int(expires_at * 1000) if expires_at else None,
"expires_in_seconds": max(0, int(expires_at - now)) if expires_at else None,
})
payload: dict[str, Any] = {"requests": requests}
if last_action is not None:
payload["last_action"] = last_action
return payload