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:
chengyongru
2026-07-19 23:30:49 +08:00
committed by GitHub
parent 7aaac37bca
commit 462a0dfb0f
388 changed files with 17093 additions and 5110 deletions
+111 -179
View File
@@ -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