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,4 +1,37 @@
|
||||
import ast
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import nanobot.channels._setup as channel_setup_module
|
||||
import nanobot.channels.registry as registry_module
|
||||
from nanobot.channels._setup import channel_setup_spec
|
||||
from nanobot.channels.plugin import ChannelPlugin, load_channel_package
|
||||
from nanobot.channels.registry import channel_default_enabled, discover_plugins
|
||||
|
||||
EXPECTED_CHANNELS = {
|
||||
"dingtalk",
|
||||
"discord",
|
||||
"email",
|
||||
"feishu",
|
||||
"matrix",
|
||||
"mattermost",
|
||||
"mochat",
|
||||
"msteams",
|
||||
"napcat",
|
||||
"qq",
|
||||
"signal",
|
||||
"slack",
|
||||
"telegram",
|
||||
"websocket",
|
||||
"wecom",
|
||||
"weixin",
|
||||
"whatsapp",
|
||||
}
|
||||
|
||||
|
||||
def test_channel_setup_spec_derives_route_and_secret_metadata() -> None:
|
||||
@@ -12,6 +45,13 @@ def test_channel_setup_spec_derives_route_and_secret_metadata() -> None:
|
||||
"groupPolicy": ("enum", {"mention", "open", "allowlist"}),
|
||||
}
|
||||
assert slack.simple_required_fields == ("appToken", "botToken")
|
||||
assert slack.fields["groupPolicy"].default == "mention"
|
||||
group_policy = next(
|
||||
field
|
||||
for field in slack.to_public_dict("slack")["fields"]
|
||||
if field["field"] == "groupPolicy"
|
||||
)
|
||||
assert group_policy["default_value"] == "mention"
|
||||
|
||||
|
||||
def test_matrix_setup_requires_one_complete_login_method() -> None:
|
||||
@@ -52,3 +92,192 @@ def test_webui_forms_have_writable_mattermost_and_whatsapp_contracts() -> None:
|
||||
"enum",
|
||||
{"mention", "open"},
|
||||
)
|
||||
|
||||
|
||||
def test_every_channel_is_a_self_contained_package() -> None:
|
||||
channel_dir = Path(channel_setup_module.__file__).parent
|
||||
package_names = {path.parent.name for path in channel_dir.glob("*/manifest.py")}
|
||||
|
||||
assert not hasattr(channel_setup_module, "CHANNEL_SETUP_SPECS")
|
||||
assert package_names == EXPECTED_CHANNELS
|
||||
assert set(discover_plugins()) == EXPECTED_CHANNELS
|
||||
for name in EXPECTED_CHANNELS:
|
||||
package_dir = channel_dir / name
|
||||
assert (package_dir / "__init__.py").is_file()
|
||||
assert (package_dir / "manifest.py").is_file()
|
||||
assert (package_dir / "runtime.py").is_file()
|
||||
assert not (channel_dir / f"{name}.py").exists()
|
||||
|
||||
plugin = load_channel_package(name)
|
||||
assert plugin is not None
|
||||
assert plugin.name == name
|
||||
assert plugin.runtime.startswith(f"nanobot.channels.{name}.runtime:")
|
||||
assert plugin.setup is channel_setup_spec(name)
|
||||
if plugin.webui is not None:
|
||||
assert (package_dir / plugin.webui).is_file()
|
||||
|
||||
|
||||
def test_channel_locales_cover_authoritative_setup_contracts() -> None:
|
||||
channel_dir = Path(channel_setup_module.__file__).parent
|
||||
for name in EXPECTED_CHANNELS:
|
||||
plugin = load_channel_package(name)
|
||||
assert plugin is not None
|
||||
if plugin.webui is None or plugin.setup is None:
|
||||
continue
|
||||
english = json.loads(
|
||||
(channel_dir / name / "webui" / "locales" / "en.json").read_text(encoding="utf-8")
|
||||
)
|
||||
setup_messages = english["setup"]
|
||||
field_messages = setup_messages.get("fields", {})
|
||||
for field_name, field in plugin.setup.fields.items():
|
||||
if not field.writable:
|
||||
continue
|
||||
message_key = re.sub(r"[^A-Za-z0-9_-]+", "_", field_name)
|
||||
assert message_key in field_messages, f"{name} field {field_name} has no locale copy"
|
||||
if plugin.setup.official_url:
|
||||
assert setup_messages.get("officialLabel"), f"{name} has no localized official label"
|
||||
|
||||
|
||||
def test_channel_manifests_only_import_contract_modules() -> None:
|
||||
channel_dir = Path(channel_setup_module.__file__).parent
|
||||
allowed_imports = {
|
||||
"nanobot.channels._manifest",
|
||||
"nanobot.channels.contracts",
|
||||
"nanobot.channels.plugin",
|
||||
}
|
||||
|
||||
for name in EXPECTED_CHANNELS:
|
||||
manifest_path = channel_dir / name / "manifest.py"
|
||||
tree = ast.parse(manifest_path.read_text(encoding="utf-8"))
|
||||
imports: set[str] = set()
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Import):
|
||||
imports.update(alias.name for alias in node.names)
|
||||
elif isinstance(node, ast.ImportFrom) and node.module:
|
||||
imports.add(node.module)
|
||||
allowed_channel_imports = {
|
||||
module
|
||||
for module in imports
|
||||
if module.startswith(f"nanobot.channels.{name}.")
|
||||
and not module.endswith(".runtime")
|
||||
}
|
||||
unexpected = imports - allowed_imports - allowed_channel_imports
|
||||
assert not unexpected, f"{name} imports runtime dependencies: {unexpected}"
|
||||
|
||||
|
||||
def test_runtime_classes_do_not_declare_persisted_management_hooks() -> None:
|
||||
channel_dir = Path(channel_setup_module.__file__).parent
|
||||
management_hooks = {
|
||||
"feature_instances",
|
||||
"instance_specs",
|
||||
"runtime_name",
|
||||
"supports_multiple_instances",
|
||||
"update_instance_config",
|
||||
}
|
||||
for name in EXPECTED_CHANNELS:
|
||||
tree = ast.parse((channel_dir / name / "runtime.py").read_text(encoding="utf-8"))
|
||||
declared = {
|
||||
item.name
|
||||
for node in tree.body
|
||||
if isinstance(node, ast.ClassDef)
|
||||
for item in node.body
|
||||
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
}
|
||||
assert declared.isdisjoint(management_hooks), f"{name} runtime owns {declared & management_hooks}"
|
||||
|
||||
|
||||
def test_feishu_package_manifest_owns_runtime_and_webui_metadata() -> None:
|
||||
plugin = load_channel_package("feishu")
|
||||
|
||||
assert plugin is not None
|
||||
assert plugin.runtime == "nanobot.channels.feishu.runtime:FeishuChannel"
|
||||
assert plugin.dependencies == ("lark-oapi>=1.5.0,<2.0.0",)
|
||||
assert plugin.connector == "nanobot.channels.feishu.connect:FeishuConnectStore"
|
||||
assert plugin.management.multi_instance is True
|
||||
assert plugin.webui == "webui/index.tsx"
|
||||
|
||||
|
||||
def test_weixin_package_manifest_owns_runtime_and_webui_metadata() -> None:
|
||||
plugin = load_channel_package("weixin")
|
||||
|
||||
assert plugin is not None
|
||||
assert plugin.runtime == "nanobot.channels.weixin.runtime:WeixinChannel"
|
||||
assert plugin.dependencies == ("qrcode[pil]>=8.0", "pycryptodome>=3.20.0")
|
||||
assert plugin.connector == "nanobot.channels.weixin.connect:WeixinConnectStore"
|
||||
assert plugin.webui == "webui/index.tsx"
|
||||
|
||||
|
||||
def test_package_manifests_do_not_import_runtimes() -> None:
|
||||
code = f"""
|
||||
import sys
|
||||
from nanobot.channels.plugin import load_channel_package
|
||||
|
||||
for name in {sorted(EXPECTED_CHANNELS)!r}:
|
||||
plugin = load_channel_package(name)
|
||||
assert plugin is not None
|
||||
assert f"nanobot.channels.{{name}}.runtime" not in sys.modules
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", code],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_channel_plugin_normalizes_webui_entry() -> None:
|
||||
plugin = ChannelPlugin(
|
||||
name="demo",
|
||||
display_name="Demo",
|
||||
runtime="example.demo.runtime:DemoChannel",
|
||||
webui="webui\\index.tsx",
|
||||
)
|
||||
|
||||
assert plugin.webui == "webui/index.tsx"
|
||||
|
||||
|
||||
def test_channel_plugin_name_must_match_package_identifier() -> None:
|
||||
with pytest.raises(ValueError, match="letters, digits, or underscores"):
|
||||
ChannelPlugin(
|
||||
name="google-chat",
|
||||
display_name="Google Chat",
|
||||
runtime="example.google_chat.runtime:GoogleChatChannel",
|
||||
)
|
||||
|
||||
|
||||
def test_channel_plugin_rejects_invalid_runtime_import_path() -> None:
|
||||
with pytest.raises(ValueError, match="absolute import path"):
|
||||
ChannelPlugin(
|
||||
name="demo",
|
||||
display_name="Demo",
|
||||
runtime="../runtime:DemoChannel",
|
||||
)
|
||||
|
||||
|
||||
def test_channel_default_enabled_uses_package_manifest(monkeypatch) -> None:
|
||||
plugin = ChannelPlugin(
|
||||
name="demo",
|
||||
display_name="Demo",
|
||||
runtime="example.demo.runtime:DemoChannel",
|
||||
default_enabled=True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
registry_module,
|
||||
"load_channel_plugin",
|
||||
lambda name: plugin if name == "demo" else (_ for _ in ()).throw(ImportError()),
|
||||
)
|
||||
|
||||
assert channel_default_enabled("demo") is True
|
||||
assert channel_default_enabled("missing") is False
|
||||
|
||||
|
||||
def test_websocket_manifest_declares_the_only_default_enabled_channel() -> None:
|
||||
enabled = {
|
||||
name
|
||||
for name in EXPECTED_CHANNELS
|
||||
if (plugin := load_channel_package(name)) is not None and plugin.default_enabled
|
||||
}
|
||||
|
||||
assert enabled == {"websocket"}
|
||||
|
||||
Reference in New Issue
Block a user