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
+22 -16
View File
@@ -728,22 +728,24 @@ def _onboard_plugins(config_path: Path) -> None:
"""Inject default config for all discovered channels (built-in + plugins)."""
import json
from nanobot.channels.registry import discover_all
from nanobot.channels.contracts import channel_default_config
from nanobot.channels.registry import discover_plugins
from nanobot.config.loader import merge_missing_defaults
all_channels = discover_all()
if not all_channels:
plugins = discover_plugins()
if not plugins:
return
with open(config_path, encoding="utf-8") as f:
data = json.load(f)
channels = data.setdefault("channels", {})
for name, cls in all_channels.items():
for name, plugin in plugins.items():
defaults = channel_default_config(plugin)
if name not in channels:
channels[name] = cls.default_config()
channels[name] = defaults
else:
channels[name] = merge_missing_defaults(channels[name], cls.default_config())
channels[name] = merge_missing_defaults(channels[name], defaults)
with open(config_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
@@ -751,8 +753,7 @@ def _onboard_plugins(config_path: Path) -> None:
def _print_enable_options(
extras: dict[str, list[str] | None],
builtin_channels: set[str],
plugin_channels: dict[str, Any],
channel_plugins: dict[str, Any],
config: Config,
) -> None:
table = Table(title="Available Features")
@@ -760,10 +761,16 @@ def _print_enable_options(
table.add_column("Type")
table.add_column("Enabled")
for item in sorted(builtin_channels | set(plugin_channels) | set(extras)):
is_channel = item in builtin_channels or item in plugin_channels
for item in sorted(set(channel_plugins) | set(extras)):
plugin = channel_plugins.get(item)
is_channel = plugin is not None
enabled = (
feature_support.channel_enabled(config, item)
feature_support.channel_enabled(
config,
item,
plugin,
default_enabled=plugin.default_enabled,
)
if is_channel
else feature_support.extra_installed(item, extras[item])
)
@@ -931,7 +938,7 @@ def _provider_setup_error(config: Config) -> str | None:
def _webui_config_dict(config: Config) -> dict[str, Any]:
"""Return the current WebSocket config as a mutable alias-key dictionary."""
from nanobot.channels.websocket import WebSocketConfig
from nanobot.channels.websocket.runtime import WebSocketConfig
current = getattr(config.channels, "websocket", None) or {}
model = WebSocketConfig.model_validate(current)
@@ -939,7 +946,7 @@ def _webui_config_dict(config: Config) -> dict[str, Any]:
def _webui_channel_enabled(config: Config) -> bool:
from nanobot.channels.websocket import WebSocketConfig
from nanobot.channels.websocket.runtime import WebSocketConfig
current = getattr(config.channels, "websocket", None) or {}
return bool(WebSocketConfig.model_validate(current).enabled)
@@ -1044,7 +1051,7 @@ def _webui_display_url(url: str) -> str:
def _ensure_local_webui_channel(config: Config, *, port: int | None, yes: bool) -> tuple[bool, bool]:
"""Enable the local WebUI channel with safe localhost defaults."""
from nanobot.channels.websocket import WebSocketConfig
from nanobot.channels.websocket.runtime import WebSocketConfig
current = getattr(config.channels, "websocket", None) or {}
model = WebSocketConfig.model_validate(current)
@@ -2515,7 +2522,7 @@ def plugins_list(
config_path: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
):
"""List optional nanobot features."""
from nanobot.channels.registry import discover_channel_names, discover_plugins
from nanobot.channels.registry import discover_plugins
from nanobot.config.loader import load_config, set_config_path
resolved_config_path = Path(config_path).expanduser().resolve() if config_path else None
@@ -2524,7 +2531,6 @@ def plugins_list(
_print_enable_options(
feature_support.optional_dependency_groups(),
set(discover_channel_names()),
discover_plugins(),
load_config(resolved_config_path),
)
+1 -1
View File
@@ -1272,7 +1272,7 @@ def _get_channel_info() -> dict[str, tuple[str, type[BaseModel]]]:
result: dict[str, tuple[str, type[BaseModel]]] = {}
for name, channel_cls in discover_all().items():
try:
mod = importlib.import_module(f"nanobot.channels.{name}")
mod = importlib.import_module(channel_cls.__module__)
config_name = channel_cls.__name__.replace("Channel", "Config")
config_cls = getattr(mod, config_name, None)
if config_cls and isinstance(config_cls, type) and issubclass(config_cls, BaseModel):