* feat: add optional nanobot features * test: update azure install hint expectation * fix: validate optional feature extras maintainer edit: verify requested dependency extras before treating optional features as installed, propagate restart state from feature enablement, and align docs with the new plugins enable command. * fix: bound optional feature installs maintainer edit: make optional feature installs time out as a normal install failure instead of leaving the WebUI or CLI action waiting indefinitely. * feat: slim optional channel dependencies * fix: log optional install commands * fix(webui): gate remote feature installs * docs: clarify webhook plugin example * fix(webui): harden optional feature installs * fix: install optional deps without package fallback * fix(cli): refine plugin feature controls * fix(webui): count enabled nanobot features * fix(webui): allow slow feature install routes * fix(webui): allow disabling websocket channel * fix(plugins): simplify optional feature controls * fix(webui): polish apps catalog states * fix(webui): confirm nanobot support installs * fix(webui): polish nanobot install dialog * fix(webui): suppress empty websocket handshakes * fix(webui): clarify apps plugin summary * fix(webui): localize workspace access copy * fix(plugins): polish optional feature controls (#4691) --------- Co-authored-by: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
102 lines
3.6 KiB
Python
102 lines
3.6 KiB
Python
"""Auto-discovery for built-in channel modules and external plugins."""
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import pkgutil
|
|
from typing import TYPE_CHECKING
|
|
|
|
from loguru import logger
|
|
|
|
if TYPE_CHECKING:
|
|
from nanobot.channels.base import BaseChannel
|
|
|
|
_INTERNAL = frozenset({"base", "manager", "registry"})
|
|
DEFAULT_ENABLED_CHANNELS = frozenset({"websocket"})
|
|
|
|
|
|
def discover_channel_names() -> list[str]:
|
|
"""Return all built-in channel module names by scanning the package (zero imports)."""
|
|
import nanobot.channels as pkg
|
|
|
|
return [
|
|
name
|
|
for _, name, ispkg in pkgutil.iter_modules(pkg.__path__)
|
|
if name not in _INTERNAL and not ispkg
|
|
]
|
|
|
|
|
|
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:
|
|
continue
|
|
try:
|
|
cls = ep.load()
|
|
plugins[ep.name] = cls
|
|
except Exception as e:
|
|
logger.warning("Failed to load channel plugin '{}': {}", ep.name, e)
|
|
return plugins
|
|
|
|
|
|
def discover_enabled(
|
|
enabled_names: set[str],
|
|
*,
|
|
_names: list[str] | None = None,
|
|
_include_all_external: bool = False,
|
|
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()
|
|
result: dict[str, type[BaseChannel]] = {}
|
|
for modname in names:
|
|
if modname not in enabled_names:
|
|
continue
|
|
try:
|
|
result[modname] = load_channel_class(modname)
|
|
except ImportError as e:
|
|
message = "Enabled built-in channel '{}' is not available: {}"
|
|
if warn_import_errors:
|
|
logger.warning(message, modname, e)
|
|
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})
|
|
|
|
return result
|
|
|
|
|
|
def discover_all() -> dict[str, type[BaseChannel]]:
|
|
"""Return all channels: built-in (pkgutil) merged with external (entry_points).
|
|
|
|
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)
|