* 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
269 lines
8.4 KiB
Python
269 lines
8.4 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
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
|
|
|
|
|
|
class _HotChannel(BaseChannel):
|
|
name = "hot"
|
|
display_name = "Hot"
|
|
|
|
def __init__(self, config, bus):
|
|
super().__init__(config, bus)
|
|
self.started = asyncio.Event()
|
|
self.stopped = asyncio.Event()
|
|
|
|
async def start(self):
|
|
self._running = True
|
|
self.started.set()
|
|
await self.stopped.wait()
|
|
|
|
async def stop(self):
|
|
self._running = False
|
|
self.stopped.set()
|
|
|
|
async def send(self, msg): # pragma: no cover - not used by this test
|
|
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({
|
|
"channels": {
|
|
"websocket": {"enabled": False},
|
|
"hot": {"enabled": False},
|
|
}
|
|
})
|
|
enabled = Config.model_validate({
|
|
"channels": {
|
|
"websocket": {"enabled": False},
|
|
"hot": {"enabled": True},
|
|
}
|
|
})
|
|
|
|
configs = iter([enabled, disabled])
|
|
_stub_registry(monkeypatch, _plugin(_HotChannel))
|
|
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: next(configs))
|
|
|
|
manager = ChannelManager(disabled, MessageBus())
|
|
manager._started = True
|
|
|
|
enabled_result = await manager.apply_channel_feature_action("enable", "hot")
|
|
|
|
assert enabled_result["handled"] is True
|
|
assert enabled_result["requires_restart"] is False
|
|
channel = manager.channels["hot"]
|
|
await asyncio.wait_for(channel.started.wait(), timeout=1)
|
|
assert channel.is_running is True
|
|
|
|
disabled_result = await manager.apply_channel_feature_action("disable", "hot")
|
|
|
|
assert disabled_result["handled"] is True
|
|
assert disabled_result["requires_restart"] is False
|
|
assert "hot" not in manager.channels
|
|
assert channel.is_running is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_apply_channel_feature_action_keeps_running_channel_when_rebuild_fails(monkeypatch):
|
|
enabled = Config.model_validate({
|
|
"channels": {
|
|
"websocket": {"enabled": False},
|
|
"hot": {"enabled": True},
|
|
}
|
|
})
|
|
|
|
_stub_registry(monkeypatch, _plugin(_HotChannel))
|
|
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: enabled)
|
|
|
|
manager = ChannelManager(enabled, MessageBus())
|
|
old_channel = manager.channels["hot"]
|
|
old_channel._running = True
|
|
|
|
def fail_build(*_args, **_kwargs):
|
|
raise RuntimeError("invalid replacement config")
|
|
|
|
monkeypatch.setattr(manager, "_build_channel", fail_build)
|
|
|
|
result = await manager.apply_channel_feature_action("enable", "hot")
|
|
|
|
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
|