refactor(channels): make built-in channels self-contained (#4908)
* refactor(channels): own setup and instance contracts * refactor(channels): isolate management contracts * refactor(channels): normalize activation contracts * fix(channels): enforce management contracts * refactor(channels): finish setup ownership migration * fix(channels): harden management contracts * fix(channels): enforce lazy loading and runtime ownership * fix(feishu): make multi-instance startup idempotent * fix(webui): render channel setup contracts cleanly * fix(feishu): stop websocket clients cleanly * fix(channels): enforce persistence and activation gates * fix(channels): preserve global feature action scope * fix(channels): apply defaults for single plugins * fix(channels): enforce management contract boundaries * refactor(feishu): remove identity helper indirection * fix(channels): preserve management setup contracts * refactor(channels): generalize instance settings UI * refactor(channels): package channel plugins with web UI metadata * refactor(channels): make built-ins self-contained packages * test(channels): colocate tests with channel packages * fix(dingtalk): use official brand icon * feat(channels): colocate webui translations * docs(channels): clarify plugin ownership * test(exec): remove output wait race * refactor(channels): unify plugin descriptors * fix(channels): enforce descriptor-owned contracts * refactor(channels): finish package-owned plugin setup * refactor(channels): use repository-owned packages only * fix(channels): self-describe dependencies and runtime state * fix(channels): warn about legacy entry points
This commit is contained in:
+111
-179
@@ -21,24 +21,30 @@ 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.channels.connect import ChannelConnectError
|
||||
from nanobot.channels.contracts import (
|
||||
channel_instance_config,
|
||||
channel_update_instance_config,
|
||||
)
|
||||
from nanobot.channels.registry import load_channel_plugin
|
||||
from nanobot.channels.validation import validate_channel_config
|
||||
from nanobot.config.loader import get_config_path, load_config, save_config
|
||||
from nanobot.optional_features import (
|
||||
OptionalFeatureError,
|
||||
extra_installed,
|
||||
optional_dependency_groups,
|
||||
with_channel_runtime_status,
|
||||
)
|
||||
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.nanobot_features_api import (
|
||||
nanobot_feature_instance_target,
|
||||
nanobot_features_action,
|
||||
nanobot_features_payload,
|
||||
)
|
||||
from nanobot.webui.settings_api import (
|
||||
WebUISettingsError,
|
||||
create_model_configuration,
|
||||
@@ -69,6 +75,18 @@ _API_SERVICE_VALUES_HEADER = "X-Nanobot-API-Service-Values"
|
||||
_API_SERVICE_VALUES_HEADER_MAX_BYTES = 8 * 1024
|
||||
|
||||
_SKIP_FIELD = object()
|
||||
_CHANNEL_CONNECT_ACTIONS = frozenset({"start", "poll", "cancel"})
|
||||
|
||||
|
||||
def _channel_connect_route(path: str) -> tuple[str, str] | None:
|
||||
prefix = "/api/settings/channels/"
|
||||
if not path.startswith(prefix):
|
||||
return None
|
||||
parts = path.removeprefix(prefix).split("/")
|
||||
if len(parts) != 3 or parts[1] != "connect" or parts[2] not in _CHANNEL_CONNECT_ACTIONS:
|
||||
return None
|
||||
channel_name = parts[0].strip()
|
||||
return (channel_name, parts[2]) if channel_name else None
|
||||
|
||||
_MCP_PRESET_ACTIONS_BY_PATH = {
|
||||
"/api/settings/mcp-presets/enable": "enable",
|
||||
@@ -96,6 +114,7 @@ class WebUISettingsRouter:
|
||||
runtime_surface: str,
|
||||
runtime_capabilities: dict[str, Any],
|
||||
channel_feature_action: Callable[..., Any] | None = None,
|
||||
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
|
||||
) -> None:
|
||||
self.bus = bus
|
||||
self.logger = logger
|
||||
@@ -106,9 +125,9 @@ class WebUISettingsRouter:
|
||||
self._runtime_surface = runtime_surface
|
||||
self._runtime_capabilities = runtime_capabilities
|
||||
self._channel_feature_action = channel_feature_action
|
||||
self._channel_runtime_status = channel_runtime_status
|
||||
self._restart_sections: set[str] = set()
|
||||
self._feishu_connect = FeishuConnectStore()
|
||||
self._weixin_connect = WeixinConnectStore()
|
||||
self._channel_connectors: dict[str, Any] = {}
|
||||
|
||||
async def dispatch(self, connection: Any, request: WsRequest, path: str) -> Response | None:
|
||||
if path == "/api/settings":
|
||||
@@ -159,18 +178,15 @@ class WebUISettingsRouter:
|
||||
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)
|
||||
channel_connect = _channel_connect_route(path)
|
||||
if channel_connect is not None:
|
||||
channel_name, action = channel_connect
|
||||
return await self._handle_settings_channel_connect(
|
||||
connection,
|
||||
request,
|
||||
channel_name,
|
||||
action,
|
||||
)
|
||||
if path == "/api/settings/channels/validate":
|
||||
return await self._handle_settings_channel_validate(request)
|
||||
if path == "/api/settings/channels/configure":
|
||||
@@ -574,7 +590,7 @@ class WebUISettingsRouter:
|
||||
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)
|
||||
return self._json_response(self._with_channel_runtime_status(payload))
|
||||
|
||||
async def _handle_settings_nanobot_features_action(
|
||||
self,
|
||||
@@ -605,8 +621,18 @@ class WebUISettingsRouter:
|
||||
self._query(request),
|
||||
payload,
|
||||
)
|
||||
payload = self._with_channel_runtime_status(payload)
|
||||
return self._json_response(self._with_restart_state(payload, section="runtime"))
|
||||
|
||||
def _with_channel_runtime_status(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
if self._channel_runtime_status is None:
|
||||
return payload
|
||||
try:
|
||||
return with_channel_runtime_status(payload, self._channel_runtime_status())
|
||||
except Exception:
|
||||
self.logger.exception("failed to load channel runtime status")
|
||||
return payload
|
||||
|
||||
async def _apply_nanobot_feature_runtime_change(
|
||||
self,
|
||||
action: str,
|
||||
@@ -621,11 +647,8 @@ class WebUISettingsRouter:
|
||||
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)
|
||||
instance_id = nanobot_feature_instance_target(query)
|
||||
result = self._channel_feature_action(action, name, instance_id)
|
||||
if inspect.isawaitable(result):
|
||||
result = await result
|
||||
except Exception as exc:
|
||||
@@ -653,6 +676,8 @@ class WebUISettingsRouter:
|
||||
else:
|
||||
last_action["message"] = message
|
||||
last_action["hot_reload"] = not payload["requires_restart"]
|
||||
if "ok" in result:
|
||||
last_action["ok"] = bool(result["ok"])
|
||||
payload["last_action"] = last_action
|
||||
return payload
|
||||
|
||||
@@ -697,10 +722,13 @@ class WebUISettingsRouter:
|
||||
"saved_keys": saved,
|
||||
}
|
||||
if not enable:
|
||||
features = await asyncio.to_thread(nanobot_features_payload)
|
||||
features = self._with_channel_runtime_status(features)
|
||||
payload["nanobot_features"] = self._with_restart_state(features, section="runtime")
|
||||
return self._json_response(payload)
|
||||
|
||||
feature_query = {"name": [name]}
|
||||
if name == "feishu":
|
||||
if instance_id:
|
||||
feature_query["instance_id"] = [instance_id]
|
||||
|
||||
try:
|
||||
@@ -721,6 +749,7 @@ class WebUISettingsRouter:
|
||||
feature_query,
|
||||
features,
|
||||
)
|
||||
features = self._with_channel_runtime_status(features)
|
||||
payload["nanobot_features"] = self._with_restart_state(features, section="runtime")
|
||||
return self._json_response(payload)
|
||||
|
||||
@@ -766,7 +795,11 @@ class WebUISettingsRouter:
|
||||
) -> list[str]:
|
||||
if not name:
|
||||
raise WebUISettingsError("missing channel name")
|
||||
setup_spec = channel_setup_spec(name)
|
||||
try:
|
||||
plugin = load_channel_plugin(name)
|
||||
except ImportError:
|
||||
raise WebUISettingsError(f"unknown channel '{name}'", status=404) from None
|
||||
setup_spec = channel_setup_spec(name, plugin=plugin)
|
||||
if setup_spec is None:
|
||||
raise WebUISettingsError(f"channel '{name}' cannot be configured from WebUI", status=404)
|
||||
field_types = setup_spec.route_field_types
|
||||
@@ -775,19 +808,11 @@ class WebUISettingsRouter:
|
||||
|
||||
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 = {}
|
||||
channel_config = channel_instance_config(
|
||||
plugin,
|
||||
section,
|
||||
instance_id=instance_id,
|
||||
)
|
||||
|
||||
saved: list[str] = []
|
||||
prefix = f"channels.{name}."
|
||||
@@ -804,19 +829,19 @@ class WebUISettingsRouter:
|
||||
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,
|
||||
try:
|
||||
updated_section = channel_update_instance_config(
|
||||
plugin,
|
||||
section,
|
||||
channel_config,
|
||||
instance_id=instance_id,
|
||||
)
|
||||
|
||||
setattr(config.channels, name, channel_config)
|
||||
except ValueError as exc:
|
||||
raise WebUISettingsError(
|
||||
f"Invalid {name} configuration: {exc}",
|
||||
status=400,
|
||||
) from exc
|
||||
setattr(config.channels, name, updated_section)
|
||||
save_config(config)
|
||||
return saved
|
||||
|
||||
@@ -885,143 +910,46 @@ class WebUISettingsRouter:
|
||||
target = current
|
||||
target[parts[-1]] = value
|
||||
|
||||
async def _handle_settings_feishu_connect_start(self, request: WsRequest) -> Response:
|
||||
async def _handle_settings_channel_connect(
|
||||
self,
|
||||
connection: Any,
|
||||
request: WsRequest,
|
||||
channel_name: str,
|
||||
action: str,
|
||||
) -> 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,
|
||||
connector = self._channel_connectors.get(channel_name)
|
||||
if connector is None:
|
||||
plugin = load_channel_plugin(channel_name)
|
||||
connector = plugin.load_connector()
|
||||
self._channel_connectors[channel_name] = connector
|
||||
except ImportError:
|
||||
return self._error_response(404, f"channel '{channel_name}' does not support connect")
|
||||
|
||||
try:
|
||||
payload = await connector.handle(action, self._query(request))
|
||||
except ChannelConnectError as exc:
|
||||
return self._error_response(exc.status, exc.message)
|
||||
except Exception:
|
||||
self.logger.exception(
|
||||
"failed to run {} WebUI connect action for {}",
|
||||
action,
|
||||
channel_name,
|
||||
)
|
||||
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")
|
||||
return self._error_response(500, f"failed to {action} {channel_name} connection")
|
||||
|
||||
if payload.get("status") == "succeeded":
|
||||
payload = await self._with_channel_connect_success(
|
||||
connection,
|
||||
request,
|
||||
"weixin",
|
||||
channel_name,
|
||||
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,
|
||||
@@ -1029,11 +957,14 @@ class WebUISettingsRouter:
|
||||
channel_name: str,
|
||||
payload: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
target = {"name": [channel_name]}
|
||||
if payload.get("instance_id"):
|
||||
target["instance_id"] = [str(payload["instance_id"])]
|
||||
try:
|
||||
features = await asyncio.to_thread(
|
||||
nanobot_features_action,
|
||||
"enable",
|
||||
{"name": [channel_name]},
|
||||
target,
|
||||
allow_install=self._allow_feature_package_install(connection, request),
|
||||
)
|
||||
except OptionalFeatureError as exc:
|
||||
@@ -1047,9 +978,10 @@ class WebUISettingsRouter:
|
||||
else:
|
||||
features = await self._apply_nanobot_feature_runtime_change(
|
||||
"enable",
|
||||
{"name": [channel_name]},
|
||||
target,
|
||||
features,
|
||||
)
|
||||
features = self._with_channel_runtime_status(features)
|
||||
payload = dict(payload)
|
||||
payload["nanobot_features"] = self._with_restart_state(features, section="runtime")
|
||||
return payload
|
||||
|
||||
Reference in New Issue
Block a user