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"