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:
+350
-197
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from importlib.metadata import PackageNotFoundError, distribution
|
||||
from pathlib import Path
|
||||
@@ -14,19 +13,20 @@ from loguru import logger
|
||||
from packaging.requirements import Requirement
|
||||
from packaging.utils import canonicalize_name
|
||||
|
||||
from nanobot.channels._feishu_instances import (
|
||||
DEFAULT_INSTANCE_ID,
|
||||
feishu_instance_specs,
|
||||
set_feishu_instance_enabled,
|
||||
)
|
||||
from nanobot.channels._setup import (
|
||||
from nanobot.channels._setup import channel_setup_spec
|
||||
from nanobot.channels.contracts import (
|
||||
ChannelSetupSpec,
|
||||
channel_feature_instances,
|
||||
channel_field_value,
|
||||
channel_setup_spec,
|
||||
channel_instance_specs,
|
||||
channel_local_state_present,
|
||||
channel_set_config_enabled,
|
||||
channel_value_present,
|
||||
refresh_channel_feature_metadata,
|
||||
resolve_channel_action_target,
|
||||
stringify_channel_value,
|
||||
)
|
||||
from nanobot.channels.registry import DEFAULT_ENABLED_CHANNELS
|
||||
from nanobot.config.loader import merge_missing_defaults
|
||||
from nanobot.channels.registry import channel_default_enabled
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
|
||||
@@ -279,80 +279,68 @@ def write_config_data(path: Path, data: dict[str, Any]) -> None:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def enable_channel_config(config_path: Path, channel_name: str, defaults: dict[str, Any]) -> None:
|
||||
def set_channel_config_enabled(
|
||||
config_path: Path,
|
||||
channel_name: str,
|
||||
plugin: Any,
|
||||
enabled: bool,
|
||||
*,
|
||||
instance_id: str | None = "default",
|
||||
) -> None:
|
||||
"""Persist one instance, or the top-level plugin gate when the target is ``None``."""
|
||||
data = read_config_data(config_path)
|
||||
channels = data.setdefault("channels", {})
|
||||
existing = channels.get(channel_name, {})
|
||||
if not isinstance(existing, dict):
|
||||
existing = {}
|
||||
merged = merge_missing_defaults(existing, defaults)
|
||||
merged["enabled"] = True
|
||||
channels[channel_name] = merged
|
||||
if instance_id is None:
|
||||
existing["enabled"] = enabled
|
||||
channels[channel_name] = existing
|
||||
else:
|
||||
try:
|
||||
channels[channel_name] = channel_set_config_enabled(
|
||||
plugin,
|
||||
existing,
|
||||
enabled,
|
||||
instance_id=instance_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise OptionalFeatureError(
|
||||
f"Invalid {channel_name} configuration: {exc}",
|
||||
status=400,
|
||||
) from exc
|
||||
write_config_data(config_path, data)
|
||||
|
||||
|
||||
def enable_feishu_instance_config(
|
||||
config_path: Path,
|
||||
defaults: dict[str, Any],
|
||||
def channel_enabled(
|
||||
config: Config,
|
||||
name: str,
|
||||
plugin: Any | None = None,
|
||||
*,
|
||||
instance_id: str = DEFAULT_INSTANCE_ID,
|
||||
) -> None:
|
||||
data = read_config_data(config_path)
|
||||
channels = data.setdefault("channels", {})
|
||||
existing = channels.get("feishu", {})
|
||||
if not isinstance(existing, dict):
|
||||
existing = {}
|
||||
channels["feishu"] = set_feishu_instance_enabled(existing, defaults, instance_id, True)
|
||||
write_config_data(config_path, data)
|
||||
|
||||
|
||||
def disable_channel_config(config_path: Path, channel_name: str) -> None:
|
||||
data = read_config_data(config_path)
|
||||
channels = data.setdefault("channels", {})
|
||||
existing = channels.get(channel_name, {})
|
||||
if not isinstance(existing, dict):
|
||||
existing = {}
|
||||
existing["enabled"] = False
|
||||
channels[channel_name] = existing
|
||||
write_config_data(config_path, data)
|
||||
|
||||
|
||||
def disable_feishu_instance_config(
|
||||
config_path: Path,
|
||||
defaults: dict[str, Any],
|
||||
*,
|
||||
instance_id: str = DEFAULT_INSTANCE_ID,
|
||||
) -> None:
|
||||
data = read_config_data(config_path)
|
||||
channels = data.setdefault("channels", {})
|
||||
existing = channels.get("feishu", {})
|
||||
if not isinstance(existing, dict):
|
||||
existing = {}
|
||||
channels["feishu"] = set_feishu_instance_enabled(existing, defaults, instance_id, False)
|
||||
write_config_data(config_path, data)
|
||||
|
||||
|
||||
def channel_enabled(config: Config, name: str) -> bool:
|
||||
default_enabled: bool | None = None,
|
||||
) -> bool:
|
||||
section = getattr(config.channels, name, None)
|
||||
if name == "feishu":
|
||||
from nanobot.channels.feishu import FeishuChannel
|
||||
|
||||
return bool(feishu_instance_specs(section, FeishuChannel.default_config(), enabled_only=True))
|
||||
default_enabled = name in DEFAULT_ENABLED_CHANNELS
|
||||
if default_enabled is None:
|
||||
default_enabled = plugin.default_enabled if plugin is not None else channel_default_enabled(name)
|
||||
if section is None:
|
||||
return default_enabled
|
||||
if isinstance(section, dict):
|
||||
return bool(section.get("enabled", default_enabled))
|
||||
return bool(getattr(section, "enabled", default_enabled))
|
||||
if plugin is None:
|
||||
from nanobot.channels.registry import load_channel_plugin
|
||||
|
||||
plugin = load_channel_plugin(name)
|
||||
return bool(channel_instance_specs(plugin, section, enabled_only=True))
|
||||
|
||||
|
||||
def _channel_config_snapshot(section: Any, name: str) -> tuple[dict[str, str], list[str]]:
|
||||
def _channel_config_snapshot(
|
||||
section: Any,
|
||||
name: str,
|
||||
spec: ChannelSetupSpec | None,
|
||||
) -> tuple[dict[str, str], list[str]]:
|
||||
if hasattr(section, "model_dump"):
|
||||
section = section.model_dump(mode="json", by_alias=True)
|
||||
if not isinstance(section, dict):
|
||||
return {}, []
|
||||
|
||||
spec = channel_setup_spec(name)
|
||||
if spec is None:
|
||||
return {}, []
|
||||
|
||||
@@ -370,71 +358,58 @@ def _channel_config_snapshot(section: Any, name: str) -> tuple[dict[str, str], l
|
||||
return values, configured_fields
|
||||
|
||||
|
||||
def _channel_has_required_setup(section: Any, name: str) -> bool:
|
||||
spec = channel_setup_spec(name)
|
||||
def _channel_has_required_setup(section: Any, spec: ChannelSetupSpec | None) -> bool:
|
||||
return bool(spec and spec.is_configured(section))
|
||||
|
||||
|
||||
def _local_login_state_present(section: Any, name: str) -> bool:
|
||||
"""Return whether a QR-login channel has reusable local account state."""
|
||||
from nanobot.config.loader import get_config_path
|
||||
|
||||
if name == "weixin":
|
||||
configured_dir = channel_field_value(section, "stateDir")
|
||||
state_dir = (
|
||||
Path(str(configured_dir)).expanduser()
|
||||
if configured_dir
|
||||
else get_config_path().parent / "weixin"
|
||||
)
|
||||
try:
|
||||
payload = json.loads((state_dir / "account.json").read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError, TypeError):
|
||||
return False
|
||||
return bool(str(payload.get("token") or "").strip())
|
||||
|
||||
if name == "whatsapp":
|
||||
configured_path = channel_field_value(section, "databasePath")
|
||||
database_path = (
|
||||
Path(str(configured_path)).expanduser()
|
||||
if configured_path
|
||||
else get_config_path().parent / "whatsapp-auth" / "neonize.db"
|
||||
)
|
||||
try:
|
||||
return database_path.is_file() and database_path.stat().st_size > 0
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _feishu_instance_display_name(config: dict[str, Any]) -> str:
|
||||
display_name = str(config.get("displayName") or "").strip()
|
||||
if display_name:
|
||||
return display_name
|
||||
local_name = str(config.get("name") or "").strip()
|
||||
return local_name or "nanobot"
|
||||
|
||||
|
||||
def channel_configured(config: Config, name: str) -> bool:
|
||||
def channel_configured(
|
||||
config: Config,
|
||||
name: str,
|
||||
spec: ChannelSetupSpec | None = None,
|
||||
plugin: Any | None = None,
|
||||
*,
|
||||
default_enabled: bool | None = None,
|
||||
) -> bool:
|
||||
"""Return whether a channel has enough saved setup to be enabled directly."""
|
||||
section = getattr(config.channels, name, None)
|
||||
if name in {"weixin", "whatsapp"} and _local_login_state_present(section, name):
|
||||
if plugin is None:
|
||||
from nanobot.channels.registry import load_channel_plugin
|
||||
|
||||
plugin = load_channel_plugin(name)
|
||||
|
||||
if channel_local_state_present(plugin, section):
|
||||
return True
|
||||
if section is None:
|
||||
return False
|
||||
|
||||
if name == "feishu":
|
||||
from nanobot.channels.feishu import FeishuChannel
|
||||
|
||||
if plugin.management.multi_instance:
|
||||
return any(
|
||||
_channel_has_required_setup(instance.config, "feishu")
|
||||
for instance in feishu_instance_specs(section, FeishuChannel.default_config())
|
||||
_channel_has_required_setup(instance.config, spec)
|
||||
for instance in channel_instance_specs(
|
||||
plugin,
|
||||
section,
|
||||
enabled_only=False,
|
||||
)
|
||||
)
|
||||
|
||||
spec = channel_setup_spec(name)
|
||||
if not spec or not spec.required:
|
||||
return channel_enabled(config, name)
|
||||
return _channel_has_required_setup(section, name)
|
||||
return channel_enabled(
|
||||
config,
|
||||
name,
|
||||
plugin,
|
||||
default_enabled=default_enabled,
|
||||
)
|
||||
return _channel_has_required_setup(section, spec)
|
||||
|
||||
|
||||
def _feature_dependencies(
|
||||
name: str,
|
||||
channel_plugin: Any | None,
|
||||
extras: dict[str, list[str] | None],
|
||||
) -> list[str] | None:
|
||||
if channel_plugin is not None:
|
||||
return list(channel_plugin.dependencies)
|
||||
return extras.get(name)
|
||||
|
||||
|
||||
def optional_features_payload(
|
||||
@@ -442,72 +417,100 @@ def optional_features_payload(
|
||||
config: Config | None = None,
|
||||
last_action: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
from nanobot.channels.registry import discover_channel_names, discover_plugins
|
||||
from nanobot.channels.registry import discover_plugins
|
||||
from nanobot.config.loader import load_config
|
||||
|
||||
config_provided = config is not None
|
||||
config = config or load_config()
|
||||
if not config_provided:
|
||||
with suppress(Exception):
|
||||
from nanobot.channels.feishu import refresh_saved_feishu_identities
|
||||
|
||||
if refresh_saved_feishu_identities(config):
|
||||
config = load_config()
|
||||
extras = optional_dependency_groups()
|
||||
builtin_channels = set(discover_channel_names())
|
||||
plugin_channels = discover_plugins()
|
||||
channel_plugins = discover_plugins()
|
||||
features: list[dict[str, Any]] = []
|
||||
|
||||
for name in sorted(builtin_channels | set(plugin_channels) | set(extras)):
|
||||
is_channel = name in builtin_channels or name in plugin_channels
|
||||
installed = extra_installed(name, extras[name]) if name in extras else True
|
||||
enabled = channel_enabled(config, name) if is_channel else installed
|
||||
configured = channel_configured(config, name) if is_channel else installed
|
||||
ready = bool(enabled and installed)
|
||||
status = "enabled" if ready else "missing_dependency" if not installed else "not_enabled"
|
||||
feature_names = set(channel_plugins) | set(extras)
|
||||
for name in sorted(feature_names):
|
||||
channel_plugin = channel_plugins.get(name)
|
||||
is_channel = channel_plugin is not None
|
||||
dependencies = _feature_dependencies(name, channel_plugin, extras)
|
||||
has_dependencies = bool(dependencies)
|
||||
installed = extra_installed(name, dependencies) if has_dependencies else True
|
||||
feature = {
|
||||
"name": name,
|
||||
"display_name": name.replace("_", " ").title(),
|
||||
"display_name": (
|
||||
channel_plugin.display_name
|
||||
if channel_plugin is not None
|
||||
else name.replace("_", " ").title()
|
||||
),
|
||||
"type": "channel" if is_channel else "feature",
|
||||
"enabled": enabled,
|
||||
"configured": configured,
|
||||
"installed": installed,
|
||||
"ready": ready,
|
||||
"status": status,
|
||||
"install_supported": name in extras or is_channel,
|
||||
"install_supported": has_dependencies or is_channel,
|
||||
"requires_restart": _feature_requires_restart(name, is_channel=is_channel),
|
||||
}
|
||||
if is_channel:
|
||||
if channel_plugin is not None:
|
||||
feature["capabilities"] = sorted(channel_plugin.capabilities)
|
||||
feature["settings_visible"] = channel_plugin.settings_visible
|
||||
if channel_plugin.webui is not None:
|
||||
feature["webui"] = channel_plugin.webui
|
||||
|
||||
if not is_channel:
|
||||
feature.update({
|
||||
"enabled": installed,
|
||||
"configured": installed,
|
||||
"ready": installed,
|
||||
"status": "enabled" if installed else "missing_dependency",
|
||||
})
|
||||
features.append(feature)
|
||||
continue
|
||||
|
||||
try:
|
||||
assert channel_plugin is not None
|
||||
setup_spec = channel_setup_spec(name, plugin=channel_plugin)
|
||||
if setup_spec is not None:
|
||||
feature["setup"] = setup_spec.to_public_dict(name)
|
||||
enabled = channel_enabled(
|
||||
config,
|
||||
name,
|
||||
channel_plugin,
|
||||
default_enabled=channel_plugin.default_enabled,
|
||||
)
|
||||
configured = channel_configured(
|
||||
config,
|
||||
name,
|
||||
setup_spec,
|
||||
channel_plugin,
|
||||
default_enabled=channel_plugin.default_enabled,
|
||||
)
|
||||
ready = bool(enabled and installed)
|
||||
status = "enabled" if ready else "missing_dependency" if not installed else "not_enabled"
|
||||
feature.update({
|
||||
"enabled": enabled,
|
||||
"configured": configured,
|
||||
"ready": ready,
|
||||
"status": status,
|
||||
})
|
||||
config_values, configured_fields = _channel_config_snapshot(
|
||||
getattr(config.channels, name, None),
|
||||
name,
|
||||
setup_spec,
|
||||
)
|
||||
if config_values:
|
||||
feature["config_values"] = config_values
|
||||
if configured_fields:
|
||||
feature["configured_fields"] = configured_fields
|
||||
if name == "feishu" and is_channel:
|
||||
from nanobot.channels.feishu import FeishuChannel
|
||||
|
||||
specs = feishu_instance_specs(
|
||||
getattr(config.channels, "feishu", None),
|
||||
FeishuChannel.default_config(),
|
||||
instances = channel_feature_instances(
|
||||
channel_plugin,
|
||||
getattr(config.channels, name, None),
|
||||
setup_spec=setup_spec,
|
||||
)
|
||||
feature["instances"] = [
|
||||
{
|
||||
"id": spec.instance_id,
|
||||
"name": spec.config.get("name") or "nanobot",
|
||||
"display_name": _feishu_instance_display_name(spec.config),
|
||||
"avatar_url": spec.config.get("avatarUrl") or "",
|
||||
"domain": spec.config.get("domain") or "feishu",
|
||||
"enabled": bool(spec.config.get("enabled", False)),
|
||||
"configured": _channel_has_required_setup(spec.config, "feishu"),
|
||||
"app_id": spec.config.get("appId") or spec.config.get("app_id") or "",
|
||||
"group_policy": spec.config.get("groupPolicy") or "mention",
|
||||
"allow_from": list(spec.config.get("allowFrom") or []),
|
||||
}
|
||||
for spec in specs
|
||||
]
|
||||
if instances is not None:
|
||||
feature["instances"] = instances
|
||||
except Exception as exc:
|
||||
logger.warning("Could not inspect {} channel configuration: {}", name, exc)
|
||||
feature.update({
|
||||
"enabled": False,
|
||||
"configured": False,
|
||||
"ready": False,
|
||||
"status": "invalid_config",
|
||||
"error": "Channel configuration could not be inspected.",
|
||||
})
|
||||
features.append(feature)
|
||||
|
||||
payload = {
|
||||
@@ -519,19 +522,117 @@ def optional_features_payload(
|
||||
return payload
|
||||
|
||||
|
||||
def with_channel_runtime_status(
|
||||
payload: dict[str, Any],
|
||||
runtime_status: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Overlay live ChannelManager state on configuration-derived features."""
|
||||
statuses_by_owner: dict[str, list[dict[str, Any]]] = {}
|
||||
for status in runtime_status.values():
|
||||
if not isinstance(status, dict):
|
||||
continue
|
||||
owner = status.get("owner")
|
||||
if isinstance(owner, str):
|
||||
statuses_by_owner.setdefault(owner, []).append(status)
|
||||
|
||||
features: list[dict[str, Any]] = []
|
||||
for original in payload.get("features", []):
|
||||
feature = dict(original)
|
||||
if feature.get("type") != "channel":
|
||||
features.append(feature)
|
||||
continue
|
||||
|
||||
desired_enabled = bool(feature.get("enabled"))
|
||||
owner_statuses = statuses_by_owner.get(str(feature.get("name")), [])
|
||||
if desired_enabled and not owner_statuses:
|
||||
owner_statuses = [{
|
||||
"state": "failed",
|
||||
"running": False,
|
||||
"error": "Enabled channel has no runtime. Check gateway logs.",
|
||||
}]
|
||||
|
||||
instances = feature.get("instances")
|
||||
if isinstance(instances, list):
|
||||
by_instance = {
|
||||
str(status.get("instance_id", "default")): status
|
||||
for status in owner_statuses
|
||||
}
|
||||
decorated_instances = []
|
||||
for original_instance in instances:
|
||||
instance = dict(original_instance)
|
||||
desired_instance = bool(instance.get("enabled"))
|
||||
status = by_instance.get(str(instance.get("id", "default")))
|
||||
if desired_instance and status is None:
|
||||
status = {
|
||||
"state": "failed",
|
||||
"running": False,
|
||||
"error": "Enabled channel instance has no runtime. Check gateway logs.",
|
||||
}
|
||||
owner_statuses.append(status)
|
||||
state = str(status.get("state", "stopped")) if status else "stopped"
|
||||
instance["runtime_status"] = state
|
||||
instance["running"] = state == "running"
|
||||
if status and status.get("error"):
|
||||
instance["runtime_error"] = str(status["error"])
|
||||
decorated_instances.append(instance)
|
||||
feature["instances"] = decorated_instances
|
||||
|
||||
state = _combined_channel_runtime_state(owner_statuses, desired_enabled)
|
||||
feature["runtime_status"] = state
|
||||
feature["running"] = state == "running"
|
||||
feature["ready"] = state == "running"
|
||||
feature["status"] = "enabled" if state == "running" else state
|
||||
error = next(
|
||||
(
|
||||
str(status["error"])
|
||||
for status in owner_statuses
|
||||
if status.get("error")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if error:
|
||||
feature["runtime_error"] = error
|
||||
features.append(feature)
|
||||
|
||||
decorated = dict(payload)
|
||||
decorated["features"] = features
|
||||
decorated["enabled_count"] = sum(
|
||||
1
|
||||
for feature in features
|
||||
if (
|
||||
feature.get("running")
|
||||
if feature.get("type") == "channel"
|
||||
else feature.get("enabled")
|
||||
)
|
||||
)
|
||||
return decorated
|
||||
|
||||
|
||||
def _combined_channel_runtime_state(
|
||||
statuses: list[dict[str, Any]],
|
||||
desired_enabled: bool,
|
||||
) -> str:
|
||||
if not desired_enabled:
|
||||
return "stopped"
|
||||
states = {str(status.get("state", "stopped")) for status in statuses}
|
||||
if "failed" in states:
|
||||
return "failed"
|
||||
if "running" in states:
|
||||
return "running"
|
||||
if "starting" in states:
|
||||
return "starting"
|
||||
return "stopped"
|
||||
|
||||
|
||||
def enable_optional_feature(
|
||||
name: str,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
allow_install: bool = True,
|
||||
instance_id: str = DEFAULT_INSTANCE_ID,
|
||||
instance_id: str | None = None,
|
||||
runner: Any = run_install_command,
|
||||
) -> dict[str, Any]:
|
||||
from nanobot.channels.registry import (
|
||||
discover_channel_names,
|
||||
discover_plugins,
|
||||
load_channel_class,
|
||||
)
|
||||
from nanobot.channels.registry import discover_plugins
|
||||
from nanobot.config.loader import get_config_path
|
||||
|
||||
if name in _BUNDLED_FEATURE_ALIASES:
|
||||
@@ -545,15 +646,17 @@ def enable_optional_feature(
|
||||
payload["requires_restart"] = False
|
||||
return payload
|
||||
config_path = config_path or get_config_path()
|
||||
requested_instance_id = (instance_id or "").strip() or None
|
||||
extras = optional_dependency_groups()
|
||||
builtin_channels = set(discover_channel_names())
|
||||
plugin_channels = discover_plugins()
|
||||
known = builtin_channels | set(plugin_channels) | set(extras)
|
||||
channel_plugins = discover_plugins()
|
||||
known = set(channel_plugins) | set(extras)
|
||||
if name not in known:
|
||||
available = ", ".join(sorted(known))
|
||||
raise OptionalFeatureError(f"Unknown feature: {name}. Available: {available}", status=404)
|
||||
|
||||
if name in extras and not extra_installed(name, extras[name]):
|
||||
channel_plugin = channel_plugins.get(name)
|
||||
dependencies = _feature_dependencies(name, channel_plugin, extras)
|
||||
if dependencies and not extra_installed(name, dependencies):
|
||||
if not allow_install:
|
||||
raise OptionalFeatureError(
|
||||
"Installing optional features from a remote WebUI is disabled. "
|
||||
@@ -562,7 +665,7 @@ def enable_optional_feature(
|
||||
)
|
||||
result = install_extra(
|
||||
name,
|
||||
extras[name],
|
||||
dependencies,
|
||||
runner=runner,
|
||||
)
|
||||
if not result.ok:
|
||||
@@ -570,33 +673,80 @@ def enable_optional_feature(
|
||||
detail = f": {result.output}" if result.output else ""
|
||||
raise OptionalFeatureError(f"Failed: {failed}{detail}", status=500)
|
||||
|
||||
if name in builtin_channels:
|
||||
channel_cls: Any | None = None
|
||||
target_instance_id: str | None = None
|
||||
if channel_plugin is not None:
|
||||
try:
|
||||
channel_cls = load_channel_class(name)
|
||||
channel_cls = channel_plugin.load_channel_class()
|
||||
except Exception as exc:
|
||||
raise OptionalFeatureError(
|
||||
f"Channel '{name}' is not importable after enable: {exc}",
|
||||
status=500,
|
||||
) from exc
|
||||
if name == "feishu":
|
||||
enable_feishu_instance_config(config_path, channel_cls.default_config(), instance_id=instance_id)
|
||||
else:
|
||||
enable_channel_config(config_path, name, channel_cls.default_config())
|
||||
message = f"Enabled channel '{name}'"
|
||||
elif name in plugin_channels:
|
||||
enable_channel_config(config_path, name, plugin_channels[name].default_config())
|
||||
target_instance_id = resolve_channel_action_target(
|
||||
requested_instance_id,
|
||||
)
|
||||
set_channel_config_enabled(
|
||||
config_path,
|
||||
name,
|
||||
channel_plugin,
|
||||
True,
|
||||
instance_id=target_instance_id,
|
||||
)
|
||||
message = f"Enabled channel '{name}'"
|
||||
else:
|
||||
message = f"Enabled feature '{name}'"
|
||||
|
||||
payload = optional_features_payload(last_action={"ok": True, "message": message, "enabled": True})
|
||||
if channel_cls is not None and target_instance_id is not None:
|
||||
try:
|
||||
refresh_channel_feature_metadata(
|
||||
channel_cls,
|
||||
config_path,
|
||||
instance_id=target_instance_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Could not refresh {} channel metadata: {}", name, exc)
|
||||
|
||||
from nanobot.config.loader import load_config
|
||||
|
||||
payload = optional_features_payload(
|
||||
config=load_config(config_path),
|
||||
last_action={"ok": True, "message": message, "enabled": True},
|
||||
)
|
||||
payload["requires_restart"] = _feature_requires_restart(
|
||||
name,
|
||||
is_channel=name in builtin_channels or name in plugin_channels,
|
||||
is_channel=channel_plugin is not None,
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def ensure_enabled_channel_dependencies(
|
||||
enabled_names: set[str],
|
||||
plugins: dict[str, Any],
|
||||
*,
|
||||
runner: Any = run_install_command,
|
||||
) -> dict[str, str]:
|
||||
"""Install requirements declared by enabled channel manifests.
|
||||
|
||||
Returns user-safe errors keyed by channel name. Detailed installer output
|
||||
remains in gateway logs.
|
||||
"""
|
||||
failures: dict[str, str] = {}
|
||||
for name in sorted(enabled_names):
|
||||
plugin = plugins.get(name)
|
||||
if plugin is None:
|
||||
continue
|
||||
dependencies = list(plugin.dependencies)
|
||||
if not dependencies or extra_installed(name, dependencies):
|
||||
continue
|
||||
result = install_extra(name, dependencies, runner=runner)
|
||||
if result.ok and extra_installed(name, dependencies):
|
||||
continue
|
||||
failures[name] = "Channel dependencies could not be installed. Check gateway logs."
|
||||
logger.error("Could not prepare dependencies for enabled channel '{}'", name)
|
||||
return failures
|
||||
|
||||
|
||||
def _feature_requires_restart(name: str, *, is_channel: bool) -> bool:
|
||||
"""Return whether an installed feature needs the running engine rebuilt."""
|
||||
if is_channel:
|
||||
@@ -609,30 +759,33 @@ def disable_optional_feature(
|
||||
name: str,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
instance_id: str = DEFAULT_INSTANCE_ID,
|
||||
instance_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
from nanobot.channels.registry import discover_channel_names, discover_plugins
|
||||
from nanobot.config.loader import get_config_path
|
||||
from nanobot.channels.registry import discover_plugins
|
||||
from nanobot.config.loader import get_config_path, load_config
|
||||
|
||||
config_path = config_path or get_config_path()
|
||||
requested_instance_id = (instance_id or "").strip() or None
|
||||
extras = optional_dependency_groups()
|
||||
builtin_channels = set(discover_channel_names())
|
||||
plugin_channels = discover_plugins()
|
||||
known_channels = builtin_channels | set(plugin_channels)
|
||||
channel_plugins = discover_plugins()
|
||||
known_channels = set(channel_plugins)
|
||||
known = known_channels | set(extras)
|
||||
if name not in known:
|
||||
available = ", ".join(sorted(known))
|
||||
raise OptionalFeatureError(f"Unknown feature: {name}. Available: {available}", status=404)
|
||||
if name not in known_channels:
|
||||
raise OptionalFeatureError(f"Feature '{name}' cannot be disabled", status=400)
|
||||
if name == "feishu":
|
||||
from nanobot.channels.registry import load_channel_class
|
||||
|
||||
channel_cls = load_channel_class(name)
|
||||
disable_feishu_instance_config(config_path, channel_cls.default_config(), instance_id=instance_id)
|
||||
else:
|
||||
disable_channel_config(config_path, name)
|
||||
channel_plugin = channel_plugins[name]
|
||||
target_instance_id = resolve_channel_action_target(requested_instance_id)
|
||||
set_channel_config_enabled(
|
||||
config_path,
|
||||
name,
|
||||
channel_plugin,
|
||||
False,
|
||||
instance_id=target_instance_id,
|
||||
)
|
||||
payload = optional_features_payload(
|
||||
config=load_config(config_path),
|
||||
last_action={"ok": True, "message": f"Disabled channel '{name}'", "enabled": False}
|
||||
)
|
||||
payload["requires_restart"] = True
|
||||
|
||||
Reference in New Issue
Block a user