chengyongru 462a0dfb0f
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
2026-07-19 23:30:49 +08:00

187 lines
7.4 KiB
Python

"""Typed metadata for self-contained channel packages."""
from __future__ import annotations
import importlib
import re
from dataclasses import dataclass
from functools import lru_cache
from importlib.resources import files
from typing import TYPE_CHECKING, Any
from packaging.requirements import InvalidRequirement, Requirement
from nanobot.channels.contracts import ChannelManagementSpec, ChannelSetupSpec
if TYPE_CHECKING:
from nanobot.channels.base import BaseChannel
_CHANNEL_PACKAGE_NAME = re.compile(r"[A-Za-z][A-Za-z0-9_]*")
@dataclass(frozen=True)
class ChannelPlugin:
"""Dependency-free manifest for one channel package.
``runtime`` is an absolute ``module:attribute`` target. Keeping it as an
import string lets discovery inspect metadata without importing optional
platform SDKs.
"""
name: str
display_name: str
runtime: str
connector: str | None = None
setup: ChannelSetupSpec | None = None
management: ChannelManagementSpec = ChannelManagementSpec()
dependencies: tuple[str, ...] = ()
default_enabled: bool = False
settings_visible: bool = True
capabilities: frozenset[str] = frozenset()
webui: str | None = None
def __post_init__(self) -> None:
if _CHANNEL_PACKAGE_NAME.fullmatch(self.name) is None:
raise ValueError(
"channel plugin name must start with a letter and contain only letters, "
"digits, or underscores"
)
_target_parts(self.runtime, label="runtime")
if self.connector is not None:
_target_parts(self.connector, label="connector")
if self.setup is not None and not isinstance(self.setup, ChannelSetupSpec):
raise TypeError("channel plugin setup must be a ChannelSetupSpec or None")
if not isinstance(self.management, ChannelManagementSpec):
raise TypeError("channel plugin management must be a ChannelManagementSpec")
if not isinstance(self.dependencies, tuple) or not all(
isinstance(requirement, str) and requirement.strip()
for requirement in self.dependencies
):
raise TypeError("channel plugin dependencies must be a tuple of requirements")
for dependency in self.dependencies:
try:
Requirement(dependency)
except InvalidRequirement as exc:
raise ValueError(
f"channel plugin dependency is not a valid requirement: {dependency}"
) from exc
if self.webui is not None:
webui = self.webui.replace("\\", "/")
if webui.startswith("/") or ".." in webui.split("/"):
raise ValueError("channel plugin webui entry must stay inside its package")
object.__setattr__(self, "webui", webui)
def load_channel_class(self) -> type[BaseChannel]:
"""Resolve and validate the runtime class only when the channel is needed."""
from nanobot.channels.base import BaseChannel
module_name, _, attr_name = self.runtime.partition(":")
module = importlib.import_module(module_name)
channel_cls: Any = getattr(module, attr_name, None)
if (
not isinstance(channel_cls, type)
or not issubclass(channel_cls, BaseChannel)
or channel_cls is BaseChannel
):
raise ImportError(
f"Channel plugin '{self.name}' runtime '{self.runtime}' "
"does not resolve to a BaseChannel subclass"
)
if channel_cls.name != self.name:
raise ImportError(
f"Channel plugin '{self.name}' runtime declares name '{channel_cls.name}'"
)
return channel_cls
def load_connector(self) -> Any:
"""Construct the optional channel-owned interactive connector."""
if self.connector is None:
raise ImportError(f"Channel plugin '{self.name}' does not provide a connector")
module_name, attr_name = _target_parts(self.connector, label="connector")
module = importlib.import_module(module_name)
factory = getattr(module, attr_name, None)
if not callable(factory):
raise ImportError(
f"Channel plugin '{self.name}' connector '{self.connector}' is not callable"
)
connector = factory()
if not callable(getattr(connector, "handle", None)):
raise ImportError(
f"Channel plugin '{self.name}' connector '{self.connector}' "
"does not provide handle()"
)
return connector
def _target_parts(target: str, *, label: str) -> tuple[str, str]:
module_name, separator, attr_name = target.partition(":")
if not separator or not module_name or not attr_name:
raise ValueError(f"channel plugin {label} must use 'module:attribute' syntax")
if not all(part.isidentifier() for part in module_name.split(".")):
raise ValueError(f"channel plugin {label} module must be an absolute import path")
if not attr_name.isidentifier():
raise ValueError(f"channel plugin {label} attribute must be a Python identifier")
return module_name, attr_name
def has_channel_package(name: str) -> bool:
"""Return whether *name* owns a dependency-free package manifest."""
if _CHANNEL_PACKAGE_NAME.fullmatch(name) is None:
return False
return files("nanobot.channels").joinpath(name, "manifest.py").is_file()
@lru_cache(maxsize=None)
def load_channel_package(name: str) -> ChannelPlugin | None:
"""Load one package manifest without importing its runtime."""
if not has_channel_package(name):
return None
module_name = f"nanobot.channels.{name}.manifest"
module = importlib.import_module(module_name)
plugin = getattr(module, "PLUGIN", None)
if not isinstance(plugin, ChannelPlugin):
raise TypeError(f"{module_name}.PLUGIN must be a ChannelPlugin")
if plugin.name != name:
raise TypeError(
f"{module_name}.PLUGIN declares name '{plugin.name}', expected '{name}'"
)
package_name = f"nanobot.channels.{name}"
package_root = files("nanobot.channels").joinpath(name)
targets = [("runtime", plugin.runtime)]
if plugin.connector is not None:
targets.append(("connector", plugin.connector))
for label, target in targets:
target_module, _ = _target_parts(target, label=label)
if not target_module.startswith(f"{package_name}."):
raise TypeError(
f"{module_name}.PLUGIN {label} must stay inside {package_name}: "
f"{target_module}"
)
target_parts = target_module.removeprefix(f"{package_name}.").split(".")
target_file = package_root.joinpath(
*target_parts[:-1],
f"{target_parts[-1]}.py",
)
target_package = package_root.joinpath(*target_parts, "__init__.py")
if not (target_file.is_file() or target_package.is_file()):
raise TypeError(
f"{module_name}.PLUGIN {label} module does not exist inside its package: "
f"{target_module}"
)
if plugin.webui is not None:
webui_entry = files("nanobot.channels").joinpath(name, *plugin.webui.split("/"))
if not webui_entry.is_file():
raise TypeError(
f"{module_name}.PLUGIN webui entry does not exist: {plugin.webui}"
)
return plugin
__all__ = [
"ChannelPlugin",
"has_channel_package",
"load_channel_package",
]