返回 JoyAI-Echo
channel-plugin-guide.md
根目录 / echo_longvideo / Director_Agent / docs / channel-plugin-guide.md
1 # Channel Plugin Guide
2
3 Build a custom nanobot channel in three steps: subclass, package, install.
4
5 > **Note:** We recommend developing channel plugins against a source checkout of nanobot (`pip install -e .`) rather than a PyPI release, so you always have access to the latest base-channel features and APIs.
6
7 ## How It Works
8
9 nanobot discovers channel plugins via Python [entry points](https://packaging.python.org/en/latest/specifications/entry-points/). When `nanobot gateway` starts, it scans:
10
11 1. Built-in channels in `nanobot/channels/`
12 2. External packages registered under the `nanobot.channels` entry point group
13
14 If a matching config section has `"enabled": true`, the channel is instantiated and started.
15
16 ## Quick Start
17
18 We'll build a minimal webhook channel that receives messages via HTTP POST and sends replies back.
19
20 ### Project Structure
21
22 ```text
23 nanobot-channel-webhook/
24 ├── nanobot_channel_webhook/
25 │ ├── __init__.py # re-export WebhookChannel
26 │ └── channel.py # channel implementation
27 └── pyproject.toml
28 ```
29
30 ### 1. Create Your Channel
31
32 ```python
33 # nanobot_channel_webhook/__init__.py
34 from nanobot_channel_webhook.channel import WebhookChannel
35
36 __all__ = ["WebhookChannel"]
37 ```
38
39 ```python
40 # nanobot_channel_webhook/channel.py
41 import asyncio
42 from typing import Any
43
44 from aiohttp import web
45 from loguru import logger
46 from pydantic import Field
47
48 from nanobot.channels.base import BaseChannel
49 from nanobot.bus.events import OutboundMessage
50 from nanobot.bus.queue import MessageBus
51 from nanobot.config.schema import Base
52
53
54 class WebhookConfig(Base):
55 """Webhook channel configuration."""
56 enabled: bool = False
57 port: int = 9000
58 allow_from: list[str] = Field(default_factory=list)
59
60
61 class WebhookChannel(BaseChannel):
62 name = "webhook"
63 display_name = "Webhook"
64
65 def __init__(self, config: Any, bus: MessageBus):
66 if isinstance(config, dict):
67 config = WebhookConfig(**config)
68 super().__init__(config, bus)
69
70 @classmethod
71 def default_config(cls) -> dict[str, Any]:
72 return WebhookConfig().model_dump(by_alias=True)
73
74 async def start(self) -> None:
75 """Start an HTTP server that listens for incoming messages.
76
77 IMPORTANT: start() must block forever (or until stop() is called).
78 If it returns, the channel is considered dead.
79 """
80 self._running = True
81 port = self.config.port
82
83 app = web.Application()
84 app.router.add_post("/message", self._on_request)
85 runner = web.AppRunner(app)
86 await runner.setup()
87 site = web.TCPSite(runner, "0.0.0.0", port)
88 await site.start()
89 logger.info("Webhook listening on :{}", port)
90
91 # Block until stopped
92 while self._running:
93 await asyncio.sleep(1)
94
95 await runner.cleanup()
96
97 async def stop(self) -> None:
98 self._running = False
99
100 async def send(self, msg: OutboundMessage) -> None:
101 """Deliver an outbound message.
102
103 msg.content — markdown text (convert to platform format as needed)
104 msg.media — list of local file paths to attach
105 msg.chat_id — the recipient (same chat_id you passed to _handle_message)
106 msg.metadata — may contain "_progress": True for streaming chunks
107 """
108 logger.info("[webhook] -> {}: {}", msg.chat_id, msg.content[:80])
109 # In a real plugin: POST to a callback URL, send via SDK, etc.
110
111 async def _on_request(self, request: web.Request) -> web.Response:
112 """Handle an incoming HTTP POST."""
113 body = await request.json()
114 sender = body.get("sender", "unknown")
115 chat_id = body.get("chat_id", sender)
116 text = body.get("text", "")
117 media = body.get("media", []) # list of URLs
118
119 # This is the key call: validates allowFrom, then puts the
120 # message onto the bus for the agent to process.
121 await self._handle_message(
122 sender_id=sender,
123 chat_id=chat_id,
124 content=text,
125 media=media,
126 )
127
128 return web.json_response({"ok": True})
129 ```
130
131 ### 2. Register the Entry Point
132
133 ```toml
134 # pyproject.toml
135 [project]
136 name = "nanobot-channel-webhook"
137 version = "0.1.0"
138 dependencies = ["echo-director-agent", "aiohttp"]
139
140 [project.entry-points."nanobot.channels"]
141 webhook = "nanobot_channel_webhook:WebhookChannel"
142
143 [build-system]
144 requires = ["hatchling"]
145 build-backend = "hatchling.build"
146
147 [tool.hatch.build.targets.wheel]
148 packages = ["nanobot_channel_webhook"]
149 ```
150
151 The key (`webhook`) becomes the config section name. The value points to your `BaseChannel` subclass.
152
153 ### 3. Install & Configure
154
155 ```bash
156 pip install -e .
157 nanobot plugins list # verify "Webhook" shows as "plugin"
158 nanobot onboard # auto-adds default config for detected plugins
159 ```
160
161 Edit `~/.nanobot/config.json`:
162
163 ```json
164 {
165 "channels": {
166 "webhook": {
167 "enabled": true,
168 "port": 9000,
169 "allowFrom": ["*"]
170 }
171 }
172 }
173 ```
174
175 ### 4. Run & Test
176
177 ```bash
178 nanobot gateway
179 ```
180
181 In another terminal:
182
183 ```bash
184 curl -X POST http://localhost:9000/message \
185 -H "Content-Type: application/json" \
186 -d '{"sender": "user1", "chat_id": "user1", "text": "Hello!"}'
187 ```
188
189 The agent receives the message and processes it. Replies arrive in your `send()` method.
190
191 ## BaseChannel API
192
193 ### Required (abstract)
194
195 | Method | Description |
196 |--------|-------------|
197 | `async start()` | **Must block forever.** Connect to platform, listen for messages, call `_handle_message()` on each. If this returns, the channel is dead. |
198 | `async stop()` | Set `self._running = False` and clean up. Called when gateway shuts down. |
199 | `async send(msg: OutboundMessage)` | Deliver an outbound message to the platform. |
200
201 ### Interactive Login
202
203 If your channel requires interactive authentication (e.g. QR code scan), override `login(force=False)`:
204
205 ```python
206 async def login(self, force: bool = False) -> bool:
207 """
208 Perform channel-specific interactive login.
209
210 Args:
211 force: If True, ignore existing credentials and re-authenticate.
212
213 Returns True if already authenticated or login succeeds.
214 """
215 # For QR-code-based login:
216 # 1. If force, clear saved credentials
217 # 2. Check if already authenticated (load from disk/state)
218 # 3. If not, show QR code and poll for confirmation
219 # 4. Save token on success
220 ```
221
222 Channels that don't need interactive login (e.g. Telegram with bot token, Discord with bot token) inherit the default `login()` which just returns `True`.
223
224 Users trigger interactive login via:
225 ```bash
226 nanobot channels login <channel_name>
227 nanobot channels login <channel_name> --force # re-authenticate
228 ```
229
230 ### Provided by Base
231
232 | Method / Property | Description |
233 |-------------------|-------------|
234 | `_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. |
235 | `is_allowed(sender_id)` | Checks against `config.allow_from`; `"*"` allows all, `[]` denies all. |
236 | `default_config()` (classmethod) | Returns default config dict for `nanobot onboard`. Override to declare your fields. |
237 | `transcribe_audio(file_path)` | Transcribes audio via Groq Whisper (if configured). |
238 | `supports_streaming` (property) | `True` when config has `"streaming": true` **and** subclass overrides `send_delta()`. |
239 | `is_running` | Returns `self._running`. |
240 | `login(force=False)` | Perform interactive login (e.g. QR code scan). Returns `True` if already authenticated or login succeeds. Override in subclasses that support interactive login. |
241
242 ### Optional (streaming)
243
244 | Method | Description |
245 |--------|-------------|
246 | `async send_delta(chat_id, delta, metadata?)` | Override to receive streaming chunks. See [Streaming Support](#streaming-support) for details. |
247
248 ### Message Types
249
250 ```python
251 @dataclass
252 class OutboundMessage:
253 channel: str # your channel name
254 chat_id: str # recipient (same value you passed to _handle_message)
255 content: str # markdown text — convert to platform format as needed
256 media: list[str] # local file paths to attach (images, audio, docs)
257 metadata: dict # may contain: "_progress" (bool) for streaming chunks,
258 # "message_id" for reply threading
259 ```
260
261 ## Streaming Support
262
263 Channels can opt into real-time streaming — the agent sends content token-by-token instead of one final message. This is entirely optional; channels work fine without it.
264
265 ### How It Works
266
267 When **both** conditions are met, the agent streams content through your channel:
268
269 1. Config has `"streaming": true`
270 2. Your subclass overrides `send_delta()`
271
272 If either is missing, the agent falls back to the normal one-shot `send()` path.
273
274 ### Implementing `send_delta`
275
276 Override `send_delta` to handle two types of calls:
277
278 ```python
279 async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None:
280 meta = metadata or {}
281
282 if meta.get("_stream_end"):
283 # Streaming finished — do final formatting, cleanup, etc.
284 return
285
286 # Regular delta — append text, update the message on screen
287 # delta contains a small chunk of text (a few tokens)
288 ```
289
290 **Metadata flags:**
291
292 | Flag | Meaning |
293 |------|---------|
294 | `_stream_delta: True` | A content chunk (delta contains the new text) |
295 | `_stream_end: True` | Streaming finished (delta is empty) |
296
297 ### Example: Webhook with Streaming
298
299 ```python
300 class WebhookChannel(BaseChannel):
301 name = "webhook"
302 display_name = "Webhook"
303
304 def __init__(self, config: Any, bus: MessageBus):
305 if isinstance(config, dict):
306 config = WebhookConfig(**config)
307 super().__init__(config, bus)
308 self._buffers: dict[str, str] = {}
309
310 async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None:
311 meta = metadata or {}
312 if meta.get("_stream_end"):
313 text = self._buffers.pop(chat_id, "")
314 # Final delivery — format and send the complete message
315 await self._deliver(chat_id, text, final=True)
316 return
317
318 self._buffers.setdefault(chat_id, "")
319 self._buffers[chat_id] += delta
320 # Incremental update — push partial text to the client
321 await self._deliver(chat_id, self._buffers[chat_id], final=False)
322
323 async def send(self, msg: OutboundMessage) -> None:
324 # Non-streaming path — unchanged
325 await self._deliver(msg.chat_id, msg.content, final=True)
326 ```
327
328 ### Config
329
330 Enable streaming per channel:
331
332 ```json
333 {
334 "channels": {
335 "webhook": {
336 "enabled": true,
337 "streaming": true,
338 "allowFrom": ["*"]
339 }
340 }
341 }
342 ```
343
344 When `streaming` is `false` (default) or omitted, only `send()` is called — no streaming overhead.
345
346 ### BaseChannel Streaming API
347
348 | Method / Property | Description |
349 |-------------------|-------------|
350 | `async send_delta(chat_id, delta, metadata?)` | Override to handle streaming chunks. No-op by default. |
351 | `supports_streaming` (property) | Returns `True` when config has `streaming: true` **and** subclass overrides `send_delta`. |
352
353 ## Config
354
355 ### Why Pydantic model is required
356
357 `BaseChannel.is_allowed()` reads the permission list via `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.
358
359 Built-in channels use Pydantic config models (subclassing `Base` from `nanobot.config.schema`). Plugin channels **must do the same**.
360
361 ### Pattern
362
363 1. Define a Pydantic model inheriting from `nanobot.config.schema.Base`:
364
365 ```python
366 from pydantic import Field
367 from nanobot.config.schema import Base
368
369 class WebhookConfig(Base):
370 """Webhook channel configuration."""
371 enabled: bool = False
372 port: int = 9000
373 allow_from: list[str] = Field(default_factory=list)
374 ```
375
376 `Base` is configured with `alias_generator=to_camel` and `populate_by_name=True`, so JSON keys like `"allowFrom"` and `"allow_from"` are both accepted.
377
378 2. Convert `dict` → model in `__init__`:
379
380 ```python
381 from typing import Any
382 from nanobot.bus.queue import MessageBus
383
384 class WebhookChannel(BaseChannel):
385 def __init__(self, config: Any, bus: MessageBus):
386 if isinstance(config, dict):
387 config = WebhookConfig(**config)
388 super().__init__(config, bus)
389 ```
390
391 3. Access config as attributes (not `.get()`):
392
393 ```python
394 async def start(self) -> None:
395 port = self.config.port
396 token = self.config.token
397 ```
398
399 `allowFrom` is handled automatically by `_handle_message()` — you don't need to check it yourself.
400
401 Override `default_config()` so `nanobot onboard` auto-populates `config.json`:
402
403 ```python
404 @classmethod
405 def default_config(cls) -> dict[str, Any]:
406 return WebhookConfig().model_dump(by_alias=True)
407 ```
408
409 > **Note:** `default_config()` returns a plain `dict` (not a Pydantic model) because it's used to serialize into `config.json`. The recommended way is to instantiate your config model and call `model_dump(by_alias=True)` — this automatically uses camelCase keys (`allowFrom`) and keeps defaults in a single source of truth.
410
411 If not overridden, the base class returns `{"enabled": false}`.
412
413 ## Naming Convention
414
415 | What | Format | Example |
416 |------|--------|---------|
417 | PyPI package | `nanobot-channel-{name}` | `nanobot-channel-webhook` |
418 | Entry point key | `{name}` | `webhook` |
419 | Config section | `channels.{name}` | `channels.webhook` |
420 | Python package | `nanobot_channel_{name}` | `nanobot_channel_webhook` |
421
422 ## Local Development
423
424 ```bash
425 git clone https://github.com/you/nanobot-channel-webhook
426 cd nanobot-channel-webhook
427 pip install -e .
428 nanobot plugins list # should show "Webhook" as "plugin"
429 nanobot gateway # test end-to-end
430 ```
431
432 ## Verify
433
434 ```bash
435 $ nanobot plugins list
436
437 Name Source Enabled
438 telegram builtin yes
439 discord builtin no
440 webhook plugin yes
441 ```
442
442 lines MARKDOWN