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:
@@ -6,7 +6,13 @@ import pytest
|
||||
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.channels.contracts import (
|
||||
ChannelInstanceSpec,
|
||||
ChannelManagementSpec,
|
||||
ChannelSetupSpec,
|
||||
)
|
||||
from nanobot.channels.manager import ChannelManager
|
||||
from nanobot.channels.plugin import ChannelPlugin
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
|
||||
@@ -32,6 +38,77 @@ class _HotChannel(BaseChannel):
|
||||
raise AssertionError("send should not be called")
|
||||
|
||||
|
||||
class _MultiHotChannel(_HotChannel):
|
||||
name = "multi"
|
||||
display_name = "Multi"
|
||||
|
||||
class _AliasHotChannel(_HotChannel):
|
||||
"""Package descriptor alias that claims another channel's runtime namespace."""
|
||||
|
||||
name = "hot"
|
||||
display_name = "Alias"
|
||||
|
||||
|
||||
def _multi_instance_specs(section, *, enabled_only=True):
|
||||
instances = section.get("instances", []) if isinstance(section, dict) else []
|
||||
return [
|
||||
ChannelInstanceSpec(
|
||||
instance_id=item["id"],
|
||||
config=item,
|
||||
)
|
||||
for item in instances
|
||||
if not enabled_only or item.get("enabled", False)
|
||||
]
|
||||
|
||||
|
||||
def _plugin(channel_cls: type[BaseChannel], *, multi_instance: bool = False) -> ChannelPlugin:
|
||||
runtime_attr = f"_runtime_{channel_cls.display_name.lower()}"
|
||||
globals()[runtime_attr] = channel_cls
|
||||
setup = ChannelSetupSpec(fields={}) if multi_instance else None
|
||||
management = (
|
||||
ChannelManagementSpec(
|
||||
multi_instance=True,
|
||||
instance_specs=_multi_instance_specs,
|
||||
update_instance_config=lambda section, values, *, instance_id="default": values,
|
||||
runtime_name=lambda name, instance_id: (
|
||||
name if instance_id == "default" else f"{name}.{instance_id}"
|
||||
),
|
||||
)
|
||||
if multi_instance
|
||||
else ChannelManagementSpec()
|
||||
)
|
||||
return ChannelPlugin(
|
||||
name=channel_cls.name,
|
||||
display_name=channel_cls.display_name,
|
||||
runtime=f"{__name__}:{runtime_attr}",
|
||||
setup=setup,
|
||||
management=management,
|
||||
)
|
||||
|
||||
|
||||
def _stub_registry(monkeypatch, *plugins: ChannelPlugin) -> None:
|
||||
by_name = {plugin.name: plugin for plugin in plugins}
|
||||
monkeypatch.setattr(
|
||||
"nanobot.channels.registry.discover_plugins",
|
||||
lambda enabled_names=None: {
|
||||
name: plugin
|
||||
for name, plugin in by_name.items()
|
||||
if enabled_names is None or name in enabled_names
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_descriptor_rejects_runtime_class_owned_by_another_name():
|
||||
plugin = ChannelPlugin(
|
||||
name="alias",
|
||||
display_name="Alias",
|
||||
runtime=f"{__name__}:_AliasHotChannel",
|
||||
)
|
||||
|
||||
with pytest.raises(ImportError, match="runtime declares name 'hot'"):
|
||||
plugin.load_channel_class()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_channel_feature_action_starts_and_stops_channel(monkeypatch):
|
||||
disabled = Config.model_validate({
|
||||
@@ -47,15 +124,8 @@ async def test_apply_channel_feature_action_starts_and_stops_channel(monkeypatch
|
||||
}
|
||||
})
|
||||
|
||||
import nanobot.channels.registry as registry
|
||||
|
||||
def discover_enabled(enabled_names, **_kwargs):
|
||||
return {"hot": _HotChannel} if "hot" in enabled_names else {}
|
||||
|
||||
configs = iter([enabled, disabled])
|
||||
monkeypatch.setattr(registry, "discover_channel_names", lambda: ["hot"])
|
||||
monkeypatch.setattr(registry, "discover_plugins", lambda enabled_names=None: {})
|
||||
monkeypatch.setattr(registry, "discover_enabled", discover_enabled)
|
||||
_stub_registry(monkeypatch, _plugin(_HotChannel))
|
||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: next(configs))
|
||||
|
||||
manager = ChannelManager(disabled, MessageBus())
|
||||
@@ -86,15 +156,7 @@ async def test_apply_channel_feature_action_keeps_running_channel_when_rebuild_f
|
||||
}
|
||||
})
|
||||
|
||||
import nanobot.channels.registry as registry
|
||||
|
||||
monkeypatch.setattr(registry, "discover_channel_names", lambda: ["hot"])
|
||||
monkeypatch.setattr(registry, "discover_plugins", lambda enabled_names=None: {})
|
||||
monkeypatch.setattr(
|
||||
registry,
|
||||
"discover_enabled",
|
||||
lambda enabled_names, **_kwargs: {"hot": _HotChannel},
|
||||
)
|
||||
_stub_registry(monkeypatch, _plugin(_HotChannel))
|
||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: enabled)
|
||||
|
||||
manager = ChannelManager(enabled, MessageBus())
|
||||
@@ -108,7 +170,99 @@ async def test_apply_channel_feature_action_keeps_running_channel_when_rebuild_f
|
||||
|
||||
result = await manager.apply_channel_feature_action("enable", "hot")
|
||||
|
||||
assert result["requires_restart"] is True
|
||||
assert result["requires_restart"] is False
|
||||
assert result["ok"] is False
|
||||
assert manager.channels["hot"] is old_channel
|
||||
assert old_channel.is_running is True
|
||||
assert not old_channel.stopped.is_set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_channel_feature_action_uses_channel_runtime_name(monkeypatch):
|
||||
config = Config.model_validate({
|
||||
"channels": {
|
||||
"websocket": {"enabled": False},
|
||||
"multi": {
|
||||
"enabled": True,
|
||||
"instances": [
|
||||
{"id": "default", "enabled": True},
|
||||
{"id": "product", "enabled": True},
|
||||
]
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
_stub_registry(monkeypatch, _plugin(_MultiHotChannel, multi_instance=True))
|
||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: config)
|
||||
|
||||
manager = ChannelManager(config, MessageBus())
|
||||
product = manager.channels["multi.product"]
|
||||
product._running = True
|
||||
|
||||
result = await manager.apply_channel_feature_action("disable", "multi", "product")
|
||||
|
||||
assert result["requires_restart"] is False
|
||||
assert "multi" in manager.channels
|
||||
assert "multi.product" not in manager.channels
|
||||
assert product.is_running is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_multi_channel_action_reconciles_only_default_runtime(monkeypatch):
|
||||
initial = Config.model_validate({
|
||||
"channels": {
|
||||
"websocket": {"enabled": False},
|
||||
"multi": {
|
||||
"enabled": True,
|
||||
"instances": [
|
||||
{"id": "default", "enabled": True},
|
||||
{"id": "product", "enabled": True},
|
||||
],
|
||||
},
|
||||
}
|
||||
})
|
||||
disabled = Config.model_validate({
|
||||
"channels": {
|
||||
"websocket": {"enabled": False},
|
||||
"multi": {
|
||||
"enabled": True,
|
||||
"instances": [
|
||||
{"id": "default", "enabled": False},
|
||||
{"id": "product", "enabled": True},
|
||||
],
|
||||
},
|
||||
}
|
||||
})
|
||||
enabled = Config.model_validate({
|
||||
"channels": {
|
||||
"websocket": {"enabled": False},
|
||||
"multi": {
|
||||
"enabled": True,
|
||||
"instances": [
|
||||
{"id": "default", "enabled": True},
|
||||
{"id": "product", "enabled": True},
|
||||
],
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
_stub_registry(monkeypatch, _plugin(_MultiHotChannel, multi_instance=True))
|
||||
configs = iter([disabled, enabled])
|
||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: next(configs))
|
||||
|
||||
manager = ChannelManager(initial, MessageBus())
|
||||
default = manager.channels["multi"]
|
||||
product = manager.channels["multi.product"]
|
||||
|
||||
disabled_result = await manager.apply_channel_feature_action("disable", "multi")
|
||||
|
||||
assert disabled_result["requires_restart"] is False
|
||||
assert set(manager.channels) == {"multi.product"}
|
||||
assert default.stopped.is_set()
|
||||
assert not product.stopped.is_set()
|
||||
|
||||
enabled_result = await manager.apply_channel_feature_action("enable", "multi")
|
||||
|
||||
assert enabled_result["requires_restart"] is False
|
||||
assert set(manager.channels) == {"multi", "multi.product"}
|
||||
assert manager.channels["multi.product"] is product
|
||||
|
||||
Reference in New Issue
Block a user