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:
@@ -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",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user