feat(channels): support multi-instance channel configs
This commit is contained in:
524
tests/test_channel_multi_config.py
Normal file
524
tests/test_channel_multi_config.py
Normal file
@@ -0,0 +1,524 @@
|
||||
import pytest
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.channels.manager import ChannelManager
|
||||
from nanobot.config.schema import (
|
||||
Config,
|
||||
DingTalkConfig,
|
||||
DingTalkMultiConfig,
|
||||
DiscordConfig,
|
||||
DiscordMultiConfig,
|
||||
EmailConfig,
|
||||
EmailMultiConfig,
|
||||
FeishuConfig,
|
||||
FeishuMultiConfig,
|
||||
MatrixConfig,
|
||||
MatrixMultiConfig,
|
||||
MochatConfig,
|
||||
MochatMultiConfig,
|
||||
QQConfig,
|
||||
QQMultiConfig,
|
||||
SlackConfig,
|
||||
SlackMultiConfig,
|
||||
TelegramConfig,
|
||||
TelegramMultiConfig,
|
||||
WhatsAppConfig,
|
||||
WhatsAppMultiConfig,
|
||||
WecomConfig,
|
||||
WecomMultiConfig,
|
||||
)
|
||||
|
||||
|
||||
class _DummyChannel(BaseChannel):
|
||||
name = "dummy"
|
||||
display_name = "Dummy"
|
||||
|
||||
async def start(self) -> None:
|
||||
self._running = True
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._running = False
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _patch_registry(monkeypatch: pytest.MonkeyPatch, channel_names: list[str]) -> None:
|
||||
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: channel_names)
|
||||
monkeypatch.setattr("nanobot.channels.registry.load_channel_class", lambda _: _DummyChannel)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field_name", "payload", "expected_cls", "attr_name", "attr_value"),
|
||||
[
|
||||
(
|
||||
"whatsapp",
|
||||
{"enabled": True, "bridgeUrl": "ws://127.0.0.1:3001", "allowFrom": ["123"]},
|
||||
WhatsAppConfig,
|
||||
"bridge_url",
|
||||
"ws://127.0.0.1:3001",
|
||||
),
|
||||
(
|
||||
"telegram",
|
||||
{"enabled": True, "token": "tg-1", "allowFrom": ["alice"]},
|
||||
TelegramConfig,
|
||||
"token",
|
||||
"tg-1",
|
||||
),
|
||||
(
|
||||
"discord",
|
||||
{"enabled": True, "token": "dc-1", "allowFrom": ["42"]},
|
||||
DiscordConfig,
|
||||
"token",
|
||||
"dc-1",
|
||||
),
|
||||
(
|
||||
"feishu",
|
||||
{"enabled": True, "appId": "fs-1", "appSecret": "secret-1", "allowFrom": ["ou_1"]},
|
||||
FeishuConfig,
|
||||
"app_id",
|
||||
"fs-1",
|
||||
),
|
||||
(
|
||||
"dingtalk",
|
||||
{
|
||||
"enabled": True,
|
||||
"clientId": "dt-1",
|
||||
"clientSecret": "secret-1",
|
||||
"allowFrom": ["staff-1"],
|
||||
},
|
||||
DingTalkConfig,
|
||||
"client_id",
|
||||
"dt-1",
|
||||
),
|
||||
(
|
||||
"matrix",
|
||||
{
|
||||
"enabled": True,
|
||||
"homeserver": "https://matrix.example.com",
|
||||
"accessToken": "mx-token",
|
||||
"userId": "@bot:example.com",
|
||||
"allowFrom": ["@alice:example.com"],
|
||||
},
|
||||
MatrixConfig,
|
||||
"homeserver",
|
||||
"https://matrix.example.com",
|
||||
),
|
||||
(
|
||||
"email",
|
||||
{
|
||||
"enabled": True,
|
||||
"consentGranted": True,
|
||||
"imapHost": "imap.example.com",
|
||||
"allowFrom": ["a@example.com"],
|
||||
},
|
||||
EmailConfig,
|
||||
"imap_host",
|
||||
"imap.example.com",
|
||||
),
|
||||
(
|
||||
"mochat",
|
||||
{
|
||||
"enabled": True,
|
||||
"clawToken": "claw-token",
|
||||
"agentUserId": "agent-1",
|
||||
"allowFrom": ["user-1"],
|
||||
},
|
||||
MochatConfig,
|
||||
"claw_token",
|
||||
"claw-token",
|
||||
),
|
||||
(
|
||||
"slack",
|
||||
{"enabled": True, "botToken": "xoxb-1", "appToken": "xapp-1", "allowFrom": ["U1"]},
|
||||
SlackConfig,
|
||||
"bot_token",
|
||||
"xoxb-1",
|
||||
),
|
||||
(
|
||||
"qq",
|
||||
{
|
||||
"enabled": True,
|
||||
"appId": "qq-1",
|
||||
"secret": "secret-1",
|
||||
"allowFrom": ["openid-1"],
|
||||
},
|
||||
QQConfig,
|
||||
"app_id",
|
||||
"qq-1",
|
||||
),
|
||||
(
|
||||
"wecom",
|
||||
{
|
||||
"enabled": True,
|
||||
"botId": "wc-1",
|
||||
"secret": "secret-1",
|
||||
"allowFrom": ["user-1"],
|
||||
},
|
||||
WecomConfig,
|
||||
"bot_id",
|
||||
"wc-1",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_config_parses_supported_single_instance_channels(
|
||||
field_name: str,
|
||||
payload: dict,
|
||||
expected_cls: type,
|
||||
attr_name: str,
|
||||
attr_value: str,
|
||||
) -> None:
|
||||
config = Config.model_validate({"channels": {field_name: payload}})
|
||||
|
||||
section = getattr(config.channels, field_name)
|
||||
assert isinstance(section, expected_cls)
|
||||
assert getattr(section, attr_name) == attr_value
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field_name", "payload", "expected_cls", "attr_name", "attr_value"),
|
||||
[
|
||||
(
|
||||
"whatsapp",
|
||||
{
|
||||
"enabled": True,
|
||||
"instances": [
|
||||
{"name": "main", "bridgeUrl": "ws://127.0.0.1:3001", "allowFrom": ["123"]},
|
||||
{"name": "backup", "bridgeUrl": "ws://127.0.0.1:3002", "allowFrom": ["456"]},
|
||||
],
|
||||
},
|
||||
WhatsAppMultiConfig,
|
||||
"bridge_url",
|
||||
"ws://127.0.0.1:3002",
|
||||
),
|
||||
(
|
||||
"telegram",
|
||||
{
|
||||
"enabled": True,
|
||||
"instances": [
|
||||
{"name": "main", "token": "tg-main", "allowFrom": ["alice"]},
|
||||
{"name": "backup", "token": "tg-backup", "allowFrom": ["bob"]},
|
||||
],
|
||||
},
|
||||
TelegramMultiConfig,
|
||||
"token",
|
||||
"tg-backup",
|
||||
),
|
||||
(
|
||||
"discord",
|
||||
{
|
||||
"enabled": True,
|
||||
"instances": [
|
||||
{"name": "main", "token": "dc-main", "allowFrom": ["42"]},
|
||||
{"name": "backup", "token": "dc-backup", "allowFrom": ["43"]},
|
||||
],
|
||||
},
|
||||
DiscordMultiConfig,
|
||||
"token",
|
||||
"dc-backup",
|
||||
),
|
||||
(
|
||||
"feishu",
|
||||
{
|
||||
"enabled": True,
|
||||
"instances": [
|
||||
{"name": "main", "appId": "fs-main", "appSecret": "s1", "allowFrom": ["ou_1"]},
|
||||
{
|
||||
"name": "backup",
|
||||
"appId": "fs-backup",
|
||||
"appSecret": "s2",
|
||||
"allowFrom": ["ou_2"],
|
||||
},
|
||||
],
|
||||
},
|
||||
FeishuMultiConfig,
|
||||
"app_id",
|
||||
"fs-backup",
|
||||
),
|
||||
(
|
||||
"dingtalk",
|
||||
{
|
||||
"enabled": True,
|
||||
"instances": [
|
||||
{
|
||||
"name": "main",
|
||||
"clientId": "dt-main",
|
||||
"clientSecret": "s1",
|
||||
"allowFrom": ["staff-1"],
|
||||
},
|
||||
{
|
||||
"name": "backup",
|
||||
"clientId": "dt-backup",
|
||||
"clientSecret": "s2",
|
||||
"allowFrom": ["staff-2"],
|
||||
},
|
||||
],
|
||||
},
|
||||
DingTalkMultiConfig,
|
||||
"client_id",
|
||||
"dt-backup",
|
||||
),
|
||||
(
|
||||
"matrix",
|
||||
{
|
||||
"enabled": True,
|
||||
"instances": [
|
||||
{
|
||||
"name": "main",
|
||||
"homeserver": "https://matrix-1.example.com",
|
||||
"accessToken": "mx-token-1",
|
||||
"userId": "@bot1:example.com",
|
||||
"allowFrom": ["@alice:example.com"],
|
||||
},
|
||||
{
|
||||
"name": "backup",
|
||||
"homeserver": "https://matrix-2.example.com",
|
||||
"accessToken": "mx-token-2",
|
||||
"userId": "@bot2:example.com",
|
||||
"allowFrom": ["@bob:example.com"],
|
||||
},
|
||||
],
|
||||
},
|
||||
MatrixMultiConfig,
|
||||
"homeserver",
|
||||
"https://matrix-2.example.com",
|
||||
),
|
||||
(
|
||||
"email",
|
||||
{
|
||||
"enabled": True,
|
||||
"instances": [
|
||||
{
|
||||
"name": "work",
|
||||
"consentGranted": True,
|
||||
"imapHost": "imap.work",
|
||||
"allowFrom": ["a@work"],
|
||||
},
|
||||
{
|
||||
"name": "home",
|
||||
"consentGranted": True,
|
||||
"imapHost": "imap.home",
|
||||
"allowFrom": ["a@home"],
|
||||
},
|
||||
],
|
||||
},
|
||||
EmailMultiConfig,
|
||||
"imap_host",
|
||||
"imap.home",
|
||||
),
|
||||
(
|
||||
"mochat",
|
||||
{
|
||||
"enabled": True,
|
||||
"instances": [
|
||||
{
|
||||
"name": "main",
|
||||
"clawToken": "claw-main",
|
||||
"agentUserId": "agent-1",
|
||||
"allowFrom": ["user-1"],
|
||||
},
|
||||
{
|
||||
"name": "backup",
|
||||
"clawToken": "claw-backup",
|
||||
"agentUserId": "agent-2",
|
||||
"allowFrom": ["user-2"],
|
||||
},
|
||||
],
|
||||
},
|
||||
MochatMultiConfig,
|
||||
"claw_token",
|
||||
"claw-backup",
|
||||
),
|
||||
(
|
||||
"slack",
|
||||
{
|
||||
"enabled": True,
|
||||
"instances": [
|
||||
{
|
||||
"name": "main",
|
||||
"botToken": "xoxb-main",
|
||||
"appToken": "xapp-main",
|
||||
"allowFrom": ["U1"],
|
||||
},
|
||||
{
|
||||
"name": "backup",
|
||||
"botToken": "xoxb-backup",
|
||||
"appToken": "xapp-backup",
|
||||
"allowFrom": ["U2"],
|
||||
},
|
||||
],
|
||||
},
|
||||
SlackMultiConfig,
|
||||
"bot_token",
|
||||
"xoxb-backup",
|
||||
),
|
||||
(
|
||||
"qq",
|
||||
{
|
||||
"enabled": True,
|
||||
"instances": [
|
||||
{"name": "main", "appId": "qq-main", "secret": "s1", "allowFrom": ["openid-1"]},
|
||||
{
|
||||
"name": "backup",
|
||||
"appId": "qq-backup",
|
||||
"secret": "s2",
|
||||
"allowFrom": ["openid-2"],
|
||||
},
|
||||
],
|
||||
},
|
||||
QQMultiConfig,
|
||||
"app_id",
|
||||
"qq-backup",
|
||||
),
|
||||
(
|
||||
"wecom",
|
||||
{
|
||||
"enabled": True,
|
||||
"instances": [
|
||||
{"name": "main", "botId": "wc-main", "secret": "s1", "allowFrom": ["user-1"]},
|
||||
{
|
||||
"name": "backup",
|
||||
"botId": "wc-backup",
|
||||
"secret": "s2",
|
||||
"allowFrom": ["user-2"],
|
||||
},
|
||||
],
|
||||
},
|
||||
WecomMultiConfig,
|
||||
"bot_id",
|
||||
"wc-backup",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_config_parses_supported_multi_instance_channels(
|
||||
field_name: str,
|
||||
payload: dict,
|
||||
expected_cls: type,
|
||||
attr_name: str,
|
||||
attr_value: str,
|
||||
) -> None:
|
||||
config = Config.model_validate({"channels": {field_name: payload}})
|
||||
|
||||
section = getattr(config.channels, field_name)
|
||||
assert isinstance(section, expected_cls)
|
||||
assert [inst.name for inst in section.instances] == ["main", "backup"]
|
||||
assert getattr(section.instances[1], attr_name) == attr_value
|
||||
|
||||
|
||||
def test_channel_manager_registers_mixed_single_and_multi_instance_channels(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_patch_registry(
|
||||
monkeypatch,
|
||||
["whatsapp", "telegram", "discord", "qq", "email", "matrix", "mochat"],
|
||||
)
|
||||
config = Config.model_validate(
|
||||
{
|
||||
"channels": {
|
||||
"whatsapp": {
|
||||
"enabled": True,
|
||||
"instances": [
|
||||
{
|
||||
"name": "phone-a",
|
||||
"bridgeUrl": "ws://127.0.0.1:3001",
|
||||
"allowFrom": ["123"],
|
||||
},
|
||||
],
|
||||
},
|
||||
"telegram": {
|
||||
"enabled": True,
|
||||
"instances": [
|
||||
{"name": "main", "token": "tg-main", "allowFrom": ["alice"]},
|
||||
{"name": "backup", "token": "tg-backup", "allowFrom": ["bob"]},
|
||||
],
|
||||
},
|
||||
"discord": {
|
||||
"enabled": True,
|
||||
"token": "dc-main",
|
||||
"allowFrom": ["42"],
|
||||
},
|
||||
"qq": {
|
||||
"enabled": True,
|
||||
"instances": [
|
||||
{
|
||||
"name": "alpha",
|
||||
"appId": "qq-alpha",
|
||||
"secret": "s1",
|
||||
"allowFrom": ["openid-1"],
|
||||
},
|
||||
],
|
||||
},
|
||||
"email": {
|
||||
"enabled": True,
|
||||
"instances": [
|
||||
{
|
||||
"name": "work",
|
||||
"consentGranted": True,
|
||||
"imapHost": "imap.work",
|
||||
"allowFrom": ["a@work"],
|
||||
},
|
||||
],
|
||||
},
|
||||
"matrix": {
|
||||
"enabled": True,
|
||||
"instances": [
|
||||
{
|
||||
"name": "ops",
|
||||
"homeserver": "https://matrix.example.com",
|
||||
"accessToken": "mx-token",
|
||||
"userId": "@bot:example.com",
|
||||
"allowFrom": ["@alice:example.com"],
|
||||
},
|
||||
],
|
||||
},
|
||||
"mochat": {
|
||||
"enabled": True,
|
||||
"instances": [
|
||||
{
|
||||
"name": "sales",
|
||||
"clawToken": "claw-token",
|
||||
"agentUserId": "agent-1",
|
||||
"allowFrom": ["user-1"],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
manager = ChannelManager(config, MessageBus())
|
||||
|
||||
assert manager.enabled_channels == [
|
||||
"whatsapp/phone-a",
|
||||
"telegram/main",
|
||||
"telegram/backup",
|
||||
"discord",
|
||||
"qq/alpha",
|
||||
"email/work",
|
||||
"matrix/ops",
|
||||
"mochat/sales",
|
||||
]
|
||||
assert manager.get_channel("whatsapp/phone-a").config.bridge_url == "ws://127.0.0.1:3001"
|
||||
assert manager.get_channel("telegram/backup") is not None
|
||||
assert manager.get_channel("telegram/backup").config.token == "tg-backup"
|
||||
assert manager.get_channel("discord") is not None
|
||||
assert manager.get_channel("qq/alpha").config.app_id == "qq-alpha"
|
||||
assert manager.get_channel("email/work").config.imap_host == "imap.work"
|
||||
assert manager.get_channel("matrix/ops").config.user_id == "@bot:example.com"
|
||||
assert manager.get_channel("mochat/sales").config.claw_token == "claw-token"
|
||||
|
||||
|
||||
def test_channel_manager_skips_empty_multi_instance_channel(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_patch_registry(monkeypatch, ["telegram"])
|
||||
config = Config.model_validate(
|
||||
{"channels": {"telegram": {"enabled": True, "instances": []}}}
|
||||
)
|
||||
|
||||
manager = ChannelManager(config, MessageBus())
|
||||
|
||||
assert isinstance(config.channels.telegram, TelegramMultiConfig)
|
||||
assert manager.enabled_channels == []
|
||||
67
tests/test_channel_multi_state.py
Normal file
67
tests/test_channel_multi_state.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from pathlib import Path
|
||||
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.matrix import MatrixChannel
|
||||
from nanobot.channels.mochat import MochatChannel
|
||||
from nanobot.config.schema import MatrixConfig, MatrixInstanceConfig, MochatConfig, MochatInstanceConfig
|
||||
|
||||
|
||||
def test_matrix_default_store_path_unchanged(monkeypatch, tmp_path: Path) -> None:
|
||||
monkeypatch.setattr("nanobot.channels.matrix.get_data_dir", lambda: tmp_path)
|
||||
channel = MatrixChannel(
|
||||
MatrixConfig(
|
||||
enabled=True,
|
||||
homeserver="https://matrix.example.com",
|
||||
access_token="token",
|
||||
user_id="@bot:example.com",
|
||||
allow_from=["*"],
|
||||
),
|
||||
MessageBus(),
|
||||
)
|
||||
|
||||
assert channel._get_store_path() == tmp_path / "matrix-store"
|
||||
|
||||
|
||||
def test_matrix_instance_store_path_isolated(monkeypatch, tmp_path: Path) -> None:
|
||||
monkeypatch.setattr("nanobot.channels.matrix.get_data_dir", lambda: tmp_path)
|
||||
channel = MatrixChannel(
|
||||
MatrixInstanceConfig(
|
||||
name="ops",
|
||||
enabled=True,
|
||||
homeserver="https://matrix.example.com",
|
||||
access_token="token",
|
||||
user_id="@bot:example.com",
|
||||
allow_from=["*"],
|
||||
),
|
||||
MessageBus(),
|
||||
)
|
||||
|
||||
assert channel._get_store_path() == tmp_path / "matrix-store" / "ops"
|
||||
|
||||
|
||||
def test_mochat_default_state_dir_unchanged(monkeypatch, tmp_path: Path) -> None:
|
||||
monkeypatch.setattr("nanobot.channels.mochat.get_runtime_subdir", lambda _: tmp_path / "mochat")
|
||||
channel = MochatChannel(
|
||||
MochatConfig(enabled=True, claw_token="token", agent_user_id="agent-1", allow_from=["*"]),
|
||||
MessageBus(),
|
||||
)
|
||||
|
||||
assert channel._state_dir == tmp_path / "mochat"
|
||||
assert channel._cursor_path == tmp_path / "mochat" / "session_cursors.json"
|
||||
|
||||
|
||||
def test_mochat_instance_state_dir_isolated(monkeypatch, tmp_path: Path) -> None:
|
||||
monkeypatch.setattr("nanobot.channels.mochat.get_runtime_subdir", lambda _: tmp_path / "mochat")
|
||||
channel = MochatChannel(
|
||||
MochatInstanceConfig(
|
||||
name="sales",
|
||||
enabled=True,
|
||||
claw_token="token",
|
||||
agent_user_id="agent-1",
|
||||
allow_from=["*"],
|
||||
),
|
||||
MessageBus(),
|
||||
)
|
||||
|
||||
assert channel._state_dir == tmp_path / "mochat" / "sales"
|
||||
assert channel._cursor_path == tmp_path / "mochat" / "sales" / "session_cursors.json"
|
||||
Reference in New Issue
Block a user