feat: channel plugin architecture with decoupled configs
- Add plugin discovery via Python entry_points (group: nanobot.channels) - Move 11 channel Config classes from schema.py into their own channel modules - ChannelsConfig now only keeps send_progress + send_tool_hints (extra=allow) - Each built-in channel parses dict->Pydantic in __init__, zero internal changes - All channels implement default_config() for onboard auto-population - nanobot onboard injects defaults for all discovered channels (built-in + plugins) - Add nanobot plugins list CLI command - Add Channel Plugin Guide (docs/CHANNEL_PLUGIN_GUIDE.md) - Fully backward compatible: existing config.json and sessions work as-is - 340 tests pass, zero regressions
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,7 +5,6 @@
|
|||||||
*.pyc
|
*.pyc
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
docs/
|
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
*.egg
|
*.egg
|
||||||
*.pycs
|
*.pycs
|
||||||
|
|||||||
@@ -216,7 +216,9 @@ That's it! You have a working AI assistant in 2 minutes.
|
|||||||
|
|
||||||
## 💬 Chat Apps
|
## 💬 Chat Apps
|
||||||
|
|
||||||
Connect nanobot to your favorite chat platform.
|
Connect nanobot to your favorite chat platform. Want to build your own? See the [Channel Plugin Guide](.docs/CHANNEL_PLUGIN_GUIDE.md).
|
||||||
|
|
||||||
|
> Channel plugin support is available in the `main` branch; not yet published to PyPI.
|
||||||
|
|
||||||
| Channel | What you need |
|
| Channel | What you need |
|
||||||
|---------|---------------|
|
|---------|---------------|
|
||||||
@@ -1370,7 +1372,7 @@ nanobot/
|
|||||||
│ ├── subagent.py # Background task execution
|
│ ├── subagent.py # Background task execution
|
||||||
│ └── tools/ # Built-in tools (incl. spawn)
|
│ └── tools/ # Built-in tools (incl. spawn)
|
||||||
├── skills/ # 🎯 Bundled skills (github, weather, tmux...)
|
├── skills/ # 🎯 Bundled skills (github, weather, tmux...)
|
||||||
├── channels/ # 📱 Chat channel integrations
|
├── channels/ # 📱 Chat channel integrations (supports plugins)
|
||||||
├── bus/ # 🚌 Message routing
|
├── bus/ # 🚌 Message routing
|
||||||
├── cron/ # ⏰ Scheduled tasks
|
├── cron/ # ⏰ Scheduled tasks
|
||||||
├── heartbeat/ # 💓 Proactive wake-up
|
├── heartbeat/ # 💓 Proactive wake-up
|
||||||
|
|||||||
254
docs/CHANNEL_PLUGIN_GUIDE.md
Normal file
254
docs/CHANNEL_PLUGIN_GUIDE.md
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
# Channel Plugin Guide
|
||||||
|
|
||||||
|
Build a custom nanobot channel in three steps: subclass, package, install.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
nanobot discovers channel plugins via Python [entry points](https://packaging.python.org/en/latest/specifications/entry-points/). When `nanobot gateway` starts, it scans:
|
||||||
|
|
||||||
|
1. Built-in channels in `nanobot/channels/`
|
||||||
|
2. External packages registered under the `nanobot.channels` entry point group
|
||||||
|
|
||||||
|
If a matching config section has `"enabled": true`, the channel is instantiated and started.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
We'll build a minimal webhook channel that receives messages via HTTP POST and sends replies back.
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
nanobot-channel-webhook/
|
||||||
|
├── nanobot_channel_webhook/
|
||||||
|
│ ├── __init__.py # re-export WebhookChannel
|
||||||
|
│ └── channel.py # channel implementation
|
||||||
|
└── pyproject.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1. Create Your Channel
|
||||||
|
|
||||||
|
```python
|
||||||
|
# nanobot_channel_webhook/__init__.py
|
||||||
|
from nanobot_channel_webhook.channel import WebhookChannel
|
||||||
|
|
||||||
|
__all__ = ["WebhookChannel"]
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# nanobot_channel_webhook/channel.py
|
||||||
|
import asyncio
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from nanobot.channels.base import BaseChannel
|
||||||
|
from nanobot.bus.events import OutboundMessage
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookChannel(BaseChannel):
|
||||||
|
name = "webhook"
|
||||||
|
display_name = "Webhook"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def default_config(cls) -> dict[str, Any]:
|
||||||
|
return {"enabled": False, "port": 9000, "allowFrom": []}
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
"""Start an HTTP server that listens for incoming messages.
|
||||||
|
|
||||||
|
IMPORTANT: start() must block forever (or until stop() is called).
|
||||||
|
If it returns, the channel is considered dead.
|
||||||
|
"""
|
||||||
|
self._running = True
|
||||||
|
port = self.config.get("port", 9000)
|
||||||
|
|
||||||
|
app = web.Application()
|
||||||
|
app.router.add_post("/message", self._on_request)
|
||||||
|
runner = web.AppRunner(app)
|
||||||
|
await runner.setup()
|
||||||
|
site = web.TCPSite(runner, "0.0.0.0", port)
|
||||||
|
await site.start()
|
||||||
|
logger.info("Webhook listening on :{}", port)
|
||||||
|
|
||||||
|
# Block until stopped
|
||||||
|
while self._running:
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
await runner.cleanup()
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
async def send(self, msg: OutboundMessage) -> None:
|
||||||
|
"""Deliver an outbound message.
|
||||||
|
|
||||||
|
msg.content — markdown text (convert to platform format as needed)
|
||||||
|
msg.media — list of local file paths to attach
|
||||||
|
msg.chat_id — the recipient (same chat_id you passed to _handle_message)
|
||||||
|
msg.metadata — may contain "_progress": True for streaming chunks
|
||||||
|
"""
|
||||||
|
logger.info("[webhook] -> {}: {}", msg.chat_id, msg.content[:80])
|
||||||
|
# In a real plugin: POST to a callback URL, send via SDK, etc.
|
||||||
|
|
||||||
|
async def _on_request(self, request: web.Request) -> web.Response:
|
||||||
|
"""Handle an incoming HTTP POST."""
|
||||||
|
body = await request.json()
|
||||||
|
sender = body.get("sender", "unknown")
|
||||||
|
chat_id = body.get("chat_id", sender)
|
||||||
|
text = body.get("text", "")
|
||||||
|
media = body.get("media", []) # list of URLs
|
||||||
|
|
||||||
|
# This is the key call: validates allowFrom, then puts the
|
||||||
|
# message onto the bus for the agent to process.
|
||||||
|
await self._handle_message(
|
||||||
|
sender_id=sender,
|
||||||
|
chat_id=chat_id,
|
||||||
|
content=text,
|
||||||
|
media=media,
|
||||||
|
)
|
||||||
|
|
||||||
|
return web.json_response({"ok": True})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Register the Entry Point
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# pyproject.toml
|
||||||
|
[project]
|
||||||
|
name = "nanobot-channel-webhook"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = ["nanobot", "aiohttp"]
|
||||||
|
|
||||||
|
[project.entry-points."nanobot.channels"]
|
||||||
|
webhook = "nanobot_channel_webhook:WebhookChannel"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools"]
|
||||||
|
build-backend = "setuptools.backends._legacy:_Backend"
|
||||||
|
```
|
||||||
|
|
||||||
|
The key (`webhook`) becomes the config section name. The value points to your `BaseChannel` subclass.
|
||||||
|
|
||||||
|
### 3. Install & Configure
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e .
|
||||||
|
nanobot plugins list # verify "Webhook" shows as "plugin"
|
||||||
|
nanobot onboard # auto-adds default config for detected plugins
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `~/.nanobot/config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"webhook": {
|
||||||
|
"enabled": true,
|
||||||
|
"port": 9000,
|
||||||
|
"allowFrom": ["*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Run & Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
In another terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:9000/message \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"sender": "user1", "chat_id": "user1", "text": "Hello!"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
The agent receives the message and processes it. Replies arrive in your `send()` method.
|
||||||
|
|
||||||
|
## BaseChannel API
|
||||||
|
|
||||||
|
### Required (abstract)
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `async start()` | **Must block forever.** Connect to platform, listen for messages, call `_handle_message()` on each. If this returns, the channel is dead. |
|
||||||
|
| `async stop()` | Set `self._running = False` and clean up. Called when gateway shuts down. |
|
||||||
|
| `async send(msg: OutboundMessage)` | Deliver an outbound message to the platform. |
|
||||||
|
|
||||||
|
### Provided by Base
|
||||||
|
|
||||||
|
| Method / Property | Description |
|
||||||
|
|-------------------|-------------|
|
||||||
|
| `_handle_message(sender_id, chat_id, content, media?, metadata?, session_key?)` | **Call this when you receive a message.** Checks `is_allowed()`, then publishes to the bus. |
|
||||||
|
| `is_allowed(sender_id)` | Checks against `config["allowFrom"]`; `"*"` allows all, `[]` denies all. |
|
||||||
|
| `default_config()` (classmethod) | Returns default config dict for `nanobot onboard`. Override to declare your fields. |
|
||||||
|
| `transcribe_audio(file_path)` | Transcribes audio via Groq Whisper (if configured). |
|
||||||
|
| `is_running` | Returns `self._running`. |
|
||||||
|
|
||||||
|
### Message Types
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class OutboundMessage:
|
||||||
|
channel: str # your channel name
|
||||||
|
chat_id: str # recipient (same value you passed to _handle_message)
|
||||||
|
content: str # markdown text — convert to platform format as needed
|
||||||
|
media: list[str] # local file paths to attach (images, audio, docs)
|
||||||
|
metadata: dict # may contain: "_progress" (bool) for streaming chunks,
|
||||||
|
# "message_id" for reply threading
|
||||||
|
```
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
Your channel receives config as a plain `dict`. Access fields with `.get()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def start(self) -> None:
|
||||||
|
port = self.config.get("port", 9000)
|
||||||
|
token = self.config.get("token", "")
|
||||||
|
```
|
||||||
|
|
||||||
|
`allowFrom` is handled automatically by `_handle_message()` — you don't need to check it yourself.
|
||||||
|
|
||||||
|
Override `default_config()` so `nanobot onboard` auto-populates `config.json`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@classmethod
|
||||||
|
def default_config(cls) -> dict[str, Any]:
|
||||||
|
return {"enabled": False, "port": 9000, "allowFrom": []}
|
||||||
|
```
|
||||||
|
|
||||||
|
If not overridden, the base class returns `{"enabled": false}`.
|
||||||
|
|
||||||
|
## Naming Convention
|
||||||
|
|
||||||
|
| What | Format | Example |
|
||||||
|
|------|--------|---------|
|
||||||
|
| PyPI package | `nanobot-channel-{name}` | `nanobot-channel-webhook` |
|
||||||
|
| Entry point key | `{name}` | `webhook` |
|
||||||
|
| Config section | `channels.{name}` | `channels.webhook` |
|
||||||
|
| Python package | `nanobot_channel_{name}` | `nanobot_channel_webhook` |
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/you/nanobot-channel-webhook
|
||||||
|
cd nanobot-channel-webhook
|
||||||
|
pip install -e .
|
||||||
|
nanobot plugins list # should show "Webhook" as "plugin"
|
||||||
|
nanobot gateway # test end-to-end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ nanobot plugins list
|
||||||
|
|
||||||
|
Name Source Enabled
|
||||||
|
telegram builtin yes
|
||||||
|
discord builtin no
|
||||||
|
webhook plugin yes
|
||||||
|
```
|
||||||
@@ -128,6 +128,11 @@ class BaseChannel(ABC):
|
|||||||
|
|
||||||
await self.bus.publish_inbound(msg)
|
await self.bus.publish_inbound(msg)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def default_config(cls) -> dict[str, Any]:
|
||||||
|
"""Return default config for onboard. Override in plugins to auto-populate config.json."""
|
||||||
|
return {"enabled": False}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_running(self) -> bool:
|
def is_running(self) -> bool:
|
||||||
"""Check if the channel is running."""
|
"""Check if the channel is running."""
|
||||||
|
|||||||
@@ -11,11 +11,12 @@ from urllib.parse import unquote, urlparse
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
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 Base
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from dingtalk_stream import (
|
from dingtalk_stream import (
|
||||||
@@ -102,6 +103,15 @@ class NanobotDingTalkHandler(CallbackHandler):
|
|||||||
return AckMessage.STATUS_OK, "Error"
|
return AckMessage.STATUS_OK, "Error"
|
||||||
|
|
||||||
|
|
||||||
|
class DingTalkConfig(Base):
|
||||||
|
"""DingTalk channel configuration using Stream mode."""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
client_id: str = ""
|
||||||
|
client_secret: str = ""
|
||||||
|
allow_from: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class DingTalkChannel(BaseChannel):
|
class DingTalkChannel(BaseChannel):
|
||||||
"""
|
"""
|
||||||
DingTalk channel using Stream Mode.
|
DingTalk channel using Stream Mode.
|
||||||
@@ -119,7 +129,13 @@ 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):
|
@classmethod
|
||||||
|
def default_config(cls) -> dict[str, Any]:
|
||||||
|
return DingTalkConfig().model_dump(by_alias=True)
|
||||||
|
|
||||||
|
def __init__(self, config: Any, bus: MessageBus):
|
||||||
|
if isinstance(config, dict):
|
||||||
|
config = DingTalkConfig.model_validate(config)
|
||||||
super().__init__(config, bus)
|
super().__init__(config, bus)
|
||||||
self.config: DingTalkConfig = config
|
self.config: DingTalkConfig = config
|
||||||
self._client: Any = None
|
self._client: Any = None
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, Literal
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from pydantic import Field
|
||||||
import websockets
|
import websockets
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@@ -13,7 +14,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 Base
|
||||||
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"
|
||||||
@@ -21,13 +22,30 @@ MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 # 20MB
|
|||||||
MAX_MESSAGE_LEN = 2000 # Discord message character limit
|
MAX_MESSAGE_LEN = 2000 # Discord message character limit
|
||||||
|
|
||||||
|
|
||||||
|
class DiscordConfig(Base):
|
||||||
|
"""Discord channel configuration."""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
token: str = ""
|
||||||
|
allow_from: list[str] = Field(default_factory=list)
|
||||||
|
gateway_url: str = "wss://gateway.discord.gg/?v=10&encoding=json"
|
||||||
|
intents: int = 37377
|
||||||
|
group_policy: Literal["mention", "open"] = "mention"
|
||||||
|
|
||||||
|
|
||||||
class DiscordChannel(BaseChannel):
|
class DiscordChannel(BaseChannel):
|
||||||
"""Discord channel using Gateway websocket."""
|
"""Discord channel using Gateway websocket."""
|
||||||
|
|
||||||
name = "discord"
|
name = "discord"
|
||||||
display_name = "Discord"
|
display_name = "Discord"
|
||||||
|
|
||||||
def __init__(self, config: DiscordConfig, bus: MessageBus):
|
@classmethod
|
||||||
|
def default_config(cls) -> dict[str, Any]:
|
||||||
|
return DiscordConfig().model_dump(by_alias=True)
|
||||||
|
|
||||||
|
def __init__(self, config: Any, bus: MessageBus):
|
||||||
|
if isinstance(config, dict):
|
||||||
|
config = DiscordConfig.model_validate(config)
|
||||||
super().__init__(config, bus)
|
super().__init__(config, bus)
|
||||||
self.config: DiscordConfig = config
|
self.config: DiscordConfig = config
|
||||||
self._ws: websockets.WebSocketClientProtocol | None = None
|
self._ws: websockets.WebSocketClientProtocol | None = None
|
||||||
|
|||||||
@@ -15,11 +15,41 @@ from email.utils import parseaddr
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
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 Base
|
||||||
|
|
||||||
|
|
||||||
|
class EmailConfig(Base):
|
||||||
|
"""Email channel configuration (IMAP inbound + SMTP outbound)."""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
consent_granted: bool = False
|
||||||
|
|
||||||
|
imap_host: str = ""
|
||||||
|
imap_port: int = 993
|
||||||
|
imap_username: str = ""
|
||||||
|
imap_password: str = ""
|
||||||
|
imap_mailbox: str = "INBOX"
|
||||||
|
imap_use_ssl: bool = True
|
||||||
|
|
||||||
|
smtp_host: str = ""
|
||||||
|
smtp_port: int = 587
|
||||||
|
smtp_username: str = ""
|
||||||
|
smtp_password: str = ""
|
||||||
|
smtp_use_tls: bool = True
|
||||||
|
smtp_use_ssl: bool = False
|
||||||
|
from_address: str = ""
|
||||||
|
|
||||||
|
auto_reply_enabled: bool = True
|
||||||
|
poll_interval_seconds: int = 30
|
||||||
|
mark_seen: bool = True
|
||||||
|
max_body_chars: int = 12000
|
||||||
|
subject_prefix: str = "Re: "
|
||||||
|
allow_from: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class EmailChannel(BaseChannel):
|
class EmailChannel(BaseChannel):
|
||||||
@@ -51,7 +81,13 @@ class EmailChannel(BaseChannel):
|
|||||||
"Dec",
|
"Dec",
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, config: EmailConfig, bus: MessageBus):
|
@classmethod
|
||||||
|
def default_config(cls) -> dict[str, Any]:
|
||||||
|
return EmailConfig().model_dump(by_alias=True)
|
||||||
|
|
||||||
|
def __init__(self, config: Any, bus: MessageBus):
|
||||||
|
if isinstance(config, dict):
|
||||||
|
config = EmailConfig.model_validate(config)
|
||||||
super().__init__(config, bus)
|
super().__init__(config, bus)
|
||||||
self.config: EmailConfig = config
|
self.config: EmailConfig = config
|
||||||
self._last_subject_by_chat: dict[str, str] = {}
|
self._last_subject_by_chat: dict[str, str] = {}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import re
|
|||||||
import threading
|
import threading
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, Literal
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@@ -15,7 +15,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_media_dir
|
from nanobot.config.paths import get_media_dir
|
||||||
from nanobot.config.schema import FeishuConfig
|
from nanobot.config.schema import Base
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
|
||||||
@@ -231,6 +232,19 @@ def _extract_post_text(content_json: dict) -> str:
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
class FeishuConfig(Base):
|
||||||
|
"""Feishu/Lark channel configuration using WebSocket long connection."""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
app_id: str = ""
|
||||||
|
app_secret: str = ""
|
||||||
|
encrypt_key: str = ""
|
||||||
|
verification_token: str = ""
|
||||||
|
allow_from: list[str] = Field(default_factory=list)
|
||||||
|
react_emoji: str = "THUMBSUP"
|
||||||
|
group_policy: Literal["open", "mention"] = "mention"
|
||||||
|
|
||||||
|
|
||||||
class FeishuChannel(BaseChannel):
|
class FeishuChannel(BaseChannel):
|
||||||
"""
|
"""
|
||||||
Feishu/Lark channel using WebSocket long connection.
|
Feishu/Lark channel using WebSocket long connection.
|
||||||
@@ -246,7 +260,13 @@ class FeishuChannel(BaseChannel):
|
|||||||
name = "feishu"
|
name = "feishu"
|
||||||
display_name = "Feishu"
|
display_name = "Feishu"
|
||||||
|
|
||||||
def __init__(self, config: FeishuConfig, bus: MessageBus):
|
@classmethod
|
||||||
|
def default_config(cls) -> dict[str, Any]:
|
||||||
|
return FeishuConfig().model_dump(by_alias=True)
|
||||||
|
|
||||||
|
def __init__(self, config: Any, bus: MessageBus):
|
||||||
|
if isinstance(config, dict):
|
||||||
|
config = FeishuConfig.model_validate(config)
|
||||||
super().__init__(config, bus)
|
super().__init__(config, bus)
|
||||||
self.config: FeishuConfig = config
|
self.config: FeishuConfig = config
|
||||||
self._client: Any = None
|
self._client: Any = None
|
||||||
|
|||||||
@@ -31,23 +31,29 @@ class ChannelManager:
|
|||||||
self._init_channels()
|
self._init_channels()
|
||||||
|
|
||||||
def _init_channels(self) -> None:
|
def _init_channels(self) -> None:
|
||||||
"""Initialize channels discovered via pkgutil scan."""
|
"""Initialize channels discovered via pkgutil scan + entry_points plugins."""
|
||||||
from nanobot.channels.registry import discover_channel_names, load_channel_class
|
from nanobot.channels.registry import discover_all
|
||||||
|
|
||||||
groq_key = self.config.providers.groq.api_key
|
groq_key = self.config.providers.groq.api_key
|
||||||
|
|
||||||
for modname in discover_channel_names():
|
for name, cls in discover_all().items():
|
||||||
section = getattr(self.config.channels, modname, None)
|
section = getattr(self.config.channels, name, None)
|
||||||
if not section or not getattr(section, "enabled", False):
|
if section is None:
|
||||||
|
continue
|
||||||
|
enabled = (
|
||||||
|
section.get("enabled", False)
|
||||||
|
if isinstance(section, dict)
|
||||||
|
else getattr(section, "enabled", False)
|
||||||
|
)
|
||||||
|
if not enabled:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
cls = load_channel_class(modname)
|
|
||||||
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[name] = channel
|
||||||
logger.info("{} channel enabled", cls.display_name)
|
logger.info("{} channel enabled", cls.display_name)
|
||||||
except ImportError as e:
|
except Exception as e:
|
||||||
logger.warning("{} channel not available: {}", modname, e)
|
logger.warning("{} channel not available: {}", name, e)
|
||||||
|
|
||||||
self._validate_allow_from()
|
self._validate_allow_from()
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, TypeAlias
|
from typing import Any, Literal, TypeAlias
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import nh3
|
import nh3
|
||||||
@@ -40,6 +41,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 Base
|
||||||
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
|
||||||
@@ -143,12 +145,33 @@ def _configure_nio_logging_bridge() -> None:
|
|||||||
nio_logger.propagate = False
|
nio_logger.propagate = False
|
||||||
|
|
||||||
|
|
||||||
|
class MatrixConfig(Base):
|
||||||
|
"""Matrix (Element) channel configuration."""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
homeserver: str = "https://matrix.org"
|
||||||
|
access_token: str = ""
|
||||||
|
user_id: str = ""
|
||||||
|
device_id: str = ""
|
||||||
|
e2ee_enabled: bool = True
|
||||||
|
sync_stop_grace_seconds: int = 2
|
||||||
|
max_media_bytes: int = 20 * 1024 * 1024
|
||||||
|
allow_from: list[str] = Field(default_factory=list)
|
||||||
|
group_policy: Literal["open", "mention", "allowlist"] = "open"
|
||||||
|
group_allow_from: list[str] = Field(default_factory=list)
|
||||||
|
allow_room_mentions: bool = False
|
||||||
|
|
||||||
|
|
||||||
class MatrixChannel(BaseChannel):
|
class MatrixChannel(BaseChannel):
|
||||||
"""Matrix (Element) channel using long-polling sync."""
|
"""Matrix (Element) channel using long-polling sync."""
|
||||||
|
|
||||||
name = "matrix"
|
name = "matrix"
|
||||||
display_name = "Matrix"
|
display_name = "Matrix"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def default_config(cls) -> dict[str, Any]:
|
||||||
|
return MatrixConfig().model_dump(by_alias=True)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
config: Any,
|
config: Any,
|
||||||
@@ -157,6 +180,8 @@ class MatrixChannel(BaseChannel):
|
|||||||
restrict_to_workspace: bool = False,
|
restrict_to_workspace: bool = False,
|
||||||
workspace: str | Path | None = None,
|
workspace: str | Path | None = None,
|
||||||
):
|
):
|
||||||
|
if isinstance(config, dict):
|
||||||
|
config = MatrixConfig.model_validate(config)
|
||||||
super().__init__(config, bus)
|
super().__init__(config, bus)
|
||||||
self.client: AsyncClient | None = None
|
self.client: AsyncClient | None = None
|
||||||
self._sync_task: asyncio.Task | None = None
|
self._sync_task: asyncio.Task | None = None
|
||||||
|
|||||||
@@ -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 Base
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import socketio
|
import socketio
|
||||||
@@ -208,6 +209,49 @@ def parse_timestamp(value: Any) -> int | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Config classes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class MochatMentionConfig(Base):
|
||||||
|
"""Mochat mention behavior configuration."""
|
||||||
|
|
||||||
|
require_in_groups: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class MochatGroupRule(Base):
|
||||||
|
"""Mochat per-group mention requirement."""
|
||||||
|
|
||||||
|
require_mention: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class MochatConfig(Base):
|
||||||
|
"""Mochat channel configuration."""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
base_url: str = "https://mochat.io"
|
||||||
|
socket_url: str = ""
|
||||||
|
socket_path: str = "/socket.io"
|
||||||
|
socket_disable_msgpack: bool = False
|
||||||
|
socket_reconnect_delay_ms: int = 1000
|
||||||
|
socket_max_reconnect_delay_ms: int = 10000
|
||||||
|
socket_connect_timeout_ms: int = 10000
|
||||||
|
refresh_interval_ms: int = 30000
|
||||||
|
watch_timeout_ms: int = 25000
|
||||||
|
watch_limit: int = 100
|
||||||
|
retry_delay_ms: int = 500
|
||||||
|
max_retry_attempts: int = 0
|
||||||
|
claw_token: str = ""
|
||||||
|
agent_user_id: str = ""
|
||||||
|
sessions: list[str] = Field(default_factory=list)
|
||||||
|
panels: list[str] = Field(default_factory=list)
|
||||||
|
allow_from: list[str] = Field(default_factory=list)
|
||||||
|
mention: MochatMentionConfig = Field(default_factory=MochatMentionConfig)
|
||||||
|
groups: dict[str, MochatGroupRule] = Field(default_factory=dict)
|
||||||
|
reply_delay_mode: str = "non-mention"
|
||||||
|
reply_delay_ms: int = 120000
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Channel
|
# Channel
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -218,7 +262,13 @@ class MochatChannel(BaseChannel):
|
|||||||
name = "mochat"
|
name = "mochat"
|
||||||
display_name = "Mochat"
|
display_name = "Mochat"
|
||||||
|
|
||||||
def __init__(self, config: MochatConfig, bus: MessageBus):
|
@classmethod
|
||||||
|
def default_config(cls) -> dict[str, Any]:
|
||||||
|
return MochatConfig().model_dump(by_alias=True)
|
||||||
|
|
||||||
|
def __init__(self, config: Any, bus: MessageBus):
|
||||||
|
if isinstance(config, dict):
|
||||||
|
config = MochatConfig.model_validate(config)
|
||||||
super().__init__(config, bus)
|
super().__init__(config, bus)
|
||||||
self.config: MochatConfig = config
|
self.config: MochatConfig = config
|
||||||
self._http: httpx.AsyncClient | None = None
|
self._http: httpx.AsyncClient | None = None
|
||||||
|
|||||||
@@ -2,14 +2,15 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from loguru import logger
|
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 Base
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import botpy
|
import botpy
|
||||||
@@ -50,13 +51,28 @@ def _make_bot_class(channel: "QQChannel") -> "type[botpy.Client]":
|
|||||||
return _Bot
|
return _Bot
|
||||||
|
|
||||||
|
|
||||||
|
class QQConfig(Base):
|
||||||
|
"""QQ channel configuration using botpy SDK."""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
app_id: str = ""
|
||||||
|
secret: str = ""
|
||||||
|
allow_from: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class QQChannel(BaseChannel):
|
class QQChannel(BaseChannel):
|
||||||
"""QQ channel using botpy SDK with WebSocket connection."""
|
"""QQ channel using botpy SDK with WebSocket connection."""
|
||||||
|
|
||||||
name = "qq"
|
name = "qq"
|
||||||
display_name = "QQ"
|
display_name = "QQ"
|
||||||
|
|
||||||
def __init__(self, config: QQConfig, bus: MessageBus):
|
@classmethod
|
||||||
|
def default_config(cls) -> dict[str, Any]:
|
||||||
|
return QQConfig().model_dump(by_alias=True)
|
||||||
|
|
||||||
|
def __init__(self, config: Any, bus: MessageBus):
|
||||||
|
if isinstance(config, dict):
|
||||||
|
config = QQConfig.model_validate(config)
|
||||||
super().__init__(config, bus)
|
super().__init__(config, bus)
|
||||||
self.config: QQConfig = config
|
self.config: QQConfig = config
|
||||||
self._client: "botpy.Client | None" = None
|
self._client: "botpy.Client | None" = None
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Auto-discovery for channel modules — no hardcoded registry."""
|
"""Auto-discovery for built-in channel modules and external plugins."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -6,6 +6,8 @@ import importlib
|
|||||||
import pkgutil
|
import pkgutil
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
|
|
||||||
@@ -13,7 +15,7 @@ _INTERNAL = frozenset({"base", "manager", "registry"})
|
|||||||
|
|
||||||
|
|
||||||
def discover_channel_names() -> list[str]:
|
def discover_channel_names() -> list[str]:
|
||||||
"""Return all channel module names by scanning the package (zero imports)."""
|
"""Return all built-in channel module names by scanning the package (zero imports)."""
|
||||||
import nanobot.channels as pkg
|
import nanobot.channels as pkg
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -33,3 +35,37 @@ def load_channel_class(module_name: str) -> type[BaseChannel]:
|
|||||||
if isinstance(obj, type) and issubclass(obj, _Base) and obj is not _Base:
|
if isinstance(obj, type) and issubclass(obj, _Base) and obj is not _Base:
|
||||||
return obj
|
return obj
|
||||||
raise ImportError(f"No BaseChannel subclass in nanobot.channels.{module_name}")
|
raise ImportError(f"No BaseChannel subclass in nanobot.channels.{module_name}")
|
||||||
|
|
||||||
|
|
||||||
|
def discover_plugins() -> dict[str, type[BaseChannel]]:
|
||||||
|
"""Discover external channel plugins registered via entry_points."""
|
||||||
|
from importlib.metadata import entry_points
|
||||||
|
|
||||||
|
plugins: dict[str, type[BaseChannel]] = {}
|
||||||
|
for ep in entry_points(group="nanobot.channels"):
|
||||||
|
try:
|
||||||
|
cls = ep.load()
|
||||||
|
plugins[ep.name] = cls
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to load channel plugin '{}': {}", ep.name, e)
|
||||||
|
return plugins
|
||||||
|
|
||||||
|
|
||||||
|
def discover_all() -> dict[str, type[BaseChannel]]:
|
||||||
|
"""Return all channels: built-in (pkgutil) merged with external (entry_points).
|
||||||
|
|
||||||
|
Built-in channels take priority — an external plugin cannot shadow a built-in name.
|
||||||
|
"""
|
||||||
|
builtin: dict[str, type[BaseChannel]] = {}
|
||||||
|
for modname in discover_channel_names():
|
||||||
|
try:
|
||||||
|
builtin[modname] = load_channel_class(modname)
|
||||||
|
except ImportError as e:
|
||||||
|
logger.debug("Skipping built-in channel '{}': {}", modname, e)
|
||||||
|
|
||||||
|
external = discover_plugins()
|
||||||
|
shadowed = set(external) & set(builtin)
|
||||||
|
if shadowed:
|
||||||
|
logger.warning("Plugin(s) shadowed by built-in channels (ignored): {}", shadowed)
|
||||||
|
|
||||||
|
return {**external, **builtin}
|
||||||
|
|||||||
@@ -13,8 +13,35 @@ 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 pydantic import Field
|
||||||
|
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
from nanobot.config.schema import SlackConfig
|
from nanobot.config.schema import Base
|
||||||
|
|
||||||
|
|
||||||
|
class SlackDMConfig(Base):
|
||||||
|
"""Slack DM policy configuration."""
|
||||||
|
|
||||||
|
enabled: bool = True
|
||||||
|
policy: str = "open"
|
||||||
|
allow_from: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SlackConfig(Base):
|
||||||
|
"""Slack channel configuration."""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
mode: str = "socket"
|
||||||
|
webhook_path: str = "/slack/events"
|
||||||
|
bot_token: str = ""
|
||||||
|
app_token: str = ""
|
||||||
|
user_token_read_only: bool = True
|
||||||
|
reply_in_thread: bool = True
|
||||||
|
react_emoji: str = "eyes"
|
||||||
|
allow_from: list[str] = Field(default_factory=list)
|
||||||
|
group_policy: str = "mention"
|
||||||
|
group_allow_from: list[str] = Field(default_factory=list)
|
||||||
|
dm: SlackDMConfig = Field(default_factory=SlackDMConfig)
|
||||||
|
|
||||||
|
|
||||||
class SlackChannel(BaseChannel):
|
class SlackChannel(BaseChannel):
|
||||||
@@ -23,7 +50,13 @@ class SlackChannel(BaseChannel):
|
|||||||
name = "slack"
|
name = "slack"
|
||||||
display_name = "Slack"
|
display_name = "Slack"
|
||||||
|
|
||||||
def __init__(self, config: SlackConfig, bus: MessageBus):
|
@classmethod
|
||||||
|
def default_config(cls) -> dict[str, Any]:
|
||||||
|
return SlackConfig().model_dump(by_alias=True)
|
||||||
|
|
||||||
|
def __init__(self, config: Any, bus: MessageBus):
|
||||||
|
if isinstance(config, dict):
|
||||||
|
config = SlackConfig.model_validate(config)
|
||||||
super().__init__(config, bus)
|
super().__init__(config, bus)
|
||||||
self.config: SlackConfig = config
|
self.config: SlackConfig = config
|
||||||
self._web_client: AsyncWebClient | None = None
|
self._web_client: AsyncWebClient | None = None
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import asyncio
|
|||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import unicodedata
|
import unicodedata
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
from pydantic import Field
|
||||||
from telegram import BotCommand, ReplyParameters, Update
|
from telegram import BotCommand, ReplyParameters, Update
|
||||||
from telegram.ext import Application, CommandHandler, ContextTypes, MessageHandler, filters
|
from telegram.ext import Application, CommandHandler, ContextTypes, MessageHandler, filters
|
||||||
from telegram.request import HTTPXRequest
|
from telegram.request import HTTPXRequest
|
||||||
@@ -16,7 +18,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 TelegramConfig
|
from nanobot.config.schema import Base
|
||||||
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
|
||||||
@@ -148,6 +150,17 @@ def _markdown_to_telegram_html(text: str) -> str:
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramConfig(Base):
|
||||||
|
"""Telegram channel configuration."""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
token: str = ""
|
||||||
|
allow_from: list[str] = Field(default_factory=list)
|
||||||
|
proxy: str | None = None
|
||||||
|
reply_to_message: bool = False
|
||||||
|
group_policy: Literal["open", "mention"] = "mention"
|
||||||
|
|
||||||
|
|
||||||
class TelegramChannel(BaseChannel):
|
class TelegramChannel(BaseChannel):
|
||||||
"""
|
"""
|
||||||
Telegram channel using long polling.
|
Telegram channel using long polling.
|
||||||
@@ -167,7 +180,13 @@ class TelegramChannel(BaseChannel):
|
|||||||
BotCommand("restart", "Restart the bot"),
|
BotCommand("restart", "Restart the bot"),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, config: TelegramConfig, bus: MessageBus):
|
@classmethod
|
||||||
|
def default_config(cls) -> dict[str, Any]:
|
||||||
|
return TelegramConfig().model_dump(by_alias=True)
|
||||||
|
|
||||||
|
def __init__(self, config: Any, bus: MessageBus):
|
||||||
|
if isinstance(config, dict):
|
||||||
|
config = TelegramConfig.model_validate(config)
|
||||||
super().__init__(config, bus)
|
super().__init__(config, bus)
|
||||||
self.config: TelegramConfig = config
|
self.config: TelegramConfig = config
|
||||||
self._app: Application | None = None
|
self._app: Application | None = None
|
||||||
|
|||||||
@@ -12,10 +12,21 @@ 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 Base
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
WECOM_AVAILABLE = importlib.util.find_spec("wecom_aibot_sdk") is not None
|
WECOM_AVAILABLE = importlib.util.find_spec("wecom_aibot_sdk") is not None
|
||||||
|
|
||||||
|
class WecomConfig(Base):
|
||||||
|
"""WeCom (Enterprise WeChat) AI Bot channel configuration."""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
bot_id: str = ""
|
||||||
|
secret: str = ""
|
||||||
|
allow_from: list[str] = Field(default_factory=list)
|
||||||
|
welcome_message: str = ""
|
||||||
|
|
||||||
|
|
||||||
# Message type display mapping
|
# Message type display mapping
|
||||||
MSG_TYPE_MAP = {
|
MSG_TYPE_MAP = {
|
||||||
"image": "[image]",
|
"image": "[image]",
|
||||||
@@ -38,7 +49,13 @@ class WecomChannel(BaseChannel):
|
|||||||
name = "wecom"
|
name = "wecom"
|
||||||
display_name = "WeCom"
|
display_name = "WeCom"
|
||||||
|
|
||||||
def __init__(self, config: WecomConfig, bus: MessageBus):
|
@classmethod
|
||||||
|
def default_config(cls) -> dict[str, Any]:
|
||||||
|
return WecomConfig().model_dump(by_alias=True)
|
||||||
|
|
||||||
|
def __init__(self, config: Any, bus: MessageBus):
|
||||||
|
if isinstance(config, dict):
|
||||||
|
config = WecomConfig.model_validate(config)
|
||||||
super().__init__(config, bus)
|
super().__init__(config, bus)
|
||||||
self.config: WecomConfig = config
|
self.config: WecomConfig = config
|
||||||
self._client: Any = None
|
self._client: Any = None
|
||||||
|
|||||||
@@ -4,13 +4,25 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import mimetypes
|
import mimetypes
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
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 Base
|
||||||
|
|
||||||
|
|
||||||
|
class WhatsAppConfig(Base):
|
||||||
|
"""WhatsApp channel configuration."""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
bridge_url: str = "ws://localhost:3001"
|
||||||
|
bridge_token: str = ""
|
||||||
|
allow_from: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class WhatsAppChannel(BaseChannel):
|
class WhatsAppChannel(BaseChannel):
|
||||||
@@ -24,9 +36,14 @@ class WhatsAppChannel(BaseChannel):
|
|||||||
name = "whatsapp"
|
name = "whatsapp"
|
||||||
display_name = "WhatsApp"
|
display_name = "WhatsApp"
|
||||||
|
|
||||||
def __init__(self, config: WhatsAppConfig, bus: MessageBus):
|
@classmethod
|
||||||
|
def default_config(cls) -> dict[str, Any]:
|
||||||
|
return WhatsAppConfig().model_dump(by_alias=True)
|
||||||
|
|
||||||
|
def __init__(self, config: Any, bus: MessageBus):
|
||||||
|
if isinstance(config, dict):
|
||||||
|
config = WhatsAppConfig.model_validate(config)
|
||||||
super().__init__(config, bus)
|
super().__init__(config, bus)
|
||||||
self.config: WhatsAppConfig = 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()
|
||||||
|
|||||||
@@ -240,6 +240,8 @@ def onboard():
|
|||||||
|
|
||||||
console.print("[dim]Config template now uses `maxTokens` + `contextWindowTokens`; `memoryWindow` is no longer a runtime setting.[/dim]")
|
console.print("[dim]Config template now uses `maxTokens` + `contextWindowTokens`; `memoryWindow` is no longer a runtime setting.[/dim]")
|
||||||
|
|
||||||
|
_onboard_plugins(config_path)
|
||||||
|
|
||||||
# Create workspace
|
# Create workspace
|
||||||
workspace = get_workspace_path()
|
workspace = get_workspace_path()
|
||||||
|
|
||||||
@@ -257,7 +259,26 @@ def onboard():
|
|||||||
console.print("\n[dim]Want Telegram/WhatsApp? See: https://github.com/HKUDS/nanobot#-chat-apps[/dim]")
|
console.print("\n[dim]Want Telegram/WhatsApp? See: https://github.com/HKUDS/nanobot#-chat-apps[/dim]")
|
||||||
|
|
||||||
|
|
||||||
|
def _onboard_plugins(config_path: Path) -> None:
|
||||||
|
"""Inject default config for all discovered channels (built-in + plugins)."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from nanobot.channels.registry import discover_all
|
||||||
|
|
||||||
|
all_channels = discover_all()
|
||||||
|
if not all_channels:
|
||||||
|
return
|
||||||
|
|
||||||
|
with open(config_path, encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
channels = data.setdefault("channels", {})
|
||||||
|
for name, cls in all_channels.items():
|
||||||
|
if name not in channels:
|
||||||
|
channels[name] = cls.default_config()
|
||||||
|
|
||||||
|
with open(config_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
def _make_provider(config: Config):
|
def _make_provider(config: Config):
|
||||||
@@ -731,7 +752,7 @@ app.add_typer(channels_app, name="channels")
|
|||||||
@channels_app.command("status")
|
@channels_app.command("status")
|
||||||
def channels_status():
|
def channels_status():
|
||||||
"""Show channel status."""
|
"""Show channel status."""
|
||||||
from nanobot.channels.registry import discover_channel_names, load_channel_class
|
from nanobot.channels.registry import discover_all
|
||||||
from nanobot.config.loader import load_config
|
from nanobot.config.loader import load_config
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
@@ -740,16 +761,16 @@ def channels_status():
|
|||||||
table.add_column("Channel", style="cyan")
|
table.add_column("Channel", style="cyan")
|
||||||
table.add_column("Enabled", style="green")
|
table.add_column("Enabled", style="green")
|
||||||
|
|
||||||
for modname in sorted(discover_channel_names()):
|
for name, cls in sorted(discover_all().items()):
|
||||||
section = getattr(config.channels, modname, None)
|
section = getattr(config.channels, name, None)
|
||||||
enabled = section and getattr(section, "enabled", False)
|
if section is None:
|
||||||
try:
|
enabled = False
|
||||||
cls = load_channel_class(modname)
|
elif isinstance(section, dict):
|
||||||
display = cls.display_name
|
enabled = section.get("enabled", False)
|
||||||
except ImportError:
|
else:
|
||||||
display = modname.title()
|
enabled = getattr(section, "enabled", False)
|
||||||
table.add_row(
|
table.add_row(
|
||||||
display,
|
cls.display_name,
|
||||||
"[green]\u2713[/green]" if enabled else "[dim]\u2717[/dim]",
|
"[green]\u2713[/green]" if enabled else "[dim]\u2717[/dim]",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -831,8 +852,10 @@ def channels_login():
|
|||||||
console.print("Scan the QR code to connect.\n")
|
console.print("Scan the QR code to connect.\n")
|
||||||
|
|
||||||
env = {**os.environ}
|
env = {**os.environ}
|
||||||
if config.channels.whatsapp.bridge_token:
|
wa_cfg = getattr(config.channels, "whatsapp", None) or {}
|
||||||
env["BRIDGE_TOKEN"] = config.channels.whatsapp.bridge_token
|
bridge_token = wa_cfg.get("bridgeToken", "") if isinstance(wa_cfg, dict) else getattr(wa_cfg, "bridge_token", "")
|
||||||
|
if bridge_token:
|
||||||
|
env["BRIDGE_TOKEN"] = bridge_token
|
||||||
env["AUTH_DIR"] = str(get_runtime_subdir("whatsapp-auth"))
|
env["AUTH_DIR"] = str(get_runtime_subdir("whatsapp-auth"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -843,6 +866,48 @@ def channels_login():
|
|||||||
console.print("[red]npm not found. Please install Node.js.[/red]")
|
console.print("[red]npm not found. Please install Node.js.[/red]")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Plugin Commands
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
plugins_app = typer.Typer(help="Manage channel plugins")
|
||||||
|
app.add_typer(plugins_app, name="plugins")
|
||||||
|
|
||||||
|
|
||||||
|
@plugins_app.command("list")
|
||||||
|
def plugins_list():
|
||||||
|
"""List all discovered channels (built-in and plugins)."""
|
||||||
|
from nanobot.channels.registry import discover_all, discover_channel_names
|
||||||
|
from nanobot.config.loader import load_config
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
builtin_names = set(discover_channel_names())
|
||||||
|
all_channels = discover_all()
|
||||||
|
|
||||||
|
table = Table(title="Channel Plugins")
|
||||||
|
table.add_column("Name", style="cyan")
|
||||||
|
table.add_column("Source", style="magenta")
|
||||||
|
table.add_column("Enabled", style="green")
|
||||||
|
|
||||||
|
for name in sorted(all_channels):
|
||||||
|
cls = all_channels[name]
|
||||||
|
source = "builtin" if name in builtin_names else "plugin"
|
||||||
|
section = getattr(config.channels, name, None)
|
||||||
|
if section is None:
|
||||||
|
enabled = False
|
||||||
|
elif isinstance(section, dict):
|
||||||
|
enabled = section.get("enabled", False)
|
||||||
|
else:
|
||||||
|
enabled = getattr(section, "enabled", False)
|
||||||
|
table.add_row(
|
||||||
|
cls.display_name,
|
||||||
|
source,
|
||||||
|
"[green]yes[/green]" if enabled else "[dim]no[/dim]",
|
||||||
|
)
|
||||||
|
|
||||||
|
console.print(table)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Status Commands
|
# Status Commands
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -14,219 +14,17 @@ class Base(BaseModel):
|
|||||||
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
|
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
|
||||||
|
|
||||||
|
|
||||||
class WhatsAppConfig(Base):
|
|
||||||
"""WhatsApp channel configuration."""
|
|
||||||
|
|
||||||
enabled: bool = False
|
|
||||||
bridge_url: str = "ws://localhost:3001"
|
|
||||||
bridge_token: str = "" # Shared token for bridge auth (optional, recommended)
|
|
||||||
allow_from: list[str] = Field(default_factory=list) # Allowed phone numbers
|
|
||||||
|
|
||||||
|
|
||||||
class TelegramConfig(Base):
|
|
||||||
"""Telegram channel configuration."""
|
|
||||||
|
|
||||||
enabled: bool = False
|
|
||||||
token: str = "" # Bot token from @BotFather
|
|
||||||
allow_from: list[str] = Field(default_factory=list) # Allowed user IDs or usernames
|
|
||||||
proxy: str | None = (
|
|
||||||
None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080"
|
|
||||||
)
|
|
||||||
reply_to_message: bool = False # If true, bot replies quote the original message
|
|
||||||
group_policy: Literal["open", "mention"] = "mention" # "mention" responds when @mentioned or replied to, "open" responds to all
|
|
||||||
|
|
||||||
|
|
||||||
class FeishuConfig(Base):
|
|
||||||
"""Feishu/Lark channel configuration using WebSocket long connection."""
|
|
||||||
|
|
||||||
enabled: bool = False
|
|
||||||
app_id: str = "" # App ID from Feishu Open Platform
|
|
||||||
app_secret: str = "" # App Secret from Feishu Open Platform
|
|
||||||
encrypt_key: str = "" # Encrypt Key for event subscription (optional)
|
|
||||||
verification_token: str = "" # Verification Token for event subscription (optional)
|
|
||||||
allow_from: list[str] = Field(default_factory=list) # Allowed user open_ids
|
|
||||||
react_emoji: str = (
|
|
||||||
"THUMBSUP" # Emoji type for message reactions (e.g. THUMBSUP, OK, DONE, SMILE)
|
|
||||||
)
|
|
||||||
group_policy: Literal["open", "mention"] = "mention" # "mention" responds when @mentioned, "open" responds to all
|
|
||||||
|
|
||||||
|
|
||||||
class DingTalkConfig(Base):
|
|
||||||
"""DingTalk channel configuration using Stream mode."""
|
|
||||||
|
|
||||||
enabled: bool = False
|
|
||||||
client_id: str = "" # AppKey
|
|
||||||
client_secret: str = "" # AppSecret
|
|
||||||
allow_from: list[str] = Field(default_factory=list) # Allowed staff_ids
|
|
||||||
|
|
||||||
|
|
||||||
class DiscordConfig(Base):
|
|
||||||
"""Discord channel configuration."""
|
|
||||||
|
|
||||||
enabled: bool = False
|
|
||||||
token: str = "" # Bot token from Discord Developer Portal
|
|
||||||
allow_from: list[str] = Field(default_factory=list) # Allowed user IDs
|
|
||||||
gateway_url: str = "wss://gateway.discord.gg/?v=10&encoding=json"
|
|
||||||
intents: int = 37377 # GUILDS + GUILD_MESSAGES + DIRECT_MESSAGES + MESSAGE_CONTENT
|
|
||||||
group_policy: Literal["mention", "open"] = "mention"
|
|
||||||
|
|
||||||
|
|
||||||
class MatrixConfig(Base):
|
|
||||||
"""Matrix (Element) channel configuration."""
|
|
||||||
|
|
||||||
enabled: bool = False
|
|
||||||
homeserver: str = "https://matrix.org"
|
|
||||||
access_token: str = ""
|
|
||||||
user_id: str = "" # @bot:matrix.org
|
|
||||||
device_id: str = ""
|
|
||||||
e2ee_enabled: bool = True # Enable Matrix E2EE support (encryption + encrypted room handling).
|
|
||||||
sync_stop_grace_seconds: int = (
|
|
||||||
2 # Max seconds to wait for sync_forever to stop gracefully before cancellation fallback.
|
|
||||||
)
|
|
||||||
max_media_bytes: int = (
|
|
||||||
20 * 1024 * 1024
|
|
||||||
) # Max attachment size accepted for Matrix media handling (inbound + outbound).
|
|
||||||
allow_from: list[str] = Field(default_factory=list)
|
|
||||||
group_policy: Literal["open", "mention", "allowlist"] = "open"
|
|
||||||
group_allow_from: list[str] = Field(default_factory=list)
|
|
||||||
allow_room_mentions: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class EmailConfig(Base):
|
|
||||||
"""Email channel configuration (IMAP inbound + SMTP outbound)."""
|
|
||||||
|
|
||||||
enabled: bool = False
|
|
||||||
consent_granted: bool = False # Explicit owner permission to access mailbox data
|
|
||||||
|
|
||||||
# IMAP (receive)
|
|
||||||
imap_host: str = ""
|
|
||||||
imap_port: int = 993
|
|
||||||
imap_username: str = ""
|
|
||||||
imap_password: str = ""
|
|
||||||
imap_mailbox: str = "INBOX"
|
|
||||||
imap_use_ssl: bool = True
|
|
||||||
|
|
||||||
# SMTP (send)
|
|
||||||
smtp_host: str = ""
|
|
||||||
smtp_port: int = 587
|
|
||||||
smtp_username: str = ""
|
|
||||||
smtp_password: str = ""
|
|
||||||
smtp_use_tls: bool = True
|
|
||||||
smtp_use_ssl: bool = False
|
|
||||||
from_address: str = ""
|
|
||||||
|
|
||||||
# Behavior
|
|
||||||
auto_reply_enabled: bool = (
|
|
||||||
True # If false, inbound email is read but no automatic reply is sent
|
|
||||||
)
|
|
||||||
poll_interval_seconds: int = 30
|
|
||||||
mark_seen: bool = True
|
|
||||||
max_body_chars: int = 12000
|
|
||||||
subject_prefix: str = "Re: "
|
|
||||||
allow_from: list[str] = Field(default_factory=list) # Allowed sender email addresses
|
|
||||||
|
|
||||||
|
|
||||||
class MochatMentionConfig(Base):
|
|
||||||
"""Mochat mention behavior configuration."""
|
|
||||||
|
|
||||||
require_in_groups: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class MochatGroupRule(Base):
|
|
||||||
"""Mochat per-group mention requirement."""
|
|
||||||
|
|
||||||
require_mention: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class MochatConfig(Base):
|
|
||||||
"""Mochat channel configuration."""
|
|
||||||
|
|
||||||
enabled: bool = False
|
|
||||||
base_url: str = "https://mochat.io"
|
|
||||||
socket_url: str = ""
|
|
||||||
socket_path: str = "/socket.io"
|
|
||||||
socket_disable_msgpack: bool = False
|
|
||||||
socket_reconnect_delay_ms: int = 1000
|
|
||||||
socket_max_reconnect_delay_ms: int = 10000
|
|
||||||
socket_connect_timeout_ms: int = 10000
|
|
||||||
refresh_interval_ms: int = 30000
|
|
||||||
watch_timeout_ms: int = 25000
|
|
||||||
watch_limit: int = 100
|
|
||||||
retry_delay_ms: int = 500
|
|
||||||
max_retry_attempts: int = 0 # 0 means unlimited retries
|
|
||||||
claw_token: str = ""
|
|
||||||
agent_user_id: str = ""
|
|
||||||
sessions: list[str] = Field(default_factory=list)
|
|
||||||
panels: list[str] = Field(default_factory=list)
|
|
||||||
allow_from: list[str] = Field(default_factory=list)
|
|
||||||
mention: MochatMentionConfig = Field(default_factory=MochatMentionConfig)
|
|
||||||
groups: dict[str, MochatGroupRule] = Field(default_factory=dict)
|
|
||||||
reply_delay_mode: str = "non-mention" # off | non-mention
|
|
||||||
reply_delay_ms: int = 120000
|
|
||||||
|
|
||||||
|
|
||||||
class SlackDMConfig(Base):
|
|
||||||
"""Slack DM policy configuration."""
|
|
||||||
|
|
||||||
enabled: bool = True
|
|
||||||
policy: str = "open" # "open" or "allowlist"
|
|
||||||
allow_from: list[str] = Field(default_factory=list) # Allowed Slack user IDs
|
|
||||||
|
|
||||||
|
|
||||||
class SlackConfig(Base):
|
|
||||||
"""Slack channel configuration."""
|
|
||||||
|
|
||||||
enabled: bool = False
|
|
||||||
mode: str = "socket" # "socket" supported
|
|
||||||
webhook_path: str = "/slack/events"
|
|
||||||
bot_token: str = "" # xoxb-...
|
|
||||||
app_token: str = "" # xapp-...
|
|
||||||
user_token_read_only: bool = True
|
|
||||||
reply_in_thread: bool = True
|
|
||||||
react_emoji: str = "eyes"
|
|
||||||
allow_from: list[str] = Field(default_factory=list) # Allowed Slack user IDs (sender-level)
|
|
||||||
group_policy: str = "mention" # "mention", "open", "allowlist"
|
|
||||||
group_allow_from: list[str] = Field(default_factory=list) # Allowed channel IDs if allowlist
|
|
||||||
dm: SlackDMConfig = Field(default_factory=SlackDMConfig)
|
|
||||||
|
|
||||||
|
|
||||||
class QQConfig(Base):
|
|
||||||
"""QQ channel configuration using botpy SDK."""
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
class WecomConfig(Base):
|
|
||||||
"""WeCom (Enterprise WeChat) AI Bot channel configuration."""
|
|
||||||
|
|
||||||
enabled: bool = False
|
|
||||||
bot_id: str = "" # Bot ID from WeCom AI Bot platform
|
|
||||||
secret: str = "" # Bot Secret from WeCom AI Bot platform
|
|
||||||
allow_from: list[str] = Field(default_factory=list) # Allowed user IDs
|
|
||||||
welcome_message: str = "" # Welcome message for enter_chat event
|
|
||||||
|
|
||||||
|
|
||||||
class ChannelsConfig(Base):
|
class ChannelsConfig(Base):
|
||||||
"""Configuration for chat channels."""
|
"""Configuration for chat channels.
|
||||||
|
|
||||||
|
Built-in and plugin channel configs are stored as extra fields (dicts).
|
||||||
|
Each channel parses its own config in __init__.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
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)
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
class AgentDefaults(Base):
|
class AgentDefaults(Base):
|
||||||
|
|||||||
225
tests/test_channel_plugins.py
Normal file
225
tests/test_channel_plugins.py
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
"""Tests for channel plugin discovery, merging, and config compatibility."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
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 ChannelsConfig
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class _FakePlugin(BaseChannel):
|
||||||
|
name = "fakeplugin"
|
||||||
|
display_name = "Fake Plugin"
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def send(self, msg: OutboundMessage) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeTelegram(BaseChannel):
|
||||||
|
"""Plugin that tries to shadow built-in telegram."""
|
||||||
|
name = "telegram"
|
||||||
|
display_name = "Fake Telegram"
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def send(self, msg: OutboundMessage) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _make_entry_point(name: str, cls: type):
|
||||||
|
"""Create a mock entry point that returns *cls* on load()."""
|
||||||
|
ep = SimpleNamespace(name=name, load=lambda _cls=cls: _cls)
|
||||||
|
return ep
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ChannelsConfig extra="allow"
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_channels_config_accepts_unknown_keys():
|
||||||
|
cfg = ChannelsConfig.model_validate({
|
||||||
|
"myplugin": {"enabled": True, "token": "abc"},
|
||||||
|
})
|
||||||
|
extra = cfg.model_extra
|
||||||
|
assert extra is not None
|
||||||
|
assert extra["myplugin"]["enabled"] is True
|
||||||
|
assert extra["myplugin"]["token"] == "abc"
|
||||||
|
|
||||||
|
|
||||||
|
def test_channels_config_getattr_returns_extra():
|
||||||
|
cfg = ChannelsConfig.model_validate({"myplugin": {"enabled": True}})
|
||||||
|
section = getattr(cfg, "myplugin", None)
|
||||||
|
assert isinstance(section, dict)
|
||||||
|
assert section["enabled"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_channels_config_builtin_fields_removed():
|
||||||
|
"""After decoupling, ChannelsConfig has no explicit channel fields."""
|
||||||
|
cfg = ChannelsConfig()
|
||||||
|
assert not hasattr(cfg, "telegram")
|
||||||
|
assert cfg.send_progress is True
|
||||||
|
assert cfg.send_tool_hints is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# discover_plugins
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_EP_TARGET = "importlib.metadata.entry_points"
|
||||||
|
|
||||||
|
|
||||||
|
def test_discover_plugins_loads_entry_points():
|
||||||
|
from nanobot.channels.registry import discover_plugins
|
||||||
|
|
||||||
|
ep = _make_entry_point("line", _FakePlugin)
|
||||||
|
with patch(_EP_TARGET, return_value=[ep]):
|
||||||
|
result = discover_plugins()
|
||||||
|
|
||||||
|
assert "line" in result
|
||||||
|
assert result["line"] is _FakePlugin
|
||||||
|
|
||||||
|
|
||||||
|
def test_discover_plugins_handles_load_error():
|
||||||
|
from nanobot.channels.registry import discover_plugins
|
||||||
|
|
||||||
|
def _boom():
|
||||||
|
raise RuntimeError("broken")
|
||||||
|
|
||||||
|
ep = SimpleNamespace(name="broken", load=_boom)
|
||||||
|
with patch(_EP_TARGET, return_value=[ep]):
|
||||||
|
result = discover_plugins()
|
||||||
|
|
||||||
|
assert "broken" not in result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# discover_all — merge & priority
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_discover_all_includes_builtins():
|
||||||
|
from nanobot.channels.registry import discover_all, discover_channel_names
|
||||||
|
|
||||||
|
with patch(_EP_TARGET, return_value=[]):
|
||||||
|
result = discover_all()
|
||||||
|
|
||||||
|
for name in discover_channel_names():
|
||||||
|
assert name in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_discover_all_includes_external_plugin():
|
||||||
|
from nanobot.channels.registry import discover_all
|
||||||
|
|
||||||
|
ep = _make_entry_point("line", _FakePlugin)
|
||||||
|
with patch(_EP_TARGET, return_value=[ep]):
|
||||||
|
result = discover_all()
|
||||||
|
|
||||||
|
assert "line" in result
|
||||||
|
assert result["line"] is _FakePlugin
|
||||||
|
|
||||||
|
|
||||||
|
def test_discover_all_builtin_shadows_plugin():
|
||||||
|
from nanobot.channels.registry import discover_all
|
||||||
|
|
||||||
|
ep = _make_entry_point("telegram", _FakeTelegram)
|
||||||
|
with patch(_EP_TARGET, return_value=[ep]):
|
||||||
|
result = discover_all()
|
||||||
|
|
||||||
|
assert "telegram" in result
|
||||||
|
assert result["telegram"] is not _FakeTelegram
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Manager _init_channels with dict config (plugin scenario)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_manager_loads_plugin_from_dict_config():
|
||||||
|
"""ChannelManager should instantiate a plugin channel from a raw dict config."""
|
||||||
|
from nanobot.channels.manager import ChannelManager
|
||||||
|
|
||||||
|
fake_config = SimpleNamespace(
|
||||||
|
channels=ChannelsConfig.model_validate({
|
||||||
|
"fakeplugin": {"enabled": True, "allowFrom": ["*"]},
|
||||||
|
}),
|
||||||
|
providers=SimpleNamespace(groq=SimpleNamespace(api_key="")),
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"nanobot.channels.registry.discover_all",
|
||||||
|
return_value={"fakeplugin": _FakePlugin},
|
||||||
|
):
|
||||||
|
mgr = ChannelManager.__new__(ChannelManager)
|
||||||
|
mgr.config = fake_config
|
||||||
|
mgr.bus = MessageBus()
|
||||||
|
mgr.channels = {}
|
||||||
|
mgr._dispatch_task = None
|
||||||
|
mgr._init_channels()
|
||||||
|
|
||||||
|
assert "fakeplugin" in mgr.channels
|
||||||
|
assert isinstance(mgr.channels["fakeplugin"], _FakePlugin)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_manager_skips_disabled_plugin():
|
||||||
|
fake_config = SimpleNamespace(
|
||||||
|
channels=ChannelsConfig.model_validate({
|
||||||
|
"fakeplugin": {"enabled": False},
|
||||||
|
}),
|
||||||
|
providers=SimpleNamespace(groq=SimpleNamespace(api_key="")),
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"nanobot.channels.registry.discover_all",
|
||||||
|
return_value={"fakeplugin": _FakePlugin},
|
||||||
|
):
|
||||||
|
mgr = ChannelManager.__new__(ChannelManager)
|
||||||
|
mgr.config = fake_config
|
||||||
|
mgr.bus = MessageBus()
|
||||||
|
mgr.channels = {}
|
||||||
|
mgr._dispatch_task = None
|
||||||
|
mgr._init_channels()
|
||||||
|
|
||||||
|
assert "fakeplugin" not in mgr.channels
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Built-in channel default_config() and dict->Pydantic conversion
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_builtin_channel_default_config():
|
||||||
|
"""Built-in channels expose default_config() returning a dict with 'enabled': False."""
|
||||||
|
from nanobot.channels.telegram import TelegramChannel
|
||||||
|
cfg = TelegramChannel.default_config()
|
||||||
|
assert isinstance(cfg, dict)
|
||||||
|
assert cfg["enabled"] is False
|
||||||
|
assert "token" in cfg
|
||||||
|
|
||||||
|
|
||||||
|
def test_builtin_channel_init_from_dict():
|
||||||
|
"""Built-in channels accept a raw dict and convert to Pydantic internally."""
|
||||||
|
from nanobot.channels.telegram import TelegramChannel
|
||||||
|
bus = MessageBus()
|
||||||
|
ch = TelegramChannel({"enabled": False, "token": "test-tok", "allowFrom": ["*"]}, bus)
|
||||||
|
assert ch.config.token == "test-tok"
|
||||||
|
assert ch.config.allow_from == ["*"]
|
||||||
@@ -6,7 +6,7 @@ import pytest
|
|||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
import nanobot.channels.dingtalk as dingtalk_module
|
import nanobot.channels.dingtalk as dingtalk_module
|
||||||
from nanobot.channels.dingtalk import DingTalkChannel, NanobotDingTalkHandler
|
from nanobot.channels.dingtalk import DingTalkChannel, NanobotDingTalkHandler
|
||||||
from nanobot.config.schema import DingTalkConfig
|
from nanobot.channels.dingtalk import DingTalkConfig
|
||||||
|
|
||||||
|
|
||||||
class _FakeResponse:
|
class _FakeResponse:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import pytest
|
|||||||
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.email import EmailChannel
|
from nanobot.channels.email import EmailChannel
|
||||||
from nanobot.config.schema import EmailConfig
|
from nanobot.channels.email import EmailConfig
|
||||||
|
|
||||||
|
|
||||||
def _make_config() -> EmailConfig:
|
def _make_config() -> EmailConfig:
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from nanobot.channels.matrix import (
|
|||||||
TYPING_NOTICE_TIMEOUT_MS,
|
TYPING_NOTICE_TIMEOUT_MS,
|
||||||
MatrixChannel,
|
MatrixChannel,
|
||||||
)
|
)
|
||||||
from nanobot.config.schema import MatrixConfig
|
from nanobot.channels.matrix import MatrixConfig
|
||||||
|
|
||||||
_ROOM_SEND_UNSET = object()
|
_ROOM_SEND_UNSET = object()
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import pytest
|
|||||||
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.qq import QQChannel
|
from nanobot.channels.qq import QQChannel
|
||||||
from nanobot.config.schema import QQConfig
|
from nanobot.channels.qq import QQConfig
|
||||||
|
|
||||||
|
|
||||||
class _FakeApi:
|
class _FakeApi:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import pytest
|
|||||||
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.slack import SlackChannel
|
from nanobot.channels.slack import SlackChannel
|
||||||
from nanobot.config.schema import SlackConfig
|
from nanobot.channels.slack import SlackConfig
|
||||||
|
|
||||||
|
|
||||||
class _FakeAsyncWebClient:
|
class _FakeAsyncWebClient:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import pytest
|
|||||||
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.telegram import TELEGRAM_REPLY_CONTEXT_MAX_LEN, TelegramChannel
|
from nanobot.channels.telegram import TELEGRAM_REPLY_CONTEXT_MAX_LEN, TelegramChannel
|
||||||
from nanobot.config.schema import TelegramConfig
|
from nanobot.channels.telegram import TelegramConfig
|
||||||
|
|
||||||
|
|
||||||
class _FakeHTTPXRequest:
|
class _FakeHTTPXRequest:
|
||||||
|
|||||||
Reference in New Issue
Block a user