- Add plugin discovery via Python entry_points (group: nanobot.channels) - Move 11 channel Config classes from schema.py into their own channel modules - ChannelsConfig now only keeps send_progress + send_tool_hints (extra=allow) - Each built-in channel parses dict->Pydantic in __init__, zero internal changes - All channels implement default_config() for onboard auto-population - nanobot onboard injects defaults for all discovered channels (built-in + plugins) - Add nanobot plugins list CLI command - Add Channel Plugin Guide (docs/CHANNEL_PLUGIN_GUIDE.md) - Fully backward compatible: existing config.json and sessions work as-is - 340 tests pass, zero regressions
72 lines
2.3 KiB
Python
72 lines
2.3 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"})
|
|
|
|
|
|
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() -> 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"):
|
|
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_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.
|
|
"""
|
|
builtin: dict[str, type[BaseChannel]] = {}
|
|
for modname in discover_channel_names():
|
|
try:
|
|
builtin[modname] = load_channel_class(modname)
|
|
except ImportError as e:
|
|
logger.debug("Skipping built-in channel '{}': {}", modname, e)
|
|
|
|
external = discover_plugins()
|
|
shadowed = set(external) & set(builtin)
|
|
if shadowed:
|
|
logger.warning("Plugin(s) shadowed by built-in channels (ignored): {}", shadowed)
|
|
|
|
return {**external, **builtin}
|