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
+84 -67
View File
@@ -1,105 +1,122 @@
"""Auto-discovery for built-in channel modules and external plugins."""
"""Discover channel descriptors and load their runtimes lazily."""
from __future__ import annotations
import importlib
import pkgutil
from functools import cache
from importlib.metadata import entry_points
from typing import TYPE_CHECKING
from loguru import logger
from nanobot.channels.plugin import (
ChannelPlugin,
has_channel_package,
load_channel_package,
)
if TYPE_CHECKING:
from nanobot.channels.base import BaseChannel
_INTERNAL = frozenset({
"base",
"manager",
"registry",
})
DEFAULT_ENABLED_CHANNELS = frozenset({"websocket"})
@cache
def _warn_legacy_channel_entry_points() -> None:
# TODO: Remove this legacy entry-point detection and warning after the migration window.
names = sorted({entry_point.name for entry_point in entry_points(group="nanobot.channels")})
if not names:
return
logger.warning(
"Legacy channel entry points were detected but will not be loaded: {}. "
"The '{}' entry-point group is no longer supported; use a built-in channel or "
"migrate it into nanobot/channels/<channel>/.",
", ".join(names),
"nanobot.channels",
)
def discover_channel_names() -> list[str]:
"""Return all built-in channel module names by scanning the package (zero imports)."""
import nanobot.channels as pkg
def _channel_package_names() -> list[str]:
import nanobot.channels as package
return [
name
for _, name, ispkg in pkgutil.iter_modules(pkg.__path__)
if name not in _INTERNAL and not name.startswith("_") and not ispkg
for _, name, is_package in pkgutil.iter_modules(package.__path__)
if is_package and has_channel_package(name)
]
def load_channel_class(module_name: str) -> type[BaseChannel]:
"""Import *module_name* and return the first BaseChannel subclass found."""
from nanobot.channels.base import BaseChannel as _Base
mod = importlib.import_module(f"nanobot.channels.{module_name}")
for attr in dir(mod):
obj = getattr(mod, attr)
if isinstance(obj, type) and issubclass(obj, _Base) and obj is not _Base:
return obj
raise ImportError(f"No BaseChannel subclass in nanobot.channels.{module_name}")
def discover_plugins(enabled_names: set[str] | None = None) -> dict[str, type[BaseChannel]]:
"""Discover external channel plugins registered via entry_points."""
from importlib.metadata import entry_points
plugins: dict[str, type[BaseChannel]] = {}
for ep in entry_points(group="nanobot.channels"):
if enabled_names is not None and ep.name not in enabled_names:
def discover_plugins(
enabled_names: set[str] | None = None,
) -> dict[str, ChannelPlugin]:
"""Load dependency-free descriptors from self-contained channel packages."""
_warn_legacy_channel_entry_points()
plugins: dict[str, ChannelPlugin] = {}
for name in _channel_package_names():
if enabled_names is not None and name not in enabled_names:
continue
try:
cls = ep.load()
plugins[ep.name] = cls
except Exception as e:
logger.warning("Failed to load channel plugin '{}': {}", ep.name, e)
plugin = load_channel_package(name)
if plugin is not None:
plugins[name] = plugin
except Exception as exc:
logger.warning("Failed to load channel package descriptor '{}': {}", name, exc)
return plugins
def load_channel_plugin(name: str) -> ChannelPlugin:
"""Load one channel package descriptor."""
plugin = discover_plugins({name}).get(name)
if plugin is None:
raise ImportError(f"Unknown channel: {name}")
return plugin
def channel_default_enabled(name: str) -> bool:
"""Return the activation default declared by a channel descriptor."""
try:
return load_channel_plugin(name).default_enabled
except ImportError:
return False
def load_channel_class(name: str) -> type[BaseChannel]:
"""Load the runtime declared by one channel descriptor."""
return load_channel_plugin(name).load_channel_class()
def discover_enabled(
enabled_names: set[str],
*,
_names: list[str] | None = None,
_include_all_external: bool = False,
_plugins: dict[str, ChannelPlugin] | None = None,
warn_import_errors: bool = False,
) -> dict[str, type[BaseChannel]]:
"""Return channels whose module names are in *enabled_names*.
Uses cheap ``pkgutil.iter_modules`` to list names, then imports only
those that match — skipping the heavy third-party SDK imports of
unneeded channels.
"""
names = _names if _names is not None else discover_channel_names()
"""Load runtime classes only for enabled descriptors."""
plugins = _plugins if _plugins is not None else discover_plugins(enabled_names)
result: dict[str, type[BaseChannel]] = {}
for modname in names:
if modname not in enabled_names:
for name, plugin in plugins.items():
if name not in enabled_names:
continue
try:
result[modname] = load_channel_class(modname)
except ImportError as e:
message = "Enabled built-in channel '{}' is not available: {}"
result[name] = plugin.load_channel_class()
except Exception as exc:
message = "Enabled channel '{}' runtime is not available: {}"
if warn_import_errors:
logger.warning(message, modname, e)
logger.warning(message, name, exc)
else:
logger.debug(message, modname, e)
external = discover_plugins(None if _include_all_external else enabled_names)
shadowed = set(external) & set(names)
if shadowed:
logger.warning("Plugin(s) shadowed by built-in channels (ignored): {}", shadowed)
if _include_all_external:
result.update({k: v for k, v in external.items() if k not in shadowed})
else:
result.update({k: v for k, v in external.items() if k not in shadowed and k in enabled_names})
logger.debug(message, name, exc)
return result
def discover_all() -> dict[str, type[BaseChannel]]:
"""Return all channels: built-in (pkgutil) merged with external (entry_points).
"""Load every available channel runtime."""
plugins = discover_plugins()
return discover_enabled(set(plugins), _plugins=plugins)
Built-in channels take priority — an external plugin cannot shadow a built-in name.
"""
names = discover_channel_names()
return discover_enabled(set(names), _names=names, _include_all_external=True)
__all__ = [
"channel_default_enabled",
"discover_all",
"discover_enabled",
"discover_plugins",
"load_channel_class",
"load_channel_plugin",
]