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 |
|
| **QQ** | App ID + App Secret |
|
||||||
| **Wecom** | Bot ID + Bot 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>
|
<details>
|
||||||
<summary><b>Telegram</b> (Recommended)</summary>
|
<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>
|
</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.
|
> 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 |
|
| 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)
|
**3. Run** (two terminals)
|
||||||
|
|
||||||
```bash
|
```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.
|
> - `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.
|
> - 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
|
```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**
|
**4. Run**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from loguru import logger
|
|||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
from nanobot.config.schema import DingTalkConfig
|
from nanobot.config.schema import DingTalkConfig, DingTalkInstanceConfig
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from dingtalk_stream import (
|
from dingtalk_stream import (
|
||||||
@@ -119,9 +119,9 @@ class DingTalkChannel(BaseChannel):
|
|||||||
_AUDIO_EXTS = {".amr", ".mp3", ".wav", ".ogg", ".m4a", ".aac"}
|
_AUDIO_EXTS = {".amr", ".mp3", ".wav", ".ogg", ".m4a", ".aac"}
|
||||||
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm"}
|
_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)
|
super().__init__(config, bus)
|
||||||
self.config: DingTalkConfig = config
|
self.config: DingTalkConfig | DingTalkInstanceConfig = config
|
||||||
self._client: Any = None
|
self._client: Any = None
|
||||||
self._http: httpx.AsyncClient | None = 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.bus.queue import MessageBus
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
from nanobot.config.paths import get_media_dir
|
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
|
from nanobot.utils.helpers import split_message
|
||||||
|
|
||||||
DISCORD_API_BASE = "https://discord.com/api/v10"
|
DISCORD_API_BASE = "https://discord.com/api/v10"
|
||||||
@@ -27,9 +27,9 @@ class DiscordChannel(BaseChannel):
|
|||||||
name = "discord"
|
name = "discord"
|
||||||
display_name = "Discord"
|
display_name = "Discord"
|
||||||
|
|
||||||
def __init__(self, config: DiscordConfig, bus: MessageBus):
|
def __init__(self, config: DiscordConfig | DiscordInstanceConfig, bus: MessageBus):
|
||||||
super().__init__(config, bus)
|
super().__init__(config, bus)
|
||||||
self.config: DiscordConfig = config
|
self.config: DiscordConfig | DiscordInstanceConfig = config
|
||||||
self._ws: websockets.WebSocketClientProtocol | None = None
|
self._ws: websockets.WebSocketClientProtocol | None = None
|
||||||
self._seq: int | None = None
|
self._seq: int | None = None
|
||||||
self._heartbeat_task: asyncio.Task | 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.events import OutboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
from nanobot.config.schema import EmailConfig
|
from nanobot.config.schema import EmailConfig, EmailInstanceConfig
|
||||||
|
|
||||||
|
|
||||||
class EmailChannel(BaseChannel):
|
class EmailChannel(BaseChannel):
|
||||||
@@ -51,9 +51,9 @@ class EmailChannel(BaseChannel):
|
|||||||
"Dec",
|
"Dec",
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, config: EmailConfig, bus: MessageBus):
|
def __init__(self, config: EmailConfig | EmailInstanceConfig, bus: MessageBus):
|
||||||
super().__init__(config, bus)
|
super().__init__(config, bus)
|
||||||
self.config: EmailConfig = config
|
self.config: EmailConfig | EmailInstanceConfig = config
|
||||||
self._last_subject_by_chat: dict[str, str] = {}
|
self._last_subject_by_chat: dict[str, str] = {}
|
||||||
self._last_message_id_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
|
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.bus.queue import MessageBus
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
from nanobot.config.paths import get_media_dir
|
from nanobot.config.paths import get_media_dir
|
||||||
from nanobot.config.schema import FeishuConfig
|
from nanobot.config.schema import FeishuConfig, FeishuInstanceConfig
|
||||||
|
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
|
||||||
@@ -246,9 +246,9 @@ class FeishuChannel(BaseChannel):
|
|||||||
name = "feishu"
|
name = "feishu"
|
||||||
display_name = "Feishu"
|
display_name = "Feishu"
|
||||||
|
|
||||||
def __init__(self, config: FeishuConfig, bus: MessageBus):
|
def __init__(self, config: FeishuConfig | FeishuInstanceConfig, bus: MessageBus):
|
||||||
super().__init__(config, bus)
|
super().__init__(config, bus)
|
||||||
self.config: FeishuConfig = config
|
self.config: FeishuConfig | FeishuInstanceConfig = config
|
||||||
self._client: Any = None
|
self._client: Any = None
|
||||||
self._ws_client: Any = None
|
self._ws_client: Any = None
|
||||||
self._ws_thread: threading.Thread | None = None
|
self._ws_thread: threading.Thread | None = None
|
||||||
|
|||||||
@@ -42,6 +42,37 @@ class ChannelManager:
|
|||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
cls = load_channel_class(modname)
|
cls = load_channel_class(modname)
|
||||||
|
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 = cls(section, self.bus)
|
||||||
channel.transcription_api_key = groq_key
|
channel.transcription_api_key = groq_key
|
||||||
self.channels[modname] = channel
|
self.channels[modname] = channel
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ from nanobot.bus.events import OutboundMessage
|
|||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
from nanobot.config.paths import get_data_dir, get_media_dir
|
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
|
from nanobot.utils.helpers import safe_filename
|
||||||
|
|
||||||
TYPING_NOTICE_TIMEOUT_MS = 30_000
|
TYPING_NOTICE_TIMEOUT_MS = 30_000
|
||||||
@@ -149,8 +150,9 @@ class MatrixChannel(BaseChannel):
|
|||||||
name = "matrix"
|
name = "matrix"
|
||||||
display_name = "Matrix"
|
display_name = "Matrix"
|
||||||
|
|
||||||
def __init__(self, config: Any, bus: MessageBus):
|
def __init__(self, config: MatrixConfig | MatrixInstanceConfig, bus: MessageBus):
|
||||||
super().__init__(config, bus)
|
super().__init__(config, bus)
|
||||||
|
self.config: MatrixConfig | MatrixInstanceConfig = config
|
||||||
self.client: AsyncClient | None = None
|
self.client: AsyncClient | None = None
|
||||||
self._sync_task: asyncio.Task | None = None
|
self._sync_task: asyncio.Task | None = None
|
||||||
self._typing_tasks: dict[str, asyncio.Task] = {}
|
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_bytes: int | None = None
|
||||||
self._server_upload_limit_checked = False
|
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:
|
async def start(self) -> None:
|
||||||
"""Start Matrix client and begin sync loop."""
|
"""Start Matrix client and begin sync loop."""
|
||||||
self._running = True
|
self._running = True
|
||||||
_configure_nio_logging_bridge()
|
_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)
|
store_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
self.client = AsyncClient(
|
self.client = AsyncClient(
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ from nanobot.bus.events import OutboundMessage
|
|||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
from nanobot.config.paths import get_runtime_subdir
|
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:
|
try:
|
||||||
import socketio
|
import socketio
|
||||||
@@ -218,14 +219,14 @@ class MochatChannel(BaseChannel):
|
|||||||
name = "mochat"
|
name = "mochat"
|
||||||
display_name = "Mochat"
|
display_name = "Mochat"
|
||||||
|
|
||||||
def __init__(self, config: MochatConfig, bus: MessageBus):
|
def __init__(self, config: MochatConfig | MochatInstanceConfig, bus: MessageBus):
|
||||||
super().__init__(config, bus)
|
super().__init__(config, bus)
|
||||||
self.config: MochatConfig = config
|
self.config: MochatConfig | MochatInstanceConfig = config
|
||||||
self._http: httpx.AsyncClient | None = None
|
self._http: httpx.AsyncClient | None = None
|
||||||
self._socket: Any = None
|
self._socket: Any = None
|
||||||
self._ws_connected = self._ws_ready = False
|
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._cursor_path = self._state_dir / "session_cursors.json"
|
||||||
self._session_cursor: dict[str, int] = {}
|
self._session_cursor: dict[str, int] = {}
|
||||||
self._cursor_save_task: asyncio.Task | None = None
|
self._cursor_save_task: asyncio.Task | None = None
|
||||||
@@ -247,6 +248,17 @@ class MochatChannel(BaseChannel):
|
|||||||
self._refresh_task: asyncio.Task | None = None
|
self._refresh_task: asyncio.Task | None = None
|
||||||
self._target_locks: dict[str, asyncio.Lock] = {}
|
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 ---------------------------------------------------------
|
# ---- lifecycle ---------------------------------------------------------
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from loguru import logger
|
|||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
from nanobot.config.schema import QQConfig
|
from nanobot.config.schema import QQConfig, QQInstanceConfig
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import botpy
|
import botpy
|
||||||
@@ -56,9 +56,9 @@ class QQChannel(BaseChannel):
|
|||||||
name = "qq"
|
name = "qq"
|
||||||
display_name = "QQ"
|
display_name = "QQ"
|
||||||
|
|
||||||
def __init__(self, config: QQConfig, bus: MessageBus):
|
def __init__(self, config: QQConfig | QQInstanceConfig, bus: MessageBus):
|
||||||
super().__init__(config, bus)
|
super().__init__(config, bus)
|
||||||
self.config: QQConfig = config
|
self.config: QQConfig | QQInstanceConfig = config
|
||||||
self._client: "botpy.Client | None" = None
|
self._client: "botpy.Client | None" = None
|
||||||
self._processed_ids: deque = deque(maxlen=1000)
|
self._processed_ids: deque = deque(maxlen=1000)
|
||||||
self._msg_seq: int = 1 # 消息序列号,避免被 QQ API 去重
|
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.events import OutboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
from nanobot.config.schema import SlackConfig
|
from nanobot.config.schema import SlackConfig, SlackInstanceConfig
|
||||||
|
|
||||||
|
|
||||||
class SlackChannel(BaseChannel):
|
class SlackChannel(BaseChannel):
|
||||||
@@ -23,9 +23,9 @@ class SlackChannel(BaseChannel):
|
|||||||
name = "slack"
|
name = "slack"
|
||||||
display_name = "Slack"
|
display_name = "Slack"
|
||||||
|
|
||||||
def __init__(self, config: SlackConfig, bus: MessageBus):
|
def __init__(self, config: SlackConfig | SlackInstanceConfig, bus: MessageBus):
|
||||||
super().__init__(config, bus)
|
super().__init__(config, bus)
|
||||||
self.config: SlackConfig = config
|
self.config: SlackConfig | SlackInstanceConfig = config
|
||||||
self._web_client: AsyncWebClient | None = None
|
self._web_client: AsyncWebClient | None = None
|
||||||
self._socket_client: SocketModeClient | None = None
|
self._socket_client: SocketModeClient | None = None
|
||||||
self._bot_user_id: str | 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.agent.i18n import help_lines, normalize_language_code, telegram_command_descriptions, text
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
from nanobot.config.paths import get_media_dir
|
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
|
from nanobot.utils.helpers import split_message
|
||||||
|
|
||||||
TELEGRAM_MAX_MESSAGE_LEN = 4000 # Telegram message character limit
|
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")
|
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)
|
super().__init__(config, bus)
|
||||||
self.config: TelegramConfig = config
|
self.config: TelegramConfig | TelegramInstanceConfig = config
|
||||||
self._app: Application | None = None
|
self._app: Application | None = None
|
||||||
self._chat_ids: dict[str, int] = {} # Map sender_id to chat_id for replies
|
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
|
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.bus.queue import MessageBus
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
from nanobot.config.paths import get_media_dir
|
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
|
WECOM_AVAILABLE = importlib.util.find_spec("wecom_aibot_sdk") is not None
|
||||||
|
|
||||||
@@ -38,9 +38,9 @@ class WecomChannel(BaseChannel):
|
|||||||
name = "wecom"
|
name = "wecom"
|
||||||
display_name = "WeCom"
|
display_name = "WeCom"
|
||||||
|
|
||||||
def __init__(self, config: WecomConfig, bus: MessageBus):
|
def __init__(self, config: WecomConfig | WecomInstanceConfig, bus: MessageBus):
|
||||||
super().__init__(config, bus)
|
super().__init__(config, bus)
|
||||||
self.config: WecomConfig = config
|
self.config: WecomConfig | WecomInstanceConfig = config
|
||||||
self._client: Any = None
|
self._client: Any = None
|
||||||
self._processed_message_ids: OrderedDict[str, None] = OrderedDict()
|
self._processed_message_ids: OrderedDict[str, None] = OrderedDict()
|
||||||
self._loop: asyncio.AbstractEventLoop | None = None
|
self._loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from loguru import logger
|
|||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
from nanobot.config.schema import WhatsAppConfig
|
from nanobot.config.schema import WhatsAppConfig, WhatsAppInstanceConfig
|
||||||
|
|
||||||
|
|
||||||
class WhatsAppChannel(BaseChannel):
|
class WhatsAppChannel(BaseChannel):
|
||||||
@@ -24,9 +24,9 @@ class WhatsAppChannel(BaseChannel):
|
|||||||
name = "whatsapp"
|
name = "whatsapp"
|
||||||
display_name = "WhatsApp"
|
display_name = "WhatsApp"
|
||||||
|
|
||||||
def __init__(self, config: WhatsAppConfig, bus: MessageBus):
|
def __init__(self, config: WhatsAppConfig | WhatsAppInstanceConfig, bus: MessageBus):
|
||||||
super().__init__(config, bus)
|
super().__init__(config, bus)
|
||||||
self.config: WhatsAppConfig = config
|
self.config: WhatsAppConfig | WhatsAppInstanceConfig = config
|
||||||
self._ws = None
|
self._ws = None
|
||||||
self._connected = False
|
self._connected = False
|
||||||
self._processed_message_ids: OrderedDict[str, None] = OrderedDict()
|
self._processed_message_ids: OrderedDict[str, None] = OrderedDict()
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"""Configuration schema using Pydantic."""
|
"""Configuration schema using Pydantic."""
|
||||||
|
|
||||||
from pathlib import Path
|
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.alias_generators import to_camel
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
@@ -23,6 +23,19 @@ class WhatsAppConfig(Base):
|
|||||||
allow_from: list[str] = Field(default_factory=list) # Allowed phone numbers
|
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):
|
class TelegramConfig(Base):
|
||||||
"""Telegram channel configuration."""
|
"""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
|
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):
|
class FeishuConfig(Base):
|
||||||
"""Feishu/Lark channel configuration using WebSocket long connection."""
|
"""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
|
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):
|
class DingTalkConfig(Base):
|
||||||
"""DingTalk channel configuration using Stream mode."""
|
"""DingTalk channel configuration using Stream mode."""
|
||||||
|
|
||||||
@@ -60,6 +99,19 @@ class DingTalkConfig(Base):
|
|||||||
allow_from: list[str] = Field(default_factory=list) # Allowed staff_ids
|
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):
|
class DiscordConfig(Base):
|
||||||
"""Discord channel configuration."""
|
"""Discord channel configuration."""
|
||||||
|
|
||||||
@@ -71,6 +123,19 @@ class DiscordConfig(Base):
|
|||||||
group_policy: Literal["mention", "open"] = "mention"
|
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):
|
class MatrixConfig(Base):
|
||||||
"""Matrix (Element) channel configuration."""
|
"""Matrix (Element) channel configuration."""
|
||||||
|
|
||||||
@@ -92,6 +157,19 @@ class MatrixConfig(Base):
|
|||||||
allow_room_mentions: bool = False
|
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):
|
class EmailConfig(Base):
|
||||||
"""Email channel configuration (IMAP inbound + SMTP outbound)."""
|
"""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
|
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):
|
class MochatMentionConfig(Base):
|
||||||
"""Mochat mention behavior configuration."""
|
"""Mochat mention behavior configuration."""
|
||||||
|
|
||||||
@@ -165,6 +256,19 @@ class MochatConfig(Base):
|
|||||||
reply_delay_ms: int = 120000
|
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):
|
class SlackDMConfig(Base):
|
||||||
"""Slack DM policy configuration."""
|
"""Slack DM policy configuration."""
|
||||||
|
|
||||||
@@ -190,15 +294,39 @@ class SlackConfig(Base):
|
|||||||
dm: SlackDMConfig = Field(default_factory=SlackDMConfig)
|
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):
|
class QQConfig(Base):
|
||||||
"""QQ channel configuration using botpy SDK."""
|
"""QQ channel configuration using botpy SDK (single instance)."""
|
||||||
|
|
||||||
enabled: bool = False
|
enabled: bool = False
|
||||||
app_id: str = "" # 机器人 ID (AppID) from q.qq.com
|
app_id: str = "" # 机器人 ID (AppID) from q.qq.com
|
||||||
secret: str = "" # 机器人密钥 (AppSecret) from q.qq.com
|
secret: str = "" # 机器人密钥 (AppSecret) from q.qq.com
|
||||||
allow_from: list[str] = Field(
|
allow_from: list[str] = Field(default_factory=list) # Allowed user openids
|
||||||
default_factory=list
|
|
||||||
) # Allowed user openids (empty = public access)
|
|
||||||
|
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):
|
class WecomConfig(Base):
|
||||||
@@ -211,22 +339,82 @@ class WecomConfig(Base):
|
|||||||
welcome_message: str = "" # Welcome message for enter_chat event
|
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):
|
class ChannelsConfig(Base):
|
||||||
"""Configuration for chat channels."""
|
"""Configuration for chat channels."""
|
||||||
|
|
||||||
send_progress: bool = True # stream agent's text progress to the channel
|
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("…"))
|
send_tool_hints: bool = False # stream tool-call hints (e.g. read_file("…"))
|
||||||
whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig)
|
whatsapp: WhatsAppConfig | WhatsAppMultiConfig = Field(default_factory=WhatsAppConfig)
|
||||||
telegram: TelegramConfig = Field(default_factory=TelegramConfig)
|
telegram: TelegramConfig | TelegramMultiConfig = Field(default_factory=TelegramConfig)
|
||||||
discord: DiscordConfig = Field(default_factory=DiscordConfig)
|
discord: DiscordConfig | DiscordMultiConfig = Field(default_factory=DiscordConfig)
|
||||||
feishu: FeishuConfig = Field(default_factory=FeishuConfig)
|
feishu: FeishuConfig | FeishuMultiConfig = Field(default_factory=FeishuConfig)
|
||||||
mochat: MochatConfig = Field(default_factory=MochatConfig)
|
mochat: MochatConfig | MochatMultiConfig = Field(default_factory=MochatConfig)
|
||||||
dingtalk: DingTalkConfig = Field(default_factory=DingTalkConfig)
|
dingtalk: DingTalkConfig | DingTalkMultiConfig = Field(default_factory=DingTalkConfig)
|
||||||
email: EmailConfig = Field(default_factory=EmailConfig)
|
email: EmailConfig | EmailMultiConfig = Field(default_factory=EmailConfig)
|
||||||
slack: SlackConfig = Field(default_factory=SlackConfig)
|
slack: SlackConfig | SlackMultiConfig = Field(default_factory=SlackConfig)
|
||||||
qq: QQConfig = Field(default_factory=QQConfig)
|
qq: QQConfig | QQMultiConfig = Field(default_factory=QQConfig)
|
||||||
matrix: MatrixConfig = Field(default_factory=MatrixConfig)
|
matrix: MatrixConfig | MatrixMultiConfig = Field(default_factory=MatrixConfig)
|
||||||
wecom: WecomConfig = Field(default_factory=WecomConfig)
|
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):
|
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