From b24ad7b526fc3ed9321e081ab2b853dabed93517 Mon Sep 17 00:00:00 2001 From: Hua Date: Fri, 13 Mar 2026 22:41:24 +0800 Subject: [PATCH] feat(channels): support multi-instance channel configs --- README.md | 66 ++++ nanobot/channels/dingtalk.py | 6 +- nanobot/channels/discord.py | 6 +- nanobot/channels/email.py | 6 +- nanobot/channels/feishu.py | 6 +- nanobot/channels/manager.py | 39 ++- nanobot/channels/matrix.py | 17 +- nanobot/channels/mochat.py | 20 +- nanobot/channels/qq.py | 6 +- nanobot/channels/slack.py | 6 +- nanobot/channels/telegram.py | 6 +- nanobot/channels/wecom.py | 6 +- nanobot/channels/whatsapp.py | 6 +- nanobot/config/schema.py | 222 +++++++++++- tests/test_channel_multi_config.py | 524 +++++++++++++++++++++++++++++ tests/test_channel_multi_state.py | 67 ++++ 16 files changed, 955 insertions(+), 54 deletions(-) create mode 100644 tests/test_channel_multi_config.py create mode 100644 tests/test_channel_multi_state.py diff --git a/README.md b/README.md index 44e7f91..4fe3538 100644 --- a/README.md +++ b/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. +
Telegram (Recommended) @@ -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. +
@@ -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/` +> 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/`. ```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 diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py index 4626d95..5a32155 100644 --- a/nanobot/channels/dingtalk.py +++ b/nanobot/channels/dingtalk.py @@ -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 diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index afa20c9..a11101f 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -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 diff --git a/nanobot/channels/email.py b/nanobot/channels/email.py index 46c2103..25cfd1e 100644 --- a/nanobot/channels/email.py +++ b/nanobot/channels/email.py @@ -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 diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 2eb6a6a..52f5eda 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -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 diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 8288ad0..80ec3cc 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -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) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 0d7a908..a5b3595 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -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( diff --git a/nanobot/channels/mochat.py b/nanobot/channels/mochat.py index 52e246f..79220b2 100644 --- a/nanobot/channels/mochat.py +++ b/nanobot/channels/mochat.py @@ -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: diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index 80b7500..4b20d80 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -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 去重 diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index 5819212..a13620e 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -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 diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index cfb05e6..3eca542 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -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 diff --git a/nanobot/channels/wecom.py b/nanobot/channels/wecom.py index e0f4ae0..6c3e90b 100644 --- a/nanobot/channels/wecom.py +++ b/nanobot/channels/wecom.py @@ -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 diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index 7fffb80..4360a9c 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -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() diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index dcc5869..d50ca5a 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -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/" + + +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): diff --git a/tests/test_channel_multi_config.py b/tests/test_channel_multi_config.py new file mode 100644 index 0000000..46f2b6b --- /dev/null +++ b/tests/test_channel_multi_config.py @@ -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 == [] diff --git a/tests/test_channel_multi_state.py b/tests/test_channel_multi_state.py new file mode 100644 index 0000000..aaa43a1 --- /dev/null +++ b/tests/test_channel_multi_state.py @@ -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"