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:
Xubin Ren
2026-03-13 15:26:55 +00:00
committed by Xubin Ren
parent 58389766a7
commit dbdb43faff
26 changed files with 923 additions and 266 deletions

View File

@@ -15,11 +15,41 @@ from email.utils import parseaddr
from typing import Any
from loguru import logger
from pydantic import Field
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.schema import EmailConfig
from nanobot.config.schema import 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):
@@ -51,7 +81,13 @@ class EmailChannel(BaseChannel):
"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)
self.config: EmailConfig = config
self._last_subject_by_chat: dict[str, str] = {}