diff --git a/docs/CHANNEL_PLUGIN_GUIDE.md b/docs/CHANNEL_PLUGIN_GUIDE.md index 2c52b20c5..f3f472d3f 100644 --- a/docs/CHANNEL_PLUGIN_GUIDE.md +++ b/docs/CHANNEL_PLUGIN_GUIDE.md @@ -43,15 +43,30 @@ from typing import Any from aiohttp import web from loguru import logger +from pydantic import Field from nanobot.channels.base import BaseChannel from nanobot.bus.events import OutboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.config.schema import Base + + +class WebhookConfig(Base): + """Webhook channel configuration.""" + enabled: bool = False + port: int = 9000 + allow_from: list[str] = Field(default_factory=list) class WebhookChannel(BaseChannel): name = "webhook" display_name = "Webhook" + def __init__(self, config: Any, bus: MessageBus): + if isinstance(config, dict): + config = WebhookConfig(**config) + super().__init__(config, bus) + @classmethod def default_config(cls) -> dict[str, Any]: return {"enabled": False, "port": 9000, "allowFrom": []} @@ -63,7 +78,7 @@ class WebhookChannel(BaseChannel): If it returns, the channel is considered dead. """ self._running = True - port = self.config.get("port", 9000) + port = self.config.port app = web.Application() app.router.add_post("/message", self._on_request) @@ -214,7 +229,7 @@ nanobot channels login --force # re-authenticate | 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. Automatically sets `_wants_stream` if `supports_streaming` is true. | -| `is_allowed(sender_id)` | Checks against `config["allowFrom"]`; `"*"` allows all, `[]` denies all. | +| `is_allowed(sender_id)` | Checks against `config.allow_from`; `"*"` 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). | | `supports_streaming` (property) | `True` when config has `"streaming": true` **and** subclass overrides `send_delta()`. | @@ -284,7 +299,9 @@ class WebhookChannel(BaseChannel): name = "webhook" display_name = "Webhook" - def __init__(self, config, bus): + def __init__(self, config: Any, bus: MessageBus): + if isinstance(config, dict): + config = WebhookConfig(**config) super().__init__(config, bus) self._buffers: dict[str, str] = {} @@ -333,12 +350,48 @@ When `streaming` is `false` (default) or omitted, only `send()` is called — no ## Config -Your channel receives config as a plain `dict`. Access fields with `.get()`: +### Why Pydantic model is required + +`BaseChannel.is_allowed()` and the `supports_streaming` property access config fields via `getattr()` (e.g. `getattr(self.config, "allow_from", [])`). This works for Pydantic models where `allow_from` is a real Python attribute, but **fails silently for plain `dict`** — `dict` has no `allow_from` attribute, so `getattr` always returns the default `[]`, causing all messages to be denied. + +Built-in channels use Pydantic config models (subclassing `Base` from `nanobot.config.schema`). Plugin channels **must do the same**. + +### Pattern + +1. Define a Pydantic model inheriting from `nanobot.config.schema.Base`: + +```python +from pydantic import Field +from nanobot.config.schema import Base + +class WebhookConfig(Base): + """Webhook channel configuration.""" + enabled: bool = False + port: int = 9000 + allow_from: list[str] = Field(default_factory=list) +``` + +`Base` is configured with `alias_generator=to_camel` and `populate_by_name=True`, so JSON keys like `"allowFrom"` and `"allow_from"` are both accepted. + +2. Convert `dict` → model in `__init__`: + +```python +from typing import Any +from nanobot.bus.queue import MessageBus + +class WebhookChannel(BaseChannel): + def __init__(self, config: Any, bus: MessageBus): + if isinstance(config, dict): + config = WebhookConfig(**config) + super().__init__(config, bus) +``` + +3. Access config as attributes (not `.get()`): ```python async def start(self) -> None: - port = self.config.get("port", 9000) - token = self.config.get("token", "") + port = self.config.port + token = self.config.token ``` `allowFrom` is handled automatically by `_handle_message()` — you don't need to check it yourself. @@ -351,6 +404,8 @@ def default_config(cls) -> dict[str, Any]: return {"enabled": False, "port": 9000, "allowFrom": []} ``` +> **Note:** `default_config()` still returns a plain `dict` (not a Pydantic model) because it's used to serialize into `config.json`. Use camelCase keys (`allowFrom`) to match the JSON convention. + If not overridden, the base class returns `{"enabled": false}`. ## Naming Convention