feat(channels): support multi-instance channel configs
This commit is contained in:
66
README.md
66
README.md
@@ -265,6 +265,36 @@ Connect nanobot to your favorite chat platform.
|
||||
| **QQ** | App ID + App Secret |
|
||||
| **Wecom** | Bot ID + Bot Secret |
|
||||
|
||||
Multi-bot support is available for `whatsapp`, `telegram`, `discord`, `feishu`, `mochat`,
|
||||
`dingtalk`, `slack`, `email`, `qq`, `matrix`, and `wecom`.
|
||||
Use `instances` when you want more than one bot/account for the same channel; each instance is
|
||||
routed as `channel/name`.
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"instances": [
|
||||
{
|
||||
"name": "main",
|
||||
"token": "BOT_TOKEN_A",
|
||||
"allowFrom": ["YOUR_USER_ID"]
|
||||
},
|
||||
{
|
||||
"name": "backup",
|
||||
"token": "BOT_TOKEN_B",
|
||||
"allowFrom": ["YOUR_USER_ID"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For `whatsapp`, each instance should point to its own bridge process with its own `bridgeUrl`
|
||||
and bridge auth/session directory.
|
||||
|
||||
<details>
|
||||
<summary><b>Telegram</b> (Recommended)</summary>
|
||||
|
||||
@@ -350,6 +380,9 @@ If you prefer to configure manually, add the following to `~/.nanobot/config.jso
|
||||
}
|
||||
```
|
||||
|
||||
> Multi-account mode is also supported with `instances`; each instance keeps its Mochat runtime
|
||||
> cursors in its own state directory automatically.
|
||||
|
||||
|
||||
|
||||
</details>
|
||||
@@ -451,6 +484,8 @@ pip install nanobot-ai[matrix]
|
||||
```
|
||||
|
||||
> Keep a persistent `matrix-store` and stable `deviceId` — encrypted session state is lost if these change across restarts.
|
||||
> In multi-account mode, nanobot isolates each instance into its own `matrix-store/<instance>`
|
||||
> directory automatically.
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
@@ -497,6 +532,10 @@ nanobot channels login
|
||||
}
|
||||
```
|
||||
|
||||
> Multi-bot mode is supported with `instances`, but each bot must connect to its own bridge
|
||||
> process. Run separate bridge processes with different `BRIDGE_PORT` and `AUTH_DIR`, then point
|
||||
> each instance at its own `bridgeUrl`.
|
||||
|
||||
**3. Run** (two terminals)
|
||||
|
||||
```bash
|
||||
@@ -579,6 +618,7 @@ Uses **botpy SDK** with WebSocket — no public IP required. Currently supports
|
||||
|
||||
> - `allowFrom`: Add your openid (find it in nanobot logs when you message the bot). Use `["*"]` for public access.
|
||||
> - For production: submit a review in the bot console and publish. See [QQ Bot Docs](https://bot.q.qq.com/wiki/) for the full publishing flow.
|
||||
> - Single-bot config is still supported. For multiple bots, use `instances`, and each bot is routed as `qq/<name>`.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -593,6 +633,32 @@ Uses **botpy SDK** with WebSocket — no public IP required. Currently supports
|
||||
}
|
||||
```
|
||||
|
||||
Multi-bot example:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"qq": {
|
||||
"enabled": true,
|
||||
"instances": [
|
||||
{
|
||||
"name": "bot-a",
|
||||
"appId": "YOUR_APP_ID_A",
|
||||
"secret": "YOUR_APP_SECRET_A",
|
||||
"allowFrom": ["YOUR_OPENID"]
|
||||
},
|
||||
{
|
||||
"name": "bot-b",
|
||||
"appId": "YOUR_APP_ID_B",
|
||||
"secret": "YOUR_APP_SECRET_B",
|
||||
"allowFrom": ["*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**4. Run**
|
||||
|
||||
```bash
|
||||
|
||||
@@ -15,7 +15,7 @@ from loguru import logger
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.schema import DingTalkConfig
|
||||
from nanobot.config.schema import DingTalkConfig, DingTalkInstanceConfig
|
||||
|
||||
try:
|
||||
from dingtalk_stream import (
|
||||
@@ -119,9 +119,9 @@ class DingTalkChannel(BaseChannel):
|
||||
_AUDIO_EXTS = {".amr", ".mp3", ".wav", ".ogg", ".m4a", ".aac"}
|
||||
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm"}
|
||||
|
||||
def __init__(self, config: DingTalkConfig, bus: MessageBus):
|
||||
def __init__(self, config: DingTalkConfig | DingTalkInstanceConfig, bus: MessageBus):
|
||||
super().__init__(config, bus)
|
||||
self.config: DingTalkConfig = config
|
||||
self.config: DingTalkConfig | DingTalkInstanceConfig = config
|
||||
self._client: Any = None
|
||||
self._http: httpx.AsyncClient | None = None
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.paths import get_media_dir
|
||||
from nanobot.config.schema import DiscordConfig
|
||||
from nanobot.config.schema import DiscordConfig, DiscordInstanceConfig
|
||||
from nanobot.utils.helpers import split_message
|
||||
|
||||
DISCORD_API_BASE = "https://discord.com/api/v10"
|
||||
@@ -27,9 +27,9 @@ class DiscordChannel(BaseChannel):
|
||||
name = "discord"
|
||||
display_name = "Discord"
|
||||
|
||||
def __init__(self, config: DiscordConfig, bus: MessageBus):
|
||||
def __init__(self, config: DiscordConfig | DiscordInstanceConfig, bus: MessageBus):
|
||||
super().__init__(config, bus)
|
||||
self.config: DiscordConfig = config
|
||||
self.config: DiscordConfig | DiscordInstanceConfig = config
|
||||
self._ws: websockets.WebSocketClientProtocol | None = None
|
||||
self._seq: int | None = None
|
||||
self._heartbeat_task: asyncio.Task | None = None
|
||||
|
||||
@@ -19,7 +19,7 @@ from loguru import logger
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.schema import EmailConfig
|
||||
from nanobot.config.schema import EmailConfig, EmailInstanceConfig
|
||||
|
||||
|
||||
class EmailChannel(BaseChannel):
|
||||
@@ -51,9 +51,9 @@ class EmailChannel(BaseChannel):
|
||||
"Dec",
|
||||
)
|
||||
|
||||
def __init__(self, config: EmailConfig, bus: MessageBus):
|
||||
def __init__(self, config: EmailConfig | EmailInstanceConfig, bus: MessageBus):
|
||||
super().__init__(config, bus)
|
||||
self.config: EmailConfig = config
|
||||
self.config: EmailConfig | EmailInstanceConfig = config
|
||||
self._last_subject_by_chat: dict[str, str] = {}
|
||||
self._last_message_id_by_chat: dict[str, str] = {}
|
||||
self._processed_uids: set[str] = set() # Capped to prevent unbounded growth
|
||||
|
||||
@@ -15,7 +15,7 @@ from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.paths import get_media_dir
|
||||
from nanobot.config.schema import FeishuConfig
|
||||
from nanobot.config.schema import FeishuConfig, FeishuInstanceConfig
|
||||
|
||||
import importlib.util
|
||||
|
||||
@@ -246,9 +246,9 @@ class FeishuChannel(BaseChannel):
|
||||
name = "feishu"
|
||||
display_name = "Feishu"
|
||||
|
||||
def __init__(self, config: FeishuConfig, bus: MessageBus):
|
||||
def __init__(self, config: FeishuConfig | FeishuInstanceConfig, bus: MessageBus):
|
||||
super().__init__(config, bus)
|
||||
self.config: FeishuConfig = config
|
||||
self.config: FeishuConfig | FeishuInstanceConfig = config
|
||||
self._client: Any = None
|
||||
self._ws_client: Any = None
|
||||
self._ws_thread: threading.Thread | None = None
|
||||
|
||||
@@ -42,10 +42,41 @@ class ChannelManager:
|
||||
continue
|
||||
try:
|
||||
cls = load_channel_class(modname)
|
||||
channel = cls(section, self.bus)
|
||||
channel.transcription_api_key = groq_key
|
||||
self.channels[modname] = channel
|
||||
logger.info("{} channel enabled", cls.display_name)
|
||||
instances = getattr(section, "instances", None)
|
||||
if instances is not None:
|
||||
if not instances:
|
||||
logger.warning(
|
||||
"{} channel enabled but no instances configured",
|
||||
cls.display_name,
|
||||
)
|
||||
continue
|
||||
|
||||
for inst in instances:
|
||||
inst_name = getattr(inst, "name", None)
|
||||
if not inst_name:
|
||||
raise ValueError(
|
||||
f'{modname}.instances item missing required field "name"'
|
||||
)
|
||||
|
||||
# Session keys use "channel:chat_id", so instance names cannot use ":".
|
||||
channel_name = f"{modname}/{inst_name}"
|
||||
if channel_name in self.channels:
|
||||
raise ValueError(f"Duplicate channel instance name: {channel_name}")
|
||||
|
||||
channel = cls(inst, self.bus)
|
||||
channel.name = channel_name
|
||||
channel.transcription_api_key = groq_key
|
||||
self.channels[channel_name] = channel
|
||||
logger.info(
|
||||
"{} channel instance enabled: {}",
|
||||
cls.display_name,
|
||||
channel_name,
|
||||
)
|
||||
else:
|
||||
channel = cls(section, self.bus)
|
||||
channel.transcription_api_key = groq_key
|
||||
self.channels[modname] = channel
|
||||
logger.info("{} channel enabled", cls.display_name)
|
||||
except ImportError as e:
|
||||
logger.warning("{} channel not available: {}", modname, e)
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.paths import get_data_dir, get_media_dir
|
||||
from nanobot.config.schema import MatrixConfig, MatrixInstanceConfig
|
||||
from nanobot.utils.helpers import safe_filename
|
||||
|
||||
TYPING_NOTICE_TIMEOUT_MS = 30_000
|
||||
@@ -149,8 +150,9 @@ class MatrixChannel(BaseChannel):
|
||||
name = "matrix"
|
||||
display_name = "Matrix"
|
||||
|
||||
def __init__(self, config: Any, bus: MessageBus):
|
||||
def __init__(self, config: MatrixConfig | MatrixInstanceConfig, bus: MessageBus):
|
||||
super().__init__(config, bus)
|
||||
self.config: MatrixConfig | MatrixInstanceConfig = config
|
||||
self.client: AsyncClient | None = None
|
||||
self._sync_task: asyncio.Task | None = None
|
||||
self._typing_tasks: dict[str, asyncio.Task] = {}
|
||||
@@ -159,12 +161,23 @@ class MatrixChannel(BaseChannel):
|
||||
self._server_upload_limit_bytes: int | None = None
|
||||
self._server_upload_limit_checked = False
|
||||
|
||||
def _get_store_path(self) -> Path:
|
||||
"""Return the Matrix sync/encryption store path for this channel instance."""
|
||||
base = get_data_dir() / "matrix-store"
|
||||
instance_name = (
|
||||
getattr(self.config, "name", "")
|
||||
or (self.name.split("/", 1)[1] if "/" in self.name else "")
|
||||
)
|
||||
if not instance_name:
|
||||
return base
|
||||
return base / safe_filename(instance_name)
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start Matrix client and begin sync loop."""
|
||||
self._running = True
|
||||
_configure_nio_logging_bridge()
|
||||
|
||||
store_path = get_data_dir() / "matrix-store"
|
||||
store_path = self._get_store_path()
|
||||
store_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.client = AsyncClient(
|
||||
|
||||
@@ -16,7 +16,8 @@ from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.paths import get_runtime_subdir
|
||||
from nanobot.config.schema import MochatConfig
|
||||
from nanobot.config.schema import MochatConfig, MochatInstanceConfig
|
||||
from nanobot.utils.helpers import safe_filename
|
||||
|
||||
try:
|
||||
import socketio
|
||||
@@ -218,14 +219,14 @@ class MochatChannel(BaseChannel):
|
||||
name = "mochat"
|
||||
display_name = "Mochat"
|
||||
|
||||
def __init__(self, config: MochatConfig, bus: MessageBus):
|
||||
def __init__(self, config: MochatConfig | MochatInstanceConfig, bus: MessageBus):
|
||||
super().__init__(config, bus)
|
||||
self.config: MochatConfig = config
|
||||
self.config: MochatConfig | MochatInstanceConfig = config
|
||||
self._http: httpx.AsyncClient | None = None
|
||||
self._socket: Any = None
|
||||
self._ws_connected = self._ws_ready = False
|
||||
|
||||
self._state_dir = get_runtime_subdir("mochat")
|
||||
self._state_dir = self._get_state_dir()
|
||||
self._cursor_path = self._state_dir / "session_cursors.json"
|
||||
self._session_cursor: dict[str, int] = {}
|
||||
self._cursor_save_task: asyncio.Task | None = None
|
||||
@@ -247,6 +248,17 @@ class MochatChannel(BaseChannel):
|
||||
self._refresh_task: asyncio.Task | None = None
|
||||
self._target_locks: dict[str, asyncio.Lock] = {}
|
||||
|
||||
def _get_state_dir(self):
|
||||
"""Return the runtime state directory for this channel instance."""
|
||||
base = get_runtime_subdir("mochat")
|
||||
instance_name = (
|
||||
getattr(self.config, "name", "")
|
||||
or (self.name.split("/", 1)[1] if "/" in self.name else "")
|
||||
)
|
||||
if not instance_name:
|
||||
return base
|
||||
return base / safe_filename(instance_name)
|
||||
|
||||
# ---- lifecycle ---------------------------------------------------------
|
||||
|
||||
async def start(self) -> None:
|
||||
|
||||
@@ -9,7 +9,7 @@ from loguru import logger
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.schema import QQConfig
|
||||
from nanobot.config.schema import QQConfig, QQInstanceConfig
|
||||
|
||||
try:
|
||||
import botpy
|
||||
@@ -56,9 +56,9 @@ class QQChannel(BaseChannel):
|
||||
name = "qq"
|
||||
display_name = "QQ"
|
||||
|
||||
def __init__(self, config: QQConfig, bus: MessageBus):
|
||||
def __init__(self, config: QQConfig | QQInstanceConfig, bus: MessageBus):
|
||||
super().__init__(config, bus)
|
||||
self.config: QQConfig = config
|
||||
self.config: QQConfig | QQInstanceConfig = config
|
||||
self._client: "botpy.Client | None" = None
|
||||
self._processed_ids: deque = deque(maxlen=1000)
|
||||
self._msg_seq: int = 1 # 消息序列号,避免被 QQ API 去重
|
||||
|
||||
@@ -14,7 +14,7 @@ from slackify_markdown import slackify_markdown
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.schema import SlackConfig
|
||||
from nanobot.config.schema import SlackConfig, SlackInstanceConfig
|
||||
|
||||
|
||||
class SlackChannel(BaseChannel):
|
||||
@@ -23,9 +23,9 @@ class SlackChannel(BaseChannel):
|
||||
name = "slack"
|
||||
display_name = "Slack"
|
||||
|
||||
def __init__(self, config: SlackConfig, bus: MessageBus):
|
||||
def __init__(self, config: SlackConfig | SlackInstanceConfig, bus: MessageBus):
|
||||
super().__init__(config, bus)
|
||||
self.config: SlackConfig = config
|
||||
self.config: SlackConfig | SlackInstanceConfig = config
|
||||
self._web_client: AsyncWebClient | None = None
|
||||
self._socket_client: SocketModeClient | None = None
|
||||
self._bot_user_id: str | None = None
|
||||
|
||||
@@ -17,7 +17,7 @@ from nanobot.bus.queue import MessageBus
|
||||
from nanobot.agent.i18n import help_lines, normalize_language_code, telegram_command_descriptions, text
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.paths import get_media_dir
|
||||
from nanobot.config.schema import TelegramConfig
|
||||
from nanobot.config.schema import TelegramConfig, TelegramInstanceConfig
|
||||
from nanobot.utils.helpers import split_message
|
||||
|
||||
TELEGRAM_MAX_MESSAGE_LEN = 4000 # Telegram message character limit
|
||||
@@ -161,9 +161,9 @@ class TelegramChannel(BaseChannel):
|
||||
|
||||
COMMAND_NAMES = ("start", "new", "lang", "persona", "stop", "help", "restart")
|
||||
|
||||
def __init__(self, config: TelegramConfig, bus: MessageBus):
|
||||
def __init__(self, config: TelegramConfig | TelegramInstanceConfig, bus: MessageBus):
|
||||
super().__init__(config, bus)
|
||||
self.config: TelegramConfig = config
|
||||
self.config: TelegramConfig | TelegramInstanceConfig = config
|
||||
self._app: Application | None = None
|
||||
self._chat_ids: dict[str, int] = {} # Map sender_id to chat_id for replies
|
||||
self._typing_tasks: dict[str, asyncio.Task] = {} # chat_id -> typing loop task
|
||||
|
||||
@@ -12,7 +12,7 @@ from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.paths import get_media_dir
|
||||
from nanobot.config.schema import WecomConfig
|
||||
from nanobot.config.schema import WecomConfig, WecomInstanceConfig
|
||||
|
||||
WECOM_AVAILABLE = importlib.util.find_spec("wecom_aibot_sdk") is not None
|
||||
|
||||
@@ -38,9 +38,9 @@ class WecomChannel(BaseChannel):
|
||||
name = "wecom"
|
||||
display_name = "WeCom"
|
||||
|
||||
def __init__(self, config: WecomConfig, bus: MessageBus):
|
||||
def __init__(self, config: WecomConfig | WecomInstanceConfig, bus: MessageBus):
|
||||
super().__init__(config, bus)
|
||||
self.config: WecomConfig = config
|
||||
self.config: WecomConfig | WecomInstanceConfig = config
|
||||
self._client: Any = None
|
||||
self._processed_message_ids: OrderedDict[str, None] = OrderedDict()
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
@@ -10,7 +10,7 @@ from loguru import logger
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.schema import WhatsAppConfig
|
||||
from nanobot.config.schema import WhatsAppConfig, WhatsAppInstanceConfig
|
||||
|
||||
|
||||
class WhatsAppChannel(BaseChannel):
|
||||
@@ -24,9 +24,9 @@ class WhatsAppChannel(BaseChannel):
|
||||
name = "whatsapp"
|
||||
display_name = "WhatsApp"
|
||||
|
||||
def __init__(self, config: WhatsAppConfig, bus: MessageBus):
|
||||
def __init__(self, config: WhatsAppConfig | WhatsAppInstanceConfig, bus: MessageBus):
|
||||
super().__init__(config, bus)
|
||||
self.config: WhatsAppConfig = config
|
||||
self.config: WhatsAppConfig | WhatsAppInstanceConfig = config
|
||||
self._ws = None
|
||||
self._connected = False
|
||||
self._processed_message_ids: OrderedDict[str, None] = OrderedDict()
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Configuration schema using Pydantic."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator
|
||||
from pydantic.alias_generators import to_camel
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
@@ -23,6 +23,19 @@ class WhatsAppConfig(Base):
|
||||
allow_from: list[str] = Field(default_factory=list) # Allowed phone numbers
|
||||
|
||||
|
||||
class WhatsAppInstanceConfig(WhatsAppConfig):
|
||||
"""WhatsApp bridge instance config for multi-bot mode."""
|
||||
|
||||
name: str = Field(min_length=1)
|
||||
|
||||
|
||||
class WhatsAppMultiConfig(Base):
|
||||
"""WhatsApp channel configuration supporting multiple bridge instances."""
|
||||
|
||||
enabled: bool = False
|
||||
instances: list[WhatsAppInstanceConfig] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TelegramConfig(Base):
|
||||
"""Telegram channel configuration."""
|
||||
|
||||
@@ -36,6 +49,19 @@ class TelegramConfig(Base):
|
||||
group_policy: Literal["open", "mention"] = "mention" # "mention" responds when @mentioned or replied to, "open" responds to all
|
||||
|
||||
|
||||
class TelegramInstanceConfig(TelegramConfig):
|
||||
"""Telegram bot instance config for multi-bot mode."""
|
||||
|
||||
name: str = Field(min_length=1)
|
||||
|
||||
|
||||
class TelegramMultiConfig(Base):
|
||||
"""Telegram channel configuration supporting multiple bot instances."""
|
||||
|
||||
enabled: bool = False
|
||||
instances: list[TelegramInstanceConfig] = Field(default_factory=list)
|
||||
|
||||
|
||||
class FeishuConfig(Base):
|
||||
"""Feishu/Lark channel configuration using WebSocket long connection."""
|
||||
|
||||
@@ -51,6 +77,19 @@ class FeishuConfig(Base):
|
||||
group_policy: Literal["open", "mention"] = "mention" # "mention" responds when @mentioned, "open" responds to all
|
||||
|
||||
|
||||
class FeishuInstanceConfig(FeishuConfig):
|
||||
"""Feishu bot instance config for multi-bot mode."""
|
||||
|
||||
name: str = Field(min_length=1)
|
||||
|
||||
|
||||
class FeishuMultiConfig(Base):
|
||||
"""Feishu channel configuration supporting multiple bot instances."""
|
||||
|
||||
enabled: bool = False
|
||||
instances: list[FeishuInstanceConfig] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DingTalkConfig(Base):
|
||||
"""DingTalk channel configuration using Stream mode."""
|
||||
|
||||
@@ -60,6 +99,19 @@ class DingTalkConfig(Base):
|
||||
allow_from: list[str] = Field(default_factory=list) # Allowed staff_ids
|
||||
|
||||
|
||||
class DingTalkInstanceConfig(DingTalkConfig):
|
||||
"""DingTalk bot instance config for multi-bot mode."""
|
||||
|
||||
name: str = Field(min_length=1)
|
||||
|
||||
|
||||
class DingTalkMultiConfig(Base):
|
||||
"""DingTalk channel configuration supporting multiple bot instances."""
|
||||
|
||||
enabled: bool = False
|
||||
instances: list[DingTalkInstanceConfig] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DiscordConfig(Base):
|
||||
"""Discord channel configuration."""
|
||||
|
||||
@@ -71,6 +123,19 @@ class DiscordConfig(Base):
|
||||
group_policy: Literal["mention", "open"] = "mention"
|
||||
|
||||
|
||||
class DiscordInstanceConfig(DiscordConfig):
|
||||
"""Discord bot instance config for multi-bot mode."""
|
||||
|
||||
name: str = Field(min_length=1)
|
||||
|
||||
|
||||
class DiscordMultiConfig(Base):
|
||||
"""Discord channel configuration supporting multiple bot instances."""
|
||||
|
||||
enabled: bool = False
|
||||
instances: list[DiscordInstanceConfig] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MatrixConfig(Base):
|
||||
"""Matrix (Element) channel configuration."""
|
||||
|
||||
@@ -92,6 +157,19 @@ class MatrixConfig(Base):
|
||||
allow_room_mentions: bool = False
|
||||
|
||||
|
||||
class MatrixInstanceConfig(MatrixConfig):
|
||||
"""Matrix bot/account instance config for multi-account mode."""
|
||||
|
||||
name: str = Field(min_length=1)
|
||||
|
||||
|
||||
class MatrixMultiConfig(Base):
|
||||
"""Matrix channel configuration supporting multiple accounts."""
|
||||
|
||||
enabled: bool = False
|
||||
instances: list[MatrixInstanceConfig] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EmailConfig(Base):
|
||||
"""Email channel configuration (IMAP inbound + SMTP outbound)."""
|
||||
|
||||
@@ -126,6 +204,19 @@ class EmailConfig(Base):
|
||||
allow_from: list[str] = Field(default_factory=list) # Allowed sender email addresses
|
||||
|
||||
|
||||
class EmailInstanceConfig(EmailConfig):
|
||||
"""Email account instance config for multi-account mode."""
|
||||
|
||||
name: str = Field(min_length=1)
|
||||
|
||||
|
||||
class EmailMultiConfig(Base):
|
||||
"""Email channel configuration supporting multiple accounts."""
|
||||
|
||||
enabled: bool = False
|
||||
instances: list[EmailInstanceConfig] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MochatMentionConfig(Base):
|
||||
"""Mochat mention behavior configuration."""
|
||||
|
||||
@@ -165,6 +256,19 @@ class MochatConfig(Base):
|
||||
reply_delay_ms: int = 120000
|
||||
|
||||
|
||||
class MochatInstanceConfig(MochatConfig):
|
||||
"""Mochat account instance config for multi-account mode."""
|
||||
|
||||
name: str = Field(min_length=1)
|
||||
|
||||
|
||||
class MochatMultiConfig(Base):
|
||||
"""Mochat channel configuration supporting multiple accounts."""
|
||||
|
||||
enabled: bool = False
|
||||
instances: list[MochatInstanceConfig] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SlackDMConfig(Base):
|
||||
"""Slack DM policy configuration."""
|
||||
|
||||
@@ -190,15 +294,39 @@ class SlackConfig(Base):
|
||||
dm: SlackDMConfig = Field(default_factory=SlackDMConfig)
|
||||
|
||||
|
||||
class SlackInstanceConfig(SlackConfig):
|
||||
"""Slack bot instance config for multi-bot mode."""
|
||||
|
||||
name: str = Field(min_length=1)
|
||||
|
||||
|
||||
class SlackMultiConfig(Base):
|
||||
"""Slack channel configuration supporting multiple bot instances."""
|
||||
|
||||
enabled: bool = False
|
||||
instances: list[SlackInstanceConfig] = Field(default_factory=list)
|
||||
|
||||
|
||||
class QQConfig(Base):
|
||||
"""QQ channel configuration using botpy SDK."""
|
||||
"""QQ channel configuration using botpy SDK (single instance)."""
|
||||
|
||||
enabled: bool = False
|
||||
app_id: str = "" # 机器人 ID (AppID) from q.qq.com
|
||||
secret: str = "" # 机器人密钥 (AppSecret) from q.qq.com
|
||||
allow_from: list[str] = Field(
|
||||
default_factory=list
|
||||
) # Allowed user openids (empty = public access)
|
||||
allow_from: list[str] = Field(default_factory=list) # Allowed user openids
|
||||
|
||||
|
||||
class QQInstanceConfig(QQConfig):
|
||||
"""QQ bot instance config for multi-bot mode."""
|
||||
|
||||
name: str = Field(min_length=1) # instance key, routed as channel name "qq/<name>"
|
||||
|
||||
|
||||
class QQMultiConfig(Base):
|
||||
"""QQ channel configuration supporting multiple bot instances."""
|
||||
|
||||
enabled: bool = False
|
||||
instances: list[QQInstanceConfig] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WecomConfig(Base):
|
||||
@@ -211,22 +339,82 @@ class WecomConfig(Base):
|
||||
welcome_message: str = "" # Welcome message for enter_chat event
|
||||
|
||||
|
||||
class WecomInstanceConfig(WecomConfig):
|
||||
"""WeCom bot instance config for multi-bot mode."""
|
||||
|
||||
name: str = Field(min_length=1)
|
||||
|
||||
|
||||
class WecomMultiConfig(Base):
|
||||
"""WeCom channel configuration supporting multiple bot instances."""
|
||||
|
||||
enabled: bool = False
|
||||
instances: list[WecomInstanceConfig] = Field(default_factory=list)
|
||||
|
||||
|
||||
def _coerce_multi_channel_config(
|
||||
value: Any,
|
||||
single_cls: type[BaseModel],
|
||||
multi_cls: type[BaseModel],
|
||||
) -> BaseModel:
|
||||
"""Parse a channel config into single- or multi-instance form."""
|
||||
if isinstance(value, (single_cls, multi_cls)):
|
||||
return value
|
||||
if value is None:
|
||||
return single_cls()
|
||||
if isinstance(value, dict) and "instances" in value:
|
||||
return multi_cls.model_validate(value)
|
||||
return single_cls.model_validate(value)
|
||||
|
||||
|
||||
class ChannelsConfig(Base):
|
||||
"""Configuration for chat channels."""
|
||||
|
||||
send_progress: bool = True # stream agent's text progress to the channel
|
||||
send_tool_hints: bool = False # stream tool-call hints (e.g. read_file("…"))
|
||||
whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig)
|
||||
telegram: TelegramConfig = Field(default_factory=TelegramConfig)
|
||||
discord: DiscordConfig = Field(default_factory=DiscordConfig)
|
||||
feishu: FeishuConfig = Field(default_factory=FeishuConfig)
|
||||
mochat: MochatConfig = Field(default_factory=MochatConfig)
|
||||
dingtalk: DingTalkConfig = Field(default_factory=DingTalkConfig)
|
||||
email: EmailConfig = Field(default_factory=EmailConfig)
|
||||
slack: SlackConfig = Field(default_factory=SlackConfig)
|
||||
qq: QQConfig = Field(default_factory=QQConfig)
|
||||
matrix: MatrixConfig = Field(default_factory=MatrixConfig)
|
||||
wecom: WecomConfig = Field(default_factory=WecomConfig)
|
||||
whatsapp: WhatsAppConfig | WhatsAppMultiConfig = Field(default_factory=WhatsAppConfig)
|
||||
telegram: TelegramConfig | TelegramMultiConfig = Field(default_factory=TelegramConfig)
|
||||
discord: DiscordConfig | DiscordMultiConfig = Field(default_factory=DiscordConfig)
|
||||
feishu: FeishuConfig | FeishuMultiConfig = Field(default_factory=FeishuConfig)
|
||||
mochat: MochatConfig | MochatMultiConfig = Field(default_factory=MochatConfig)
|
||||
dingtalk: DingTalkConfig | DingTalkMultiConfig = Field(default_factory=DingTalkConfig)
|
||||
email: EmailConfig | EmailMultiConfig = Field(default_factory=EmailConfig)
|
||||
slack: SlackConfig | SlackMultiConfig = Field(default_factory=SlackConfig)
|
||||
qq: QQConfig | QQMultiConfig = Field(default_factory=QQConfig)
|
||||
matrix: MatrixConfig | MatrixMultiConfig = Field(default_factory=MatrixConfig)
|
||||
wecom: WecomConfig | WecomMultiConfig = Field(default_factory=WecomConfig)
|
||||
|
||||
@field_validator(
|
||||
"whatsapp",
|
||||
"telegram",
|
||||
"discord",
|
||||
"feishu",
|
||||
"mochat",
|
||||
"dingtalk",
|
||||
"email",
|
||||
"slack",
|
||||
"qq",
|
||||
"matrix",
|
||||
"wecom",
|
||||
mode="before",
|
||||
)
|
||||
@classmethod
|
||||
def _parse_multi_instance_channels(cls, value: Any, info: ValidationInfo) -> BaseModel:
|
||||
mapping: dict[str, tuple[type[BaseModel], type[BaseModel]]] = {
|
||||
"whatsapp": (WhatsAppConfig, WhatsAppMultiConfig),
|
||||
"telegram": (TelegramConfig, TelegramMultiConfig),
|
||||
"discord": (DiscordConfig, DiscordMultiConfig),
|
||||
"feishu": (FeishuConfig, FeishuMultiConfig),
|
||||
"mochat": (MochatConfig, MochatMultiConfig),
|
||||
"dingtalk": (DingTalkConfig, DingTalkMultiConfig),
|
||||
"email": (EmailConfig, EmailMultiConfig),
|
||||
"slack": (SlackConfig, SlackMultiConfig),
|
||||
"qq": (QQConfig, QQMultiConfig),
|
||||
"matrix": (MatrixConfig, MatrixMultiConfig),
|
||||
"wecom": (WecomConfig, WecomMultiConfig),
|
||||
}
|
||||
single_cls, multi_cls = mapping[info.field_name]
|
||||
return _coerce_multi_channel_config(value, single_cls, multi_cls)
|
||||
|
||||
|
||||
class AgentDefaults(Base):
|
||||
|
||||
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