refactor(channels): make built-in channels self-contained (#4908)
* refactor(channels): own setup and instance contracts * refactor(channels): isolate management contracts * refactor(channels): normalize activation contracts * fix(channels): enforce management contracts * refactor(channels): finish setup ownership migration * fix(channels): harden management contracts * fix(channels): enforce lazy loading and runtime ownership * fix(feishu): make multi-instance startup idempotent * fix(webui): render channel setup contracts cleanly * fix(feishu): stop websocket clients cleanly * fix(channels): enforce persistence and activation gates * fix(channels): preserve global feature action scope * fix(channels): apply defaults for single plugins * fix(channels): enforce management contract boundaries * refactor(feishu): remove identity helper indirection * fix(channels): preserve management setup contracts * refactor(channels): generalize instance settings UI * refactor(channels): package channel plugins with web UI metadata * refactor(channels): make built-ins self-contained packages * test(channels): colocate tests with channel packages * fix(dingtalk): use official brand icon * feat(channels): colocate webui translations * docs(channels): clarify plugin ownership * test(exec): remove output wait race * refactor(channels): unify plugin descriptors * fix(channels): enforce descriptor-owned contracts * refactor(channels): finish package-owned plugin setup * refactor(channels): use repository-owned packages only * fix(channels): self-describe dependencies and runtime state * fix(channels): warn about legacy entry points
This commit is contained in:
@@ -59,19 +59,19 @@ jobs:
|
||||
|
||||
- name: Lint with ruff
|
||||
if: matrix.coverage
|
||||
run: uv run ruff check nanobot tests
|
||||
run: uv run ruff check nanobot tests conftest.py
|
||||
|
||||
- name: Run tests with coverage
|
||||
if: matrix.coverage
|
||||
run: >-
|
||||
uv run python -m pytest tests/
|
||||
uv run python -m pytest
|
||||
--cov=nanobot --cov-report=term-missing:skip-covered
|
||||
--durations=25 --durations-min=1.0
|
||||
|
||||
- name: Run compatibility tests
|
||||
if: ${{ !matrix.coverage }}
|
||||
run: >-
|
||||
uv run python -m pytest tests/
|
||||
uv run python -m pytest
|
||||
--durations=25 --durations-min=1.0
|
||||
|
||||
webui:
|
||||
|
||||
@@ -36,7 +36,7 @@ Messages flow through an async `MessageBus` (`nanobot/bus/queue.py`) that decoup
|
||||
|
||||
- **Agent Loop** (`nanobot/agent/loop.py`, `runner.py`): The core processing engine. `AgentLoop` manages session keys, hooks, and context building. `AgentRunner` executes the multi-turn LLM conversation with tool execution.
|
||||
- **LLM Providers** (`nanobot/providers/`): Provider implementations (Anthropic, OpenAI-compatible, OpenAI Responses API, Azure, Bedrock, GitHub Copilot, OpenAI Codex, etc.) built on a common base (`base.py`). Includes image generation (`image_generation.py`) and audio transcription (`transcription.py`). `factory.py` and `registry.py` handle instantiation and model discovery.
|
||||
- **Channels** (`nanobot/channels/`): Platform integrations (Telegram, Discord, Slack, Feishu, Matrix, WhatsApp, QQ, WeChat, WeCom, DingTalk, Email, MoChat, MS Teams, WebSocket, Mattermost). `manager.py` discovers and coordinates them. Channels are auto-discovered via `pkgutil` scan + entry-point plugins.
|
||||
- **Channels** (`nanobot/channels/`): Platform integrations (Telegram, Discord, Slack, Feishu, Matrix, WhatsApp, QQ, WeChat, WeCom, DingTalk, Email, MoChat, MS Teams, WebSocket, Mattermost). `manager.py` discovers and coordinates them. Channels are self-contained packages auto-discovered via `pkgutil` scanning.
|
||||
- **Tools** (`nanobot/agent/tools/`): Agent capabilities exposed to the LLM: filesystem (read/write/edit/list), shell execution (with sandbox backends), web search/fetch, MCP servers, cron, notebook editing, subagent spawning, long-running tasks / sustained goals (`long_task.py`), image generation, and self-modification. Tools are auto-discovered via `pkgutil` scan + entry-point plugins.
|
||||
- **Memory** (`nanobot/agent/memory.py`): Session history persistence with Dream two-phase memory consolidation. Uses atomic writes with fsync for durability.
|
||||
- **Session Management** (`nanobot/session/`): Per-session history, context compaction, TTL-based auto-compaction (`manager.py`), and sustained goal state tracking (`goal_state.py`).
|
||||
|
||||
+1
-1
@@ -129,7 +129,7 @@ pip install --upgrade nanobot-ai
|
||||
|
||||
**Important Notes:**
|
||||
- Keep `litellm` updated to the latest version for security fixes
|
||||
- Run `pip-audit` regularly, including optional channel dependencies such as `nanobot-ai[whatsapp]`
|
||||
- Run `pip-audit` regularly after enabling the channels used in production; their manifest-declared dependencies are installed into the same environment
|
||||
- Subscribe to security advisories for nanobot and its dependencies
|
||||
|
||||
### 7. Production Deployment
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ These pages explain implementation and extension points. You do not need them to
|
||||
|---|---|
|
||||
| Understand source ownership and runtime flow | [Architecture](./architecture.md) |
|
||||
| Set up a development environment | [Development](./development.md) and [CONTRIBUTING.md](../CONTRIBUTING.md) |
|
||||
| Add a channel package | [Channel Plugin Guide](./channel-plugin-guide.md) |
|
||||
| Add a channel package | [Channel Package Guide](./channel-package-guide.md) |
|
||||
| Build the WebUI source | [WebUI Development](../webui/README.md) |
|
||||
|
||||
If a command or screen no longer matches these docs, please [open an issue](https://github.com/HKUDS/nanobot/issues) with your nanobot version, operating system, and the page that needs correction.
|
||||
|
||||
@@ -81,11 +81,11 @@ Main files:
|
||||
| Area | Files |
|
||||
|---|---|
|
||||
| Base channel contract | `nanobot/channels/base.py` |
|
||||
| Built-in channels | `nanobot/channels/*.py` |
|
||||
| Channel packages | `nanobot/channels/<channel>/` |
|
||||
| Discovery and lifecycle | `nanobot/channels/manager.py` |
|
||||
| WebSocket/WebUI channel | `nanobot/channels/websocket.py` |
|
||||
| WebSocket/WebUI channel | `nanobot/channels/websocket/` |
|
||||
|
||||
Channels are discovered through built-in module scanning and plugin entry points. A custom channel should follow [`channel-plugin-guide.md`](./channel-plugin-guide.md).
|
||||
Channels are discovered by scanning self-contained packages under `nanobot/channels/`. Add a channel by contributing one package that follows [`channel-package-guide.md`](./channel-package-guide.md).
|
||||
|
||||
## WebUI and Gateway
|
||||
|
||||
@@ -181,7 +181,7 @@ When changing tools, channels, file access, WebUI workspace behavior, or network
|
||||
| Extension | How |
|
||||
|---|---|
|
||||
| Provider | Add `ProviderSpec` in `providers/registry.py`, add schema field in `config/schema.py`, implement provider only if the generic backend is not enough |
|
||||
| Channel | Implement `BaseChannel`, expose an entry point, follow [`channel-plugin-guide.md`](./channel-plugin-guide.md) |
|
||||
| Channel | Export a `ChannelPlugin` descriptor, keep its runtime and optional setup surfaces in one package, and follow [`channel-package-guide.md`](./channel-package-guide.md) |
|
||||
| Tool | Implement a tool under `agent/tools/` or expose a plugin entry point |
|
||||
| MCP | Add `tools.mcpServers` config |
|
||||
| Skill | Add workspace skill files under `<workspace>/skills/` or built-in skills under `nanobot/skills/` |
|
||||
|
||||
@@ -0,0 +1,792 @@
|
||||
# Channel Package Guide
|
||||
|
||||
Use this guide to add a self-contained channel package to the nanobot repository. A channel is part of nanobot when its package lives at `nanobot/channels/<channel>/`; there is no separate external channel-plugin path.
|
||||
|
||||
> **Breaking change:** nanobot no longer discovers the `nanobot.channels` Python entry-point group. Move an entry-point implementation into `nanobot/channels/<channel>/` with a package-owned manifest, runtime, tests, and optional WebUI contribution.
|
||||
|
||||
## How It Works
|
||||
|
||||
When `nanobot gateway` starts, nanobot scans the packages under `nanobot/channels/` and loads each dependency-free `ChannelPlugin` descriptor from `manifest.py`.
|
||||
|
||||
If a matching config section has `"enabled": true`, the channel is instantiated and started.
|
||||
|
||||
## Ownership and Sources of Truth
|
||||
|
||||
| Concern | Owner and source of truth |
|
||||
|---------|---------------------------|
|
||||
| Runtime behavior and platform SDK use | `runtime.py` and package-local helpers |
|
||||
| Python package requirements | `ChannelPlugin.dependencies` in `manifest.py` |
|
||||
| Writable settings fields, types, defaults, requirements, secret handling, and validation | `ChannelPlugin.setup` in `manifest.py` |
|
||||
| Persisted config expansion, instance updates, and runtime naming | `ChannelPlugin.management` backed by a dependency-free module |
|
||||
| Interactive setup connections and their short-lived state | `ChannelPlugin.connector` backed by package-local `connect.py` |
|
||||
| Reusable local login-state detection | `ChannelPlugin.management.local_state_present` backed by package-local code |
|
||||
| Discovery metadata and lazy runtime target | `PLUGIN` in `manifest.py` |
|
||||
| WebUI structure, components, URLs, field keys, actions, and preset values | `webui/index.ts` or `webui/index.tsx` |
|
||||
| Channel-specific user-facing copy | `webui/locales/<locale>.json` |
|
||||
| Generic settings-shell copy shared by every channel | `webui/src/i18n/locales/<locale>/common.json` |
|
||||
|
||||
Keep one source of truth for each concern. In particular, the backend setup contract decides what may be written, the TypeScript contribution decides how those fields are presented, and locale JSON supplies the channel-specific words shown to users.
|
||||
|
||||
## Quick Start
|
||||
|
||||
We'll build a minimal webhook channel that receives messages via HTTP POST and sends replies back.
|
||||
|
||||
### Project Structure
|
||||
|
||||
```text
|
||||
nanobot/channels/webhook/
|
||||
├── __init__.py # lightweight package marker; do not import the runtime
|
||||
├── manifest.py # dependency-free ChannelPlugin descriptor
|
||||
├── runtime.py # channel implementation and optional SDK imports
|
||||
├── tests/ # package-local tests
|
||||
└── webui/ # optional settings UI and translations
|
||||
```
|
||||
|
||||
### 1. Create Your Channel
|
||||
|
||||
```python
|
||||
# nanobot/channels/webhook/__init__.py
|
||||
"""Webhook channel package."""
|
||||
```
|
||||
|
||||
```python
|
||||
# nanobot/channels/webhook/manifest.py
|
||||
from nanobot.channels.contracts import ChannelFieldSpec, ChannelSetupSpec
|
||||
from nanobot.channels.plugin import ChannelPlugin
|
||||
|
||||
|
||||
PLUGIN = ChannelPlugin(
|
||||
name="webhook",
|
||||
display_name="Webhook",
|
||||
runtime=f"{__package__}.runtime:WebhookChannel",
|
||||
dependencies=("aiohttp>=3.9.0,<4.0.0",),
|
||||
setup=ChannelSetupSpec(
|
||||
fields={
|
||||
"port": ChannelFieldSpec(kind="int", default=9000),
|
||||
"allowFrom": ChannelFieldSpec(kind="list"),
|
||||
},
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
```python
|
||||
# nanobot/channels/webhook/runtime.py
|
||||
import asyncio
|
||||
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 WebhookConfig().model_dump(by_alias=True)
|
||||
|
||||
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.port
|
||||
|
||||
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 — channel routing context such as message/thread ids
|
||||
msg.event — typed runtime event for progress/status messages
|
||||
"""
|
||||
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})
|
||||
```
|
||||
|
||||
The package directory, `PLUGIN.name`, runtime class name, and config section must all use `webhook`. Channel names use a portable ASCII package identifier: they start with a letter and contain only letters, digits, or underscores.
|
||||
|
||||
Declare runtime requirements directly in `ChannelPlugin.dependencies`. Do not add channel requirements to the root `pyproject.toml`: the package manifest is the source of truth used by the CLI, WebUI, and gateway startup. Keep the manifest and anything it imports free of the optional SDK itself.
|
||||
|
||||
### 2. Configure
|
||||
|
||||
```bash
|
||||
nanobot plugins list # verify the channel package appears as "webhook"
|
||||
nanobot onboard # add default config for detected channels
|
||||
```
|
||||
|
||||
Edit `~/.nanobot/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"webhook": {
|
||||
"enabled": true,
|
||||
"port": 9000,
|
||||
"allowFrom": ["*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
nanobot always loads the dependency-free descriptor during discovery. When the WebUI gateway starts, it installs missing requirements for enabled channels before importing their runtimes. It also installs them when a channel is enabled from the CLI or WebUI. Status, configuration, and disable operations do not need the runtime. Single-instance and multi-instance channels use the same activation rules.
|
||||
|
||||
### 3. 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.
|
||||
|
||||
## Channel Package Requirements
|
||||
|
||||
Every channel is a self-contained package at `nanobot/channels/<channel>/`; channel-specific runtime code, setup metadata, tests, WebUI structure, components, and translations stay under that directory.
|
||||
|
||||
### Package Layout
|
||||
|
||||
```text
|
||||
nanobot/channels/<channel>/
|
||||
├── __init__.py # package marker only; no runtime or SDK imports
|
||||
├── manifest.py # dependency-free ChannelPlugin and ChannelSetupSpec
|
||||
├── config.py # optional dependency-free config model and defaults
|
||||
├── connect.py # optional interactive setup connector
|
||||
├── instances.py # optional dependency-free multi-instance management adapter
|
||||
├── state.py # optional persisted login-state detection
|
||||
├── validation.py # optional package-owned setup checks
|
||||
├── runtime.py # BaseChannel implementation and platform SDK imports
|
||||
├── tests/ # channel-specific Python tests
|
||||
└── webui/ # optional, compiled into the shared WebUI
|
||||
├── index.ts or index.tsx # structure and optional React components
|
||||
└── locales/
|
||||
├── en.json # canonical locale shape
|
||||
└── <locale>.json # one file for every supported WebUI locale
|
||||
```
|
||||
|
||||
Do not add a runtime module directly under `nanobot/channels/`, create a parallel manifest tree, or add a central per-channel UI catalog. If existing channel files move, use `git mv` so history remains traceable.
|
||||
|
||||
### Manifest and Runtime Boundary
|
||||
|
||||
`manifest.py` exports a typed `ChannelPlugin` whose `runtime` target is an absolute import target, such as `nanobot.channels.telegram.runtime:TelegramChannel`; using `f"{__package__}.runtime:TelegramChannel"` keeps it package-owned without repeating the package path. Discovery imports the manifest before it knows whether the optional platform dependency is installed, so `manifest.py` must not import `runtime.py` or any platform SDK. Import runtime symbols from `runtime.py` explicitly; `__init__.py` remains an inert package marker.
|
||||
|
||||
The manifest owns the channel name, display name, setup contract, management adapter, optional connector target, optional dependency extra, capabilities, default activation, and optional WebUI entry path. The management adapter alone decides whether a channel is single-instance or multi-instance.
|
||||
|
||||
Interactive browser setup uses one small connector contract. Set `connector=f"{__package__}.connect:MyConnectStore"`; the target is loaded only when `/api/settings/channels/<name>/connect/{start,poll,cancel}` is called. The store exposes one async `handle(action, query)` method and keeps platform-specific parsing, sessions, and errors inside the channel package. The shared settings router only authenticates, dispatches, and applies a successful connection.
|
||||
|
||||
Use the small constructors in [`nanobot/channels/_manifest.py`](../nanobot/channels/_manifest.py) for declarative field and requirement definitions. Use [`nanobot/channels/dingtalk/manifest.py`](../nanobot/channels/dingtalk/manifest.py) as a compact single-instance example and [`nanobot/channels/feishu/`](../nanobot/channels/feishu/) as a multi-instance example.
|
||||
|
||||
### Package-owned WebUI
|
||||
|
||||
Set `webui="webui/index.ts"` or `webui="webui/index.tsx"` in the channel manifest. Candidate modules are bundled from channel packages, but the settings UI activates only the exact path returned by the backend feature payload.
|
||||
|
||||
The entry module exports one default `ChannelUiContribution`. Channel identity comes from the package directory, so do not repeat a `channel` field in TypeScript. Keep only structure and executable UI data in this module: presentation metadata, icons or logo URLs, docs URLs, config field keys, action payloads, preset values, aliases, and optional `Panel` or `ConnectFlow` components.
|
||||
|
||||
Do not put static descriptions, setup steps, labels, placeholders, help text, action labels, or preset labels in TSX. Those strings belong in the channel's locale JSON. TSX remains appropriate for dynamic rendering, interpolation, conditions, and rich component composition.
|
||||
|
||||
### Channel-owned i18n
|
||||
|
||||
Create `webui/locales/<locale>.json` for every locale code declared in [`webui/src/i18n/config.ts`](../webui/src/i18n/config.ts). Treat `en.json` as the canonical shape; every other locale must contain the same message keys and the same interpolation variables. `displayName` may be omitted when the product name should remain unchanged.
|
||||
|
||||
```json
|
||||
{
|
||||
"description": "Use nanobot from Example chats.",
|
||||
"requirements": "Example app credentials and gateway",
|
||||
"setup": {
|
||||
"docsLabel": "Open Example setup",
|
||||
"officialLabel": "Open Example console",
|
||||
"summary": "Example needs app credentials.",
|
||||
"tryIt": "Send a test message.",
|
||||
"steps": [
|
||||
"Create an Example app.",
|
||||
"Add the credentials.",
|
||||
"Save, enable, and test the channel."
|
||||
],
|
||||
"fields": {
|
||||
"clientId": {
|
||||
"label": "Client ID",
|
||||
"placeholder": "Example client ID",
|
||||
"help": "Copy it from the Example console."
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"copyManifest": "Copy manifest"
|
||||
},
|
||||
"presets": {
|
||||
"default": "Default"
|
||||
}
|
||||
},
|
||||
"custom": {
|
||||
"connected": "{{name}} is connected."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Field messages are keyed by the config path after `channels.<channel>.`, with remaining punctuation converted to underscores. For example, `channels.signal.dm.allowFrom` maps to `setup.fields.dm_allowFrom`. Action and preset messages use the IDs declared in the TypeScript contribution.
|
||||
|
||||
Custom channel components should read dynamic copy with `channelTranslator(t, "<channel>")`; keep the English fallback adjacent to the call so an incomplete translation still renders useful text. Aliases reuse the owning channel's locale namespace rather than duplicating translations.
|
||||
|
||||
The dependency direction is intentional:
|
||||
|
||||
- [`webui/src/i18n/index.ts`](../webui/src/i18n/index.ts) imports the pure JSON [`channel-plugins/locale-registry.ts`](../webui/src/channel-plugins/locale-registry.ts).
|
||||
- The locale registry discovers only `nanobot/channels/*/webui/locales/*.json` and must not import the UI registry, React, or TSX.
|
||||
- Settings components may consume both the UI registry and locale registry.
|
||||
- Channel UI code may use shared types and generic settings components, but core settings code must not add `if (feature.name === "...")` branches for individual channels.
|
||||
|
||||
This separation prevents i18n initialization from eagerly loading every channel React component and keeps channel-specific ownership below the channel package.
|
||||
|
||||
### Tests and Definition of Done
|
||||
|
||||
Put channel-specific Python tests in `nanobot/channels/<channel>/tests/`. Keep only shared registry, manager, base-class, and cross-channel contract tests in `tests/channels/`. Release builds exclude package-local tests while the repository test configuration discovers both trees.
|
||||
|
||||
For a focused channel change, run the smallest relevant set:
|
||||
|
||||
```bash
|
||||
uv run pytest nanobot/channels/<channel>/tests -q
|
||||
|
||||
cd webui
|
||||
bun run test -- src/tests/channel-locale-registry.test.ts src/tests/channel-ui-registry.test.ts src/tests/channel-identity.test.ts
|
||||
bun run lint
|
||||
bun run build
|
||||
```
|
||||
|
||||
Before considering the change complete, verify all of the following:
|
||||
|
||||
- The manifest can be discovered without importing the runtime or optional platform SDK.
|
||||
- `ChannelSetupSpec` contains every writable field and rejects unknown fields.
|
||||
- The TypeScript field, action, and preset IDs have matching English locale messages.
|
||||
- Every supported locale matches the English key shape and interpolation variables.
|
||||
- Generic settings copy remains in core `common.json`; channel-specific copy remains inside the channel package.
|
||||
- User-facing WebUI changes work through the built frontend served by a real gateway, including language switching and refresh persistence.
|
||||
- Markdown prose paragraphs and individual list items remain on one source line; let the renderer handle visual wrapping.
|
||||
|
||||
## 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. Raise when the transport does not accept it. |
|
||||
|
||||
#### Outbound delivery contract
|
||||
|
||||
A normal return from `send()` means either the visible payload was accepted by the platform transport/API, or the channel deliberately had nothing to deliver, such as an empty progress event. Do not log and return when the client is disconnected, still starting, or the platform rejects the request. Raise an exception so `ChannelManager` can apply the shared retry policy.
|
||||
|
||||
`send()` may run as soon as `is_running` becomes true. If a channel sets `_running` before its transport is ready, it must keep raising until delivery can be attempted safely. Small platform-specific retries are fine, but the final failure must still reach the manager.
|
||||
|
||||
### Interactive Login
|
||||
|
||||
If your channel requires interactive authentication (e.g. QR code scan), override `login(force=False)`:
|
||||
|
||||
```python
|
||||
async def login(self, force: bool = False) -> bool:
|
||||
"""
|
||||
Perform channel-specific interactive login.
|
||||
|
||||
Args:
|
||||
force: If True, ignore existing credentials and re-authenticate.
|
||||
|
||||
Returns True if already authenticated or login succeeds.
|
||||
"""
|
||||
# For QR-code-based login:
|
||||
# 1. If force, clear saved credentials
|
||||
# 2. Check if already authenticated (load from disk/state)
|
||||
# 3. If not, show QR code and poll for confirmation
|
||||
# 4. Save token on success
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
Users trigger interactive login via:
|
||||
```bash
|
||||
nanobot channels login <channel_name>
|
||||
nanobot channels login <channel_name> --force # re-authenticate
|
||||
```
|
||||
|
||||
### 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. Automatically sets `_wants_stream` if `supports_streaming` is true. |
|
||||
| `is_allowed(sender_id)` | Checks against `config.allow_from`; `"*"` allows all, `[]` denies all. |
|
||||
| `default_config()` (classmethod) | Returns runtime-local defaults for callers that construct the class directly. Discovery and onboarding use the descriptor instead. |
|
||||
| `refresh_feature_metadata(config_path, instance_id)` (classmethod) | Optionally refreshes saved display metadata after an explicit settings action. It is never called by a read-only feature GET. |
|
||||
| `transcribe_audio(file_path)` | Transcribes audio via the shared top-level `transcription` config (if configured). |
|
||||
| `supports_streaming` (property) | `True` when config has `"streaming": true` **and** subclass overrides `send_delta()`. |
|
||||
| `is_running` | Returns `self._running`. |
|
||||
| `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. |
|
||||
| `send_reasoning_delta(chat_id, delta, metadata?, *, stream_id?)` | Optional hook for streamed model reasoning/thinking content. Default is no-op. |
|
||||
| `send_reasoning_end(chat_id, metadata?, *, stream_id?)` | Optional hook marking the end of a reasoning block. Default is no-op. |
|
||||
| `send_reasoning(msg)` | Optional one-shot reasoning fallback. Default translates to `send_reasoning_delta()` + `send_reasoning_end()`. |
|
||||
|
||||
### Optional management contract
|
||||
|
||||
Persisted-state management belongs to `ChannelPlugin.management`, not `BaseChannel`. Keep the adapter and anything it imports free of optional platform SDKs so status, settings, and disable operations still work when the runtime cannot be imported. Runtime classes own network lifecycle, message delivery, interactive login, enable-time availability checks, and explicit runtime-only actions such as metadata refresh.
|
||||
|
||||
```python
|
||||
from nanobot.channels.contracts import ChannelFieldSpec, ChannelSetupSpec, SetupRequirement
|
||||
from nanobot.channels.plugin import ChannelPlugin
|
||||
|
||||
from .instances import MANAGEMENT
|
||||
|
||||
PLUGIN = ChannelPlugin(
|
||||
name="webhook",
|
||||
display_name="Webhook",
|
||||
runtime=f"{__package__}.channel:WebhookChannel",
|
||||
setup=ChannelSetupSpec(
|
||||
fields={
|
||||
"token": ChannelFieldSpec(kind="secret"),
|
||||
"region": ChannelFieldSpec(
|
||||
kind="enum",
|
||||
choices=frozenset({"us", "eu"}),
|
||||
default="us",
|
||||
),
|
||||
},
|
||||
required=(SetupRequirement.field("token"),),
|
||||
),
|
||||
management=MANAGEMENT,
|
||||
)
|
||||
```
|
||||
|
||||
`instances.py` then exports the dependency-free adapter assembled from channel-owned callbacks:
|
||||
|
||||
```python
|
||||
from typing import Any
|
||||
|
||||
from nanobot.channels.contracts import ChannelInstanceSpec, ChannelManagementSpec
|
||||
|
||||
from .config import default_config
|
||||
|
||||
|
||||
def instance_specs(section: Any, *, enabled_only: bool = True) -> list[ChannelInstanceSpec]:
|
||||
... # Expand the persisted channel-owned envelope.
|
||||
|
||||
|
||||
def update_instance_config(
|
||||
section: Any,
|
||||
values: dict[str, Any],
|
||||
*,
|
||||
instance_id: str = "default",
|
||||
) -> dict[str, Any]:
|
||||
... # Update one instance without discarding sibling data.
|
||||
|
||||
|
||||
MANAGEMENT = ChannelManagementSpec(
|
||||
multi_instance=True,
|
||||
default_config=default_config,
|
||||
instance_specs=instance_specs,
|
||||
update_instance_config=update_instance_config,
|
||||
)
|
||||
```
|
||||
|
||||
`ChannelSetupSpec` is authoritative for writable field names, field types, choices, defaults, required setup, secret redaction, and optional backend validation. The settings API rejects fields outside this contract. A validator receives `(values, context)`; use `context.allow_local_service_access` for host network policy instead of loading global config from the channel package.
|
||||
|
||||
The dependency-free `MANAGEMENT` value is a `ChannelManagementSpec`. Multi-instance plugins provide `instance_specs(section, enabled_only=True)` and `update_instance_config(section, values, instance_id=...)`; they may also provide `default_config`, `runtime_name`, presentation-only `feature_instances`, and `local_state_present`. Single-instance plugins normally derive onboarding defaults from `ChannelSetupSpec`; use `default_config` only when persisted defaults include fields that are not part of generic setup.
|
||||
|
||||
Multi-instance adapters return `ChannelInstanceSpec` objects and preserve their persisted envelope when updating one instance. Their descriptor sets `ChannelManagementSpec(multi_instance=True)`. The shared contract enforces these invariants:
|
||||
|
||||
- every `instance_id` is non-empty and unique;
|
||||
- the management adapter's `runtime_name(channel_name, instance_id)` is the single source of routing names, and every derived name is unique and is either the channel name or starts with `<channel-name>.`;
|
||||
- runtime names cannot overwrite a runtime already owned by another channel;
|
||||
- settings instance summaries are generated from `instance_specs()` and `ChannelPlugin.setup`. They contain the authoritative `enabled` and `configured` state plus secret-safe `config_values` and `configured_fields` for the generic instance editor;
|
||||
- the management adapter's `feature_instances()` may return `None` or presentation overrides containing an `id` plus `name`, `display_name`, or `avatar_url`. It cannot override runtime state or the configuration snapshot.
|
||||
|
||||
`ChannelInstanceSpec` contains only `instance_id` and the instance config; nanobot derives its runtime name through the adapter. Single-instance plugins keep ownership of their entire config, including a field named `instances`. Only plugins whose management spec sets `multi_instance=True` opt into instance expansion.
|
||||
|
||||
The package/config section name owns every runtime produced from that section. Class inheritance does not transfer runtime ownership to another package.
|
||||
|
||||
Return a concrete iterable or generator from the adapter's `instance_specs()`; nanobot materializes and validates it before constructing any runtime. Raise an exception for malformed persisted data rather than silently changing instance identity. Keep network-backed metadata refresh behind the runtime's `refresh_feature_metadata()` so feature GET requests remain dependency-free and read-only.
|
||||
|
||||
For package layout, WebUI ownership, and localization rules, see [Channel Package Requirements](#channel-package-requirements).
|
||||
|
||||
### Optional (streaming)
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `async send_delta(chat_id, delta, metadata?, *, stream_id?, stream_end=False, resuming=False)` | Override to receive streaming chunks. See [Streaming Support](#streaming-support) for details. |
|
||||
|
||||
### 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 # channel routing context, e.g. "message_id" for threading
|
||||
event: object | None # typed runtime/UI event; usually inspect with isinstance()
|
||||
```
|
||||
|
||||
Runtime/UI semantics live on `msg.event`. Plugin-authored outbound messages should use typed events instead of legacy metadata flags such as `_progress`, `_stream_delta`, `_stream_end`, `_reasoning_delta`, `_turn_end`, or `_goal_status`. nanobot still accepts those old flags as a compatibility bridge for existing in-process extensions, but new plugin code should not add fresh dependencies on them.
|
||||
|
||||
## Streaming Support
|
||||
|
||||
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.
|
||||
|
||||
### How It Works
|
||||
|
||||
When **both** conditions are met, the agent streams content through your channel:
|
||||
|
||||
1. Config has `"streaming": true`
|
||||
2. Your subclass overrides `send_delta()`
|
||||
|
||||
If either is missing, the agent falls back to the normal one-shot `send()` path.
|
||||
|
||||
### Implementing `send_delta`
|
||||
|
||||
Override `send_delta` to handle two types of calls:
|
||||
|
||||
```python
|
||||
async def send_delta(
|
||||
self,
|
||||
chat_id: str,
|
||||
delta: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
stream_id: str | None = None,
|
||||
stream_end: bool = False,
|
||||
resuming: bool = False,
|
||||
) -> None:
|
||||
buffer_key = stream_id or chat_id
|
||||
if stream_end:
|
||||
# Streaming finished — do final formatting, cleanup, etc.
|
||||
return
|
||||
|
||||
# Regular delta — append text, update the message on screen
|
||||
# delta contains a small chunk of text (a few tokens)
|
||||
```
|
||||
|
||||
Streaming state is passed through keyword-only arguments, not `_stream_delta` or `_stream_end` metadata flags. Use `stream_id` to key any per-stream buffers; fall back to `chat_id` when it is missing.
|
||||
|
||||
### Example: Webhook with Streaming
|
||||
|
||||
```python
|
||||
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)
|
||||
self._buffers: dict[str, str] = {}
|
||||
|
||||
async def send_delta(
|
||||
self,
|
||||
chat_id: str,
|
||||
delta: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
stream_id: str | None = None,
|
||||
stream_end: bool = False,
|
||||
resuming: bool = False,
|
||||
) -> None:
|
||||
buffer_key = stream_id or chat_id
|
||||
if stream_end:
|
||||
text = self._buffers.pop(buffer_key, "")
|
||||
# Final delivery — format and send the complete message
|
||||
await self._deliver(chat_id, text, final=True)
|
||||
return
|
||||
|
||||
self._buffers.setdefault(buffer_key, "")
|
||||
self._buffers[buffer_key] += delta
|
||||
# Incremental update — push partial text to the client
|
||||
await self._deliver(chat_id, self._buffers[buffer_key], final=False)
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
# Non-streaming path — unchanged
|
||||
await self._deliver(msg.chat_id, msg.content, final=True)
|
||||
```
|
||||
|
||||
### Config
|
||||
|
||||
Enable streaming per channel:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"webhook": {
|
||||
"enabled": true,
|
||||
"streaming": true,
|
||||
"allowFrom": ["*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When `streaming` is `false` (default) or omitted, only `send()` is called — no streaming overhead.
|
||||
|
||||
### BaseChannel Streaming API
|
||||
|
||||
| Method / Property | Description |
|
||||
|-------------------|-------------|
|
||||
| `async send_delta(chat_id, delta, metadata?, *, stream_id?, stream_end=False, resuming=False)` | Override to handle streaming chunks. No-op by default. |
|
||||
| `supports_streaming` (property) | Returns `True` when config has `streaming: true` **and** subclass overrides `send_delta`. |
|
||||
|
||||
## Progress, Tool Hints, and Reasoning
|
||||
|
||||
Besides normal assistant text, nanobot can emit low-emphasis trace blocks. These are intended for UI affordances like status rows, collapsible "used tools" groups, or reasoning/thinking blocks. Platforms that do not have a good place for them can ignore them safely.
|
||||
|
||||
### Progress and Tool Hints
|
||||
|
||||
Progress and tool hints arrive through the normal `send(msg)` path. Check `msg.event` before rendering:
|
||||
|
||||
```python
|
||||
from nanobot.bus.outbound_events import ProgressEvent
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
event = msg.event
|
||||
|
||||
if isinstance(event, ProgressEvent) and event.tool_hint:
|
||||
# A short tool breadcrumb, e.g. read_file("config.json")
|
||||
await self._send_trace(msg.chat_id, msg.content, kind="tool")
|
||||
return
|
||||
|
||||
if isinstance(event, ProgressEvent):
|
||||
# Generic non-final status, e.g. "Thinking..." or "Running command..."
|
||||
await self._send_trace(msg.chat_id, msg.content, kind="progress")
|
||||
return
|
||||
|
||||
await self._send_message(msg.chat_id, msg.content, media=msg.media)
|
||||
```
|
||||
|
||||
Tool hints are off by default for most channels. Users can enable them globally or per channel:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"sendToolHints": true,
|
||||
"webhook": {
|
||||
"enabled": true,
|
||||
"sendToolHints": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Reasoning Blocks
|
||||
|
||||
Reasoning is delivered through dedicated optional hooks, not `send()`. Override `send_reasoning_delta()` and `send_reasoning_end()` if your platform can show model reasoning as a subdued/collapsible block. The default implementation is a no-op, so unsupported channels simply drop reasoning content.
|
||||
|
||||
```python
|
||||
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)
|
||||
self._reasoning_buffers: dict[str, str] = {}
|
||||
|
||||
async def send_reasoning_delta(
|
||||
self,
|
||||
chat_id: str,
|
||||
delta: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
stream_id: str | None = None,
|
||||
) -> None:
|
||||
buffer_key = stream_id or chat_id
|
||||
self._reasoning_buffers[buffer_key] = self._reasoning_buffers.get(buffer_key, "") + delta
|
||||
await self._update_reasoning_block(chat_id, self._reasoning_buffers[buffer_key], final=False)
|
||||
|
||||
async def send_reasoning_end(
|
||||
self,
|
||||
chat_id: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
stream_id: str | None = None,
|
||||
) -> None:
|
||||
buffer_key = stream_id or chat_id
|
||||
text = self._reasoning_buffers.pop(buffer_key, "")
|
||||
if text:
|
||||
await self._update_reasoning_block(chat_id, text, final=True)
|
||||
```
|
||||
|
||||
**Reasoning arguments:**
|
||||
|
||||
| Argument | Meaning |
|
||||
|------|---------|
|
||||
| `delta` | A reasoning/thinking chunk for `send_reasoning_delta()`. |
|
||||
| `stream_id` | Stable id for this assistant turn/segment. Use it to key buffers instead of only `chat_id`. |
|
||||
| `send_reasoning_end()` | The current reasoning block is complete. |
|
||||
|
||||
Reasoning visibility is controlled by `showReasoning` globally or per channel:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"showReasoning": true,
|
||||
"webhook": {
|
||||
"enabled": true,
|
||||
"showReasoning": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Recommended rendering:
|
||||
|
||||
- Render tool hints and progress as trace/status UI, not as normal assistant replies.
|
||||
- Render reasoning with lower visual emphasis and collapse it after completion when the platform supports that.
|
||||
- Keep reasoning separate from final answer text. A final answer still arrives through `send()` or `send_delta()`.
|
||||
|
||||
## Config
|
||||
|
||||
### Why Pydantic model is required
|
||||
|
||||
`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.
|
||||
|
||||
Channel runtimes use Pydantic config models by subclassing `Base` from `nanobot.config.schema`.
|
||||
|
||||
### 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.port
|
||||
token = self.config.token
|
||||
```
|
||||
|
||||
`allowFrom` is handled automatically by `_handle_message()` — you don't need to check it yourself.
|
||||
|
||||
`nanobot onboard` reads the descriptor without importing the runtime. Put writable defaults in `ChannelSetupSpec`:
|
||||
|
||||
```python
|
||||
setup=ChannelSetupSpec(
|
||||
fields={
|
||||
"port": ChannelFieldSpec(kind="int", default=9000),
|
||||
"allowFrom": ChannelFieldSpec(kind="list"),
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
String and secret fields default to `""`, list fields to `[]`, and boolean fields to `false` when no explicit default is declared. For non-setup or multi-instance persisted defaults, provide `ChannelManagementSpec.default_config` from a dependency-free package-local module.
|
||||
|
||||
## Naming Convention
|
||||
|
||||
| What | Format | Example |
|
||||
|------|--------|---------|
|
||||
| Package directory | `nanobot/channels/{name}` | `nanobot/channels/webhook` |
|
||||
| Manifest name | `{name}` | `webhook` |
|
||||
| Config section | `channels.{name}` | `channels.webhook` |
|
||||
| Runtime import | `nanobot.channels.{name}.runtime` | `nanobot.channels.webhook.runtime` |
|
||||
|
||||
## Local Development
|
||||
|
||||
```bash
|
||||
git clone https://github.com/HKUDS/nanobot.git
|
||||
cd nanobot
|
||||
python -m pip install -e .
|
||||
nanobot plugins list # should show the package as "webhook"
|
||||
nanobot gateway # test end-to-end
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
$ nanobot plugins list
|
||||
|
||||
Name Type Enabled
|
||||
discord channel no
|
||||
telegram channel yes
|
||||
webhook channel yes
|
||||
```
|
||||
@@ -1,580 +0,0 @@
|
||||
# Channel Plugin Guide
|
||||
|
||||
Build a custom nanobot channel in three steps: subclass, package, install.
|
||||
|
||||
> **Note:** We recommend developing channel plugins against a source checkout of nanobot (`python -m pip install -e .`) rather than a PyPI release, so you always have access to the latest base-channel features and APIs.
|
||||
|
||||
## 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
|
||||
|
||||
```text
|
||||
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 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 WebhookConfig().model_dump(by_alias=True)
|
||||
|
||||
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.port
|
||||
|
||||
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 — channel routing context such as message/thread ids
|
||||
msg.event — typed runtime event for progress/status messages
|
||||
"""
|
||||
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-ai", "aiohttp"]
|
||||
|
||||
[project.entry-points."nanobot.channels"]
|
||||
webhook = "nanobot_channel_webhook:WebhookChannel"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["nanobot_channel_webhook"]
|
||||
```
|
||||
|
||||
The key (`webhook`) becomes the config section name. The value points to your `BaseChannel` subclass.
|
||||
|
||||
### 3. Install & Configure
|
||||
|
||||
```bash
|
||||
python -m pip install -e .
|
||||
nanobot plugins list # verify the installed example plugin appears as "webhook"
|
||||
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. Raise when the transport does not accept it. |
|
||||
|
||||
#### Outbound delivery contract
|
||||
|
||||
A normal return from `send()` means either the visible payload was accepted by the
|
||||
platform transport/API, or the channel deliberately had nothing to deliver (for example,
|
||||
an empty progress event). Do not log and return when the client is disconnected, still
|
||||
starting, or the platform rejects the request. Raise an exception so `ChannelManager` can
|
||||
apply the shared retry policy.
|
||||
|
||||
`send()` may run as soon as `is_running` becomes true. If a channel sets `_running` before
|
||||
its transport is ready, it must keep raising until delivery can be attempted safely. Small
|
||||
platform-specific retries are fine, but the final failure must still reach the manager.
|
||||
|
||||
### Interactive Login
|
||||
|
||||
If your channel requires interactive authentication (e.g. QR code scan), override `login(force=False)`:
|
||||
|
||||
```python
|
||||
async def login(self, force: bool = False) -> bool:
|
||||
"""
|
||||
Perform channel-specific interactive login.
|
||||
|
||||
Args:
|
||||
force: If True, ignore existing credentials and re-authenticate.
|
||||
|
||||
Returns True if already authenticated or login succeeds.
|
||||
"""
|
||||
# For QR-code-based login:
|
||||
# 1. If force, clear saved credentials
|
||||
# 2. Check if already authenticated (load from disk/state)
|
||||
# 3. If not, show QR code and poll for confirmation
|
||||
# 4. Save token on success
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
Users trigger interactive login via:
|
||||
```bash
|
||||
nanobot channels login <channel_name>
|
||||
nanobot channels login <channel_name> --force # re-authenticate
|
||||
```
|
||||
|
||||
### 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. Automatically sets `_wants_stream` if `supports_streaming` is true. |
|
||||
| `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 the shared top-level `transcription` config (if configured). |
|
||||
| `supports_streaming` (property) | `True` when config has `"streaming": true` **and** subclass overrides `send_delta()`. |
|
||||
| `is_running` | Returns `self._running`. |
|
||||
| `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. |
|
||||
| `send_reasoning_delta(chat_id, delta, metadata?, *, stream_id?)` | Optional hook for streamed model reasoning/thinking content. Default is no-op. |
|
||||
| `send_reasoning_end(chat_id, metadata?, *, stream_id?)` | Optional hook marking the end of a reasoning block. Default is no-op. |
|
||||
| `send_reasoning(msg)` | Optional one-shot reasoning fallback. Default translates to `send_reasoning_delta()` + `send_reasoning_end()`. |
|
||||
|
||||
### Optional (streaming)
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `async send_delta(chat_id, delta, metadata?, *, stream_id?, stream_end=False, resuming=False)` | Override to receive streaming chunks. See [Streaming Support](#streaming-support) for details. |
|
||||
|
||||
### 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 # channel routing context, e.g. "message_id" for threading
|
||||
event: object | None # typed runtime/UI event; usually inspect with isinstance()
|
||||
```
|
||||
|
||||
Runtime/UI semantics live on `msg.event`. Plugin-authored outbound messages should use typed events instead of legacy metadata flags such as `_progress`, `_stream_delta`, `_stream_end`, `_reasoning_delta`, `_turn_end`, or `_goal_status`. nanobot still accepts those old flags as a compatibility bridge for existing in-process extensions, but new plugin code should not add fresh dependencies on them.
|
||||
|
||||
## Streaming Support
|
||||
|
||||
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.
|
||||
|
||||
### How It Works
|
||||
|
||||
When **both** conditions are met, the agent streams content through your channel:
|
||||
|
||||
1. Config has `"streaming": true`
|
||||
2. Your subclass overrides `send_delta()`
|
||||
|
||||
If either is missing, the agent falls back to the normal one-shot `send()` path.
|
||||
|
||||
### Implementing `send_delta`
|
||||
|
||||
Override `send_delta` to handle two types of calls:
|
||||
|
||||
```python
|
||||
async def send_delta(
|
||||
self,
|
||||
chat_id: str,
|
||||
delta: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
stream_id: str | None = None,
|
||||
stream_end: bool = False,
|
||||
resuming: bool = False,
|
||||
) -> None:
|
||||
buffer_key = stream_id or chat_id
|
||||
if stream_end:
|
||||
# Streaming finished — do final formatting, cleanup, etc.
|
||||
return
|
||||
|
||||
# Regular delta — append text, update the message on screen
|
||||
# delta contains a small chunk of text (a few tokens)
|
||||
```
|
||||
|
||||
Streaming state is passed through keyword-only arguments, not `_stream_delta` or `_stream_end` metadata flags. Use `stream_id` to key any per-stream buffers; fall back to `chat_id` when it is missing.
|
||||
|
||||
### Example: Webhook with Streaming
|
||||
|
||||
```python
|
||||
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)
|
||||
self._buffers: dict[str, str] = {}
|
||||
|
||||
async def send_delta(
|
||||
self,
|
||||
chat_id: str,
|
||||
delta: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
stream_id: str | None = None,
|
||||
stream_end: bool = False,
|
||||
resuming: bool = False,
|
||||
) -> None:
|
||||
buffer_key = stream_id or chat_id
|
||||
if stream_end:
|
||||
text = self._buffers.pop(buffer_key, "")
|
||||
# Final delivery — format and send the complete message
|
||||
await self._deliver(chat_id, text, final=True)
|
||||
return
|
||||
|
||||
self._buffers.setdefault(buffer_key, "")
|
||||
self._buffers[buffer_key] += delta
|
||||
# Incremental update — push partial text to the client
|
||||
await self._deliver(chat_id, self._buffers[buffer_key], final=False)
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
# Non-streaming path — unchanged
|
||||
await self._deliver(msg.chat_id, msg.content, final=True)
|
||||
```
|
||||
|
||||
### Config
|
||||
|
||||
Enable streaming per channel:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"webhook": {
|
||||
"enabled": true,
|
||||
"streaming": true,
|
||||
"allowFrom": ["*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When `streaming` is `false` (default) or omitted, only `send()` is called — no streaming overhead.
|
||||
|
||||
### BaseChannel Streaming API
|
||||
|
||||
| Method / Property | Description |
|
||||
|-------------------|-------------|
|
||||
| `async send_delta(chat_id, delta, metadata?, *, stream_id?, stream_end=False, resuming=False)` | Override to handle streaming chunks. No-op by default. |
|
||||
| `supports_streaming` (property) | Returns `True` when config has `streaming: true` **and** subclass overrides `send_delta`. |
|
||||
|
||||
## Progress, Tool Hints, and Reasoning
|
||||
|
||||
Besides normal assistant text, nanobot can emit low-emphasis trace blocks. These are intended for UI affordances like status rows, collapsible "used tools" groups, or reasoning/thinking blocks. Platforms that do not have a good place for them can ignore them safely.
|
||||
|
||||
### Progress and Tool Hints
|
||||
|
||||
Progress and tool hints arrive through the normal `send(msg)` path. Check `msg.event` before rendering:
|
||||
|
||||
```python
|
||||
from nanobot.bus.outbound_events import ProgressEvent
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
event = msg.event
|
||||
|
||||
if isinstance(event, ProgressEvent) and event.tool_hint:
|
||||
# A short tool breadcrumb, e.g. read_file("config.json")
|
||||
await self._send_trace(msg.chat_id, msg.content, kind="tool")
|
||||
return
|
||||
|
||||
if isinstance(event, ProgressEvent):
|
||||
# Generic non-final status, e.g. "Thinking..." or "Running command..."
|
||||
await self._send_trace(msg.chat_id, msg.content, kind="progress")
|
||||
return
|
||||
|
||||
await self._send_message(msg.chat_id, msg.content, media=msg.media)
|
||||
```
|
||||
|
||||
Tool hints are off by default for most channels. Users can enable them globally or per channel:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"sendToolHints": true,
|
||||
"webhook": {
|
||||
"enabled": true,
|
||||
"sendToolHints": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Reasoning Blocks
|
||||
|
||||
Reasoning is delivered through dedicated optional hooks, not `send()`. Override `send_reasoning_delta()` and `send_reasoning_end()` if your platform can show model reasoning as a subdued/collapsible block. The default implementation is a no-op, so unsupported channels simply drop reasoning content.
|
||||
|
||||
```python
|
||||
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)
|
||||
self._reasoning_buffers: dict[str, str] = {}
|
||||
|
||||
async def send_reasoning_delta(
|
||||
self,
|
||||
chat_id: str,
|
||||
delta: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
stream_id: str | None = None,
|
||||
) -> None:
|
||||
buffer_key = stream_id or chat_id
|
||||
self._reasoning_buffers[buffer_key] = self._reasoning_buffers.get(buffer_key, "") + delta
|
||||
await self._update_reasoning_block(chat_id, self._reasoning_buffers[buffer_key], final=False)
|
||||
|
||||
async def send_reasoning_end(
|
||||
self,
|
||||
chat_id: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
stream_id: str | None = None,
|
||||
) -> None:
|
||||
buffer_key = stream_id or chat_id
|
||||
text = self._reasoning_buffers.pop(buffer_key, "")
|
||||
if text:
|
||||
await self._update_reasoning_block(chat_id, text, final=True)
|
||||
```
|
||||
|
||||
**Reasoning arguments:**
|
||||
|
||||
| Argument | Meaning |
|
||||
|------|---------|
|
||||
| `delta` | A reasoning/thinking chunk for `send_reasoning_delta()`. |
|
||||
| `stream_id` | Stable id for this assistant turn/segment. Use it to key buffers instead of only `chat_id`. |
|
||||
| `send_reasoning_end()` | The current reasoning block is complete. |
|
||||
|
||||
Reasoning visibility is controlled by `showReasoning` globally or per channel:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"showReasoning": true,
|
||||
"webhook": {
|
||||
"enabled": true,
|
||||
"showReasoning": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Recommended rendering:
|
||||
|
||||
- Render tool hints and progress as trace/status UI, not as normal assistant replies.
|
||||
- Render reasoning with lower visual emphasis and collapse it after completion when the platform supports that.
|
||||
- Keep reasoning separate from final answer text. A final answer still arrives through `send()` or `send_delta()`.
|
||||
|
||||
## Config
|
||||
|
||||
### Why Pydantic model is required
|
||||
|
||||
`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.
|
||||
|
||||
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.port
|
||||
token = self.config.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 WebhookConfig().model_dump(by_alias=True)
|
||||
```
|
||||
|
||||
> **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.
|
||||
|
||||
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
|
||||
python -m pip install -e .
|
||||
nanobot plugins list # should show the installed example plugin as "webhook"
|
||||
nanobot gateway # test end-to-end
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
$ nanobot plugins list
|
||||
|
||||
Name Type Enabled
|
||||
discord channel no
|
||||
telegram channel yes
|
||||
webhook channel yes
|
||||
```
|
||||
+1
-1
@@ -16,7 +16,7 @@ a focused setup path for one platform, start with a guide:
|
||||
| Email | [Build an Email AI Agent with nanobot](./guides/email-ai-agent.md) |
|
||||
| Mattermost | [Build a Mattermost AI Agent with nanobot](./guides/mattermost-ai-agent.md) |
|
||||
|
||||
Want to build your own channel? See the [Channel Plugin Guide](./channel-plugin-guide.md).
|
||||
Want to build your own channel? See the [Channel Package Guide](./channel-package-guide.md).
|
||||
|
||||
Before configuring a chat app, make sure the local CLI path works:
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Chat channels module with plugin architecture."""
|
||||
"""Shared contracts for chat channels."""
|
||||
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.channels.manager import ChannelManager
|
||||
|
||||
__all__ = ["BaseChannel", "ChannelManager"]
|
||||
__all__ = ["BaseChannel"]
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Small constructors shared by declarative channel manifests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from nanobot.channels.contracts import ChannelFieldSpec, FieldKind, SetupRequirement
|
||||
|
||||
GROUP_POLICIES = frozenset({"mention", "open", "allowlist"})
|
||||
DIRECT_GROUP_POLICIES = frozenset({"mention", "open"})
|
||||
|
||||
|
||||
def field(
|
||||
kind: FieldKind = "string",
|
||||
*,
|
||||
choices: Iterable[str] = (),
|
||||
default: Any = None,
|
||||
writable: bool = True,
|
||||
snapshot: bool = True,
|
||||
) -> ChannelFieldSpec:
|
||||
return ChannelFieldSpec(
|
||||
kind=kind,
|
||||
choices=frozenset(choices),
|
||||
default=default,
|
||||
writable=writable,
|
||||
snapshot=snapshot,
|
||||
)
|
||||
|
||||
|
||||
def required(name: str) -> SetupRequirement:
|
||||
return SetupRequirement.field(name)
|
||||
|
||||
|
||||
def required_fields(*names: str) -> tuple[SetupRequirement, ...]:
|
||||
return tuple(required(name) for name in names)
|
||||
|
||||
|
||||
def one_of(*alternatives: tuple[str, ...]) -> SetupRequirement:
|
||||
return SetupRequirement.one_of(*alternatives)
|
||||
+15
-335
@@ -1,343 +1,23 @@
|
||||
"""Shared channel setup contract for configuration, display, and validation."""
|
||||
"""Resolve channel-owned setup contracts for settings consumers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
FieldKind = Literal["string", "secret", "list", "bool", "int", "enum"]
|
||||
RouteFieldType = str | tuple[str, set[str]]
|
||||
from nanobot.channels.contracts import ChannelSetupSpec
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.channels.plugin import ChannelPlugin
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChannelFieldSpec:
|
||||
"""One channel field exposed through the settings contract."""
|
||||
|
||||
kind: FieldKind = "string"
|
||||
choices: frozenset[str] = frozenset()
|
||||
writable: bool = True
|
||||
snapshot: bool = True
|
||||
|
||||
@property
|
||||
def route_type(self) -> RouteFieldType:
|
||||
if self.kind == "enum":
|
||||
return ("enum", set(self.choices))
|
||||
return self.kind
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SetupRequirement:
|
||||
"""A requirement satisfied by any one complete field group."""
|
||||
|
||||
alternatives: tuple[tuple[str, ...], ...]
|
||||
|
||||
def is_satisfied(self, values: Any) -> bool:
|
||||
return any(
|
||||
all(channel_value_present(channel_field_value(values, field)) for field in group)
|
||||
for group in self.alternatives
|
||||
)
|
||||
|
||||
@property
|
||||
def simple_field(self) -> str | None:
|
||||
if len(self.alternatives) == 1 and len(self.alternatives[0]) == 1:
|
||||
return self.alternatives[0][0]
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChannelSetupSpec:
|
||||
"""Save, display, and validation contract for one channel."""
|
||||
|
||||
fields: dict[str, ChannelFieldSpec]
|
||||
required: tuple[SetupRequirement, ...] = ()
|
||||
official_url: str | None = None
|
||||
|
||||
@property
|
||||
def secrets(self) -> frozenset[str]:
|
||||
return frozenset(name for name, field in self.fields.items() if field.kind == "secret")
|
||||
|
||||
@property
|
||||
def snapshot_fields(self) -> tuple[str, ...]:
|
||||
return tuple(name for name, field in self.fields.items() if field.snapshot)
|
||||
|
||||
@property
|
||||
def route_field_types(self) -> dict[str, RouteFieldType]:
|
||||
return {
|
||||
name: field.route_type
|
||||
for name, field in self.fields.items()
|
||||
if field.writable
|
||||
}
|
||||
|
||||
@property
|
||||
def simple_required_fields(self) -> tuple[str, ...]:
|
||||
return tuple(
|
||||
field
|
||||
for requirement in self.required
|
||||
if (field := requirement.simple_field) is not None
|
||||
)
|
||||
|
||||
def is_configured(self, values: Any) -> bool:
|
||||
return bool(self.required) and all(
|
||||
requirement.is_satisfied(values) for requirement in self.required
|
||||
)
|
||||
|
||||
|
||||
def _field(
|
||||
kind: FieldKind = "string",
|
||||
def channel_setup_spec(
|
||||
name: str,
|
||||
*,
|
||||
choices: set[str] | None = None,
|
||||
writable: bool = True,
|
||||
snapshot: bool = True,
|
||||
) -> ChannelFieldSpec:
|
||||
return ChannelFieldSpec(
|
||||
kind=kind,
|
||||
choices=frozenset(choices or ()),
|
||||
writable=writable,
|
||||
snapshot=snapshot,
|
||||
)
|
||||
plugin: ChannelPlugin | None = None,
|
||||
) -> ChannelSetupSpec | None:
|
||||
"""Return the setup contract declared by one channel descriptor."""
|
||||
if plugin is None:
|
||||
from nanobot.channels.registry import load_channel_plugin
|
||||
|
||||
|
||||
def _required(field: str) -> SetupRequirement:
|
||||
return SetupRequirement(((field,),))
|
||||
|
||||
|
||||
def _one_of(*alternatives: tuple[str, ...]) -> SetupRequirement:
|
||||
return SetupRequirement(alternatives)
|
||||
|
||||
|
||||
_GROUP_POLICIES = {"mention", "open", "allowlist"}
|
||||
_DIRECT_GROUP_POLICIES = {"mention", "open"}
|
||||
|
||||
CHANNEL_SETUP_SPECS: dict[str, ChannelSetupSpec] = {
|
||||
"websocket": ChannelSetupSpec(
|
||||
fields={},
|
||||
official_url="http://127.0.0.1:8765",
|
||||
),
|
||||
"telegram": ChannelSetupSpec(
|
||||
fields={
|
||||
"token": _field("secret"),
|
||||
"allowFrom": _field("list"),
|
||||
"groupPolicy": _field("enum", choices=_GROUP_POLICIES),
|
||||
},
|
||||
required=(_required("token"),),
|
||||
official_url="https://t.me/BotFather",
|
||||
),
|
||||
"slack": ChannelSetupSpec(
|
||||
fields={
|
||||
"appToken": _field("secret"),
|
||||
"botToken": _field("secret"),
|
||||
"groupPolicy": _field("enum", choices=_GROUP_POLICIES),
|
||||
},
|
||||
required=(_required("appToken"), _required("botToken")),
|
||||
official_url="https://api.slack.com/apps",
|
||||
),
|
||||
"discord": ChannelSetupSpec(
|
||||
fields={
|
||||
"token": _field("secret"),
|
||||
"allowFrom": _field("list", snapshot=False),
|
||||
"allowChannels": _field("list"),
|
||||
"groupPolicy": _field("enum", choices=_DIRECT_GROUP_POLICIES),
|
||||
},
|
||||
required=(_required("token"),),
|
||||
official_url="https://discord.com/developers/applications",
|
||||
),
|
||||
"email": ChannelSetupSpec(
|
||||
fields={
|
||||
"consentGranted": _field("bool"),
|
||||
"imapHost": _field(),
|
||||
"imapPort": _field("int"),
|
||||
"imapUsername": _field(),
|
||||
"imapPassword": _field("secret"),
|
||||
"smtpHost": _field(),
|
||||
"smtpPort": _field("int"),
|
||||
"smtpUsername": _field(),
|
||||
"smtpPassword": _field("secret"),
|
||||
"fromAddress": _field(),
|
||||
"pollIntervalSeconds": _field("int"),
|
||||
"allowFrom": _field("list"),
|
||||
"verifyDkim": _field("bool"),
|
||||
"verifySpf": _field("bool"),
|
||||
},
|
||||
required=tuple(
|
||||
_required(field)
|
||||
for field in (
|
||||
"consentGranted",
|
||||
"imapHost",
|
||||
"imapUsername",
|
||||
"imapPassword",
|
||||
"smtpHost",
|
||||
"smtpUsername",
|
||||
"smtpPassword",
|
||||
)
|
||||
),
|
||||
official_url="https://support.google.com/accounts/answer/185833",
|
||||
),
|
||||
"matrix": ChannelSetupSpec(
|
||||
fields={
|
||||
"homeserver": _field(),
|
||||
"userId": _field(),
|
||||
"password": _field("secret"),
|
||||
"accessToken": _field("secret"),
|
||||
"deviceId": _field(),
|
||||
"groupPolicy": _field("enum", choices=_GROUP_POLICIES),
|
||||
"allowFrom": _field("list", writable=False),
|
||||
},
|
||||
required=(
|
||||
_required("homeserver"),
|
||||
_required("userId"),
|
||||
_one_of(("password",), ("accessToken", "deviceId")),
|
||||
),
|
||||
official_url="https://matrix.org/ecosystem/clients/",
|
||||
),
|
||||
"mattermost": ChannelSetupSpec(
|
||||
fields={
|
||||
"serverUrl": _field(),
|
||||
"token": _field("secret"),
|
||||
"teamId": _field(),
|
||||
"groupPolicy": _field("enum", choices=_GROUP_POLICIES),
|
||||
"allowFrom": _field("list"),
|
||||
},
|
||||
required=(_required("serverUrl"), _required("token")),
|
||||
official_url="https://developers.mattermost.com/integrate/reference/bot-accounts/",
|
||||
),
|
||||
"whatsapp": ChannelSetupSpec(
|
||||
fields={
|
||||
"allowFrom": _field("list", snapshot=False),
|
||||
"groupPolicy": _field("enum", choices=_DIRECT_GROUP_POLICIES, snapshot=False),
|
||||
"databasePath": _field(writable=False, snapshot=False),
|
||||
},
|
||||
official_url="https://faq.whatsapp.com/",
|
||||
),
|
||||
"dingtalk": ChannelSetupSpec(
|
||||
fields={
|
||||
"clientId": _field(),
|
||||
"clientSecret": _field("secret"),
|
||||
"allowFrom": _field("list"),
|
||||
},
|
||||
required=(_required("clientId"), _required("clientSecret")),
|
||||
official_url="https://open.dingtalk.com/",
|
||||
),
|
||||
"wecom": ChannelSetupSpec(
|
||||
fields={
|
||||
"botId": _field(),
|
||||
"secret": _field("secret"),
|
||||
"allowFrom": _field("list"),
|
||||
},
|
||||
required=(_required("botId"), _required("secret")),
|
||||
official_url="https://developer.work.weixin.qq.com/",
|
||||
),
|
||||
"weixin": ChannelSetupSpec(
|
||||
fields={
|
||||
"token": _field("secret"),
|
||||
"allowFrom": _field("list"),
|
||||
},
|
||||
required=(_required("token"),),
|
||||
official_url="https://weixin.qq.com/",
|
||||
),
|
||||
"qq": ChannelSetupSpec(
|
||||
fields={
|
||||
"appId": _field(),
|
||||
"secret": _field("secret"),
|
||||
"allowFrom": _field("list"),
|
||||
"msgFormat": _field("enum", choices={"plain", "markdown"}),
|
||||
},
|
||||
required=(_required("appId"), _required("secret")),
|
||||
official_url="https://q.qq.com/",
|
||||
),
|
||||
"signal": ChannelSetupSpec(
|
||||
fields={
|
||||
"phoneNumber": _field(),
|
||||
"daemonHost": _field(),
|
||||
"daemonPort": _field("int"),
|
||||
"allowFrom": _field("list", snapshot=False),
|
||||
"dm.allowFrom": _field("list"),
|
||||
"group.allowFrom": _field("list"),
|
||||
},
|
||||
required=(_required("phoneNumber"),),
|
||||
official_url="https://github.com/bbernhard/signal-cli-rest-api",
|
||||
),
|
||||
"msteams": ChannelSetupSpec(
|
||||
fields={
|
||||
"appId": _field(),
|
||||
"appPassword": _field("secret"),
|
||||
"tenantId": _field(),
|
||||
"path": _field(),
|
||||
"allowFrom": _field("list"),
|
||||
},
|
||||
required=(_required("appId"), _required("appPassword")),
|
||||
official_url="https://dev.teams.microsoft.com/apps",
|
||||
),
|
||||
"napcat": ChannelSetupSpec(
|
||||
fields={
|
||||
"wsUrl": _field(),
|
||||
"accessToken": _field("secret"),
|
||||
"allowFrom": _field("list"),
|
||||
"groupPolicy": _field("enum", choices=_DIRECT_GROUP_POLICIES),
|
||||
},
|
||||
required=(_required("wsUrl"),),
|
||||
official_url="https://napneko.github.io/",
|
||||
),
|
||||
"feishu": ChannelSetupSpec(
|
||||
fields={
|
||||
"appId": _field(snapshot=False),
|
||||
"appSecret": _field("secret", snapshot=False),
|
||||
"domain": _field("enum", choices={"feishu", "lark"}, snapshot=False),
|
||||
"groupPolicy": _field(
|
||||
"enum", choices=_DIRECT_GROUP_POLICIES, snapshot=False
|
||||
),
|
||||
"allowFrom": _field("list", snapshot=False),
|
||||
"topicIsolation": _field("bool", snapshot=False),
|
||||
},
|
||||
required=(_required("appId"), _required("appSecret")),
|
||||
official_url="https://open.feishu.cn/app",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def channel_setup_spec(name: str) -> ChannelSetupSpec | None:
|
||||
return CHANNEL_SETUP_SPECS.get(name)
|
||||
|
||||
|
||||
def channel_field_value(values: Any, field_path: str) -> Any:
|
||||
current = values
|
||||
for part in field_path.split("."):
|
||||
candidates = (part, _camel_to_snake(part))
|
||||
if isinstance(current, dict):
|
||||
for candidate in candidates:
|
||||
if candidate in current:
|
||||
current = current[candidate]
|
||||
break
|
||||
else:
|
||||
return None
|
||||
continue
|
||||
for candidate in candidates:
|
||||
if hasattr(current, candidate):
|
||||
current = getattr(current, candidate)
|
||||
break
|
||||
else:
|
||||
return None
|
||||
return current
|
||||
|
||||
|
||||
def channel_value_present(value: Any) -> bool:
|
||||
return value not in (None, "", [], {})
|
||||
|
||||
|
||||
def stringify_channel_value(value: Any) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, list):
|
||||
return ", ".join(str(item) for item in value)
|
||||
return str(value)
|
||||
|
||||
|
||||
def _camel_to_snake(value: str) -> str:
|
||||
chars: list[str] = []
|
||||
for char in value:
|
||||
if char.isupper():
|
||||
if chars:
|
||||
chars.append("_")
|
||||
chars.append(char.lower())
|
||||
else:
|
||||
chars.append(char)
|
||||
return "".join(chars)
|
||||
plugin = load_channel_plugin(name)
|
||||
return plugin.setup
|
||||
|
||||
@@ -278,6 +278,16 @@ class BaseChannel(ABC):
|
||||
"""Return default config for onboard. Override in plugins to auto-populate config.json."""
|
||||
return {"enabled": False}
|
||||
|
||||
@classmethod
|
||||
def refresh_feature_metadata(
|
||||
cls,
|
||||
config_path: Path,
|
||||
*,
|
||||
instance_id: str = "default",
|
||||
) -> bool:
|
||||
"""Refresh persisted display metadata after an explicit settings action."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""Check if the channel is running."""
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Small contract shared by channel-owned interactive connection flows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
QueryParams = Mapping[str, list[str]]
|
||||
|
||||
|
||||
class ChannelConnectError(Exception):
|
||||
"""User-facing channel connection failure."""
|
||||
|
||||
def __init__(self, message: str, *, status: int = 400) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.status = status
|
||||
|
||||
|
||||
def query_first(query: QueryParams, key: str) -> str | None:
|
||||
values = query.get(key)
|
||||
return values[0] if values else None
|
||||
|
||||
|
||||
__all__ = ["ChannelConnectError", "QueryParams", "query_first"]
|
||||
@@ -0,0 +1,602 @@
|
||||
"""Stable contracts shared by channel runtimes and management surfaces."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Callable, Literal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.channels.plugin import ChannelPlugin
|
||||
|
||||
FieldKind = Literal["string", "secret", "list", "bool", "int", "enum"]
|
||||
RouteFieldType = str | tuple[str, set[str]]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ChannelValidationContext:
|
||||
"""Host policy passed to package-owned setup validators."""
|
||||
|
||||
allow_local_service_access: bool = False
|
||||
|
||||
|
||||
SetupValidator = Callable[[dict[str, Any], ChannelValidationContext], dict[str, Any]]
|
||||
DefaultConfigFactory = Callable[[], dict[str, Any]]
|
||||
InstanceSpecsFactory = Callable[..., Iterable["ChannelInstanceSpec"]]
|
||||
InstanceConfigUpdater = Callable[..., dict[str, Any]]
|
||||
RuntimeNameFactory = Callable[[str, str], str]
|
||||
FeatureInstancesFactory = Callable[..., list[dict[str, Any]] | None]
|
||||
LocalStatePresent = Callable[[Any], bool]
|
||||
|
||||
__all__ = [
|
||||
"ChannelActivation",
|
||||
"ChannelFieldSpec",
|
||||
"ChannelInstanceSpec",
|
||||
"ChannelManagementSpec",
|
||||
"ChannelSetupSpec",
|
||||
"ChannelValidationContext",
|
||||
"SetupRequirement",
|
||||
"channel_feature_instances",
|
||||
"channel_default_config",
|
||||
"channel_field_value",
|
||||
"channel_instance_config",
|
||||
"channel_instance_specs",
|
||||
"channel_local_state_present",
|
||||
"channel_runtime_name",
|
||||
"resolve_channel_action_target",
|
||||
"channel_set_config_enabled",
|
||||
"channel_update_instance_config",
|
||||
"channel_value_present",
|
||||
"refresh_channel_feature_metadata",
|
||||
"stringify_channel_value",
|
||||
]
|
||||
|
||||
|
||||
_MISSING = object()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChannelActivation:
|
||||
"""Normalized enablement state used before a channel runtime is imported.
|
||||
|
||||
Channel configuration may be a Pydantic model or persisted JSON, and a
|
||||
channel may expose independently enabled instances. Instance envelopes are
|
||||
opt-in so a channel can keep using an ``instances``
|
||||
field as ordinary channel-owned configuration.
|
||||
"""
|
||||
|
||||
enabled: bool | None = None
|
||||
instances: tuple["ChannelActivation", ...] | None = None
|
||||
|
||||
@classmethod
|
||||
def from_config(
|
||||
cls,
|
||||
section: Any,
|
||||
*,
|
||||
include_instances: bool = False,
|
||||
) -> "ChannelActivation":
|
||||
values = _config_mapping(section)
|
||||
if values is None:
|
||||
raw_enabled = getattr(section, "enabled", _MISSING)
|
||||
return cls(enabled=None if raw_enabled is _MISSING else bool(raw_enabled))
|
||||
|
||||
raw_enabled = values.get("enabled", _MISSING)
|
||||
raw_instances = values.get("instances", _MISSING) if include_instances else _MISSING
|
||||
instances = (
|
||||
tuple(
|
||||
cls.from_config(item, include_instances=True)
|
||||
for item in raw_instances
|
||||
if _config_mapping(item) is not None
|
||||
)
|
||||
if isinstance(raw_instances, list)
|
||||
else None
|
||||
)
|
||||
return cls(
|
||||
enabled=None if raw_enabled is _MISSING else bool(raw_enabled),
|
||||
instances=instances,
|
||||
)
|
||||
|
||||
def resolve(self, *, default: bool = False) -> bool:
|
||||
"""Return whether the section contains at least one enabled runtime."""
|
||||
inherited = default if self.enabled is None else self.enabled
|
||||
if self.instances is None:
|
||||
return inherited
|
||||
return any(instance.resolve(default=inherited) for instance in self.instances)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChannelFieldSpec:
|
||||
"""One channel field exposed through the settings contract."""
|
||||
|
||||
kind: FieldKind = "string"
|
||||
choices: frozenset[str] = frozenset()
|
||||
default: Any = None
|
||||
writable: bool = True
|
||||
snapshot: bool = True
|
||||
|
||||
@property
|
||||
def route_type(self) -> RouteFieldType:
|
||||
if self.kind == "enum":
|
||||
return ("enum", set(self.choices))
|
||||
return self.kind
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SetupRequirement:
|
||||
"""A requirement satisfied by any one complete field group."""
|
||||
|
||||
alternatives: tuple[tuple[str, ...], ...]
|
||||
|
||||
@classmethod
|
||||
def field(cls, name: str) -> "SetupRequirement":
|
||||
"""Require one field."""
|
||||
return cls(((name,),))
|
||||
|
||||
@classmethod
|
||||
def one_of(cls, *alternatives: tuple[str, ...]) -> "SetupRequirement":
|
||||
"""Require one complete alternative field group."""
|
||||
return cls(alternatives)
|
||||
|
||||
def is_satisfied(self, values: Any) -> bool:
|
||||
return any(
|
||||
all(channel_value_present(channel_field_value(values, field)) for field in group)
|
||||
for group in self.alternatives
|
||||
)
|
||||
|
||||
@property
|
||||
def simple_field(self) -> str | None:
|
||||
if len(self.alternatives) == 1 and len(self.alternatives[0]) == 1:
|
||||
return self.alternatives[0][0]
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChannelSetupSpec:
|
||||
"""Writable setup fields, requirements, and optional validation."""
|
||||
|
||||
fields: dict[str, ChannelFieldSpec]
|
||||
required: tuple[SetupRequirement, ...] = ()
|
||||
official_url: str | None = None
|
||||
validator: SetupValidator | None = None
|
||||
|
||||
@property
|
||||
def secrets(self) -> frozenset[str]:
|
||||
return frozenset(name for name, field in self.fields.items() if field.kind == "secret")
|
||||
|
||||
@property
|
||||
def snapshot_fields(self) -> tuple[str, ...]:
|
||||
return tuple(name for name, field in self.fields.items() if field.snapshot)
|
||||
|
||||
@property
|
||||
def route_field_types(self) -> dict[str, RouteFieldType]:
|
||||
return {
|
||||
name: field.route_type
|
||||
for name, field in self.fields.items()
|
||||
if field.writable
|
||||
}
|
||||
|
||||
@property
|
||||
def simple_required_fields(self) -> tuple[str, ...]:
|
||||
return tuple(
|
||||
field
|
||||
for requirement in self.required
|
||||
if (field := requirement.simple_field) is not None
|
||||
)
|
||||
|
||||
def is_configured(self, values: Any) -> bool:
|
||||
return bool(self.required) and all(
|
||||
requirement.is_satisfied(values) for requirement in self.required
|
||||
)
|
||||
|
||||
def to_public_dict(self, channel_name: str) -> dict[str, Any]:
|
||||
"""Serialize the writable setup contract for generic WebUI consumers."""
|
||||
simple_required = set(self.simple_required_fields)
|
||||
fields = []
|
||||
for name, field in self.fields.items():
|
||||
if not field.writable:
|
||||
continue
|
||||
public_field = {
|
||||
"key": f"channels.{channel_name}.{name}",
|
||||
"field": name,
|
||||
"kind": field.kind,
|
||||
"choices": sorted(field.choices),
|
||||
"required": name in simple_required,
|
||||
}
|
||||
if field.default is not None:
|
||||
public_field["default_value"] = stringify_channel_value(field.default)
|
||||
fields.append(public_field)
|
||||
payload: dict[str, Any] = {
|
||||
"fields": fields,
|
||||
}
|
||||
if self.official_url:
|
||||
payload["official_url"] = self.official_url
|
||||
return payload
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChannelInstanceSpec:
|
||||
"""One independently managed runtime instance."""
|
||||
|
||||
instance_id: str
|
||||
config: Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChannelManagementSpec:
|
||||
"""Dependency-free adapter for persisted channel state.
|
||||
|
||||
Runtime classes own network and message lifecycle only. A multi-instance
|
||||
channel supplies these callbacks from a module that can be imported without
|
||||
its optional platform SDK.
|
||||
"""
|
||||
|
||||
multi_instance: bool = False
|
||||
default_config: DefaultConfigFactory | None = None
|
||||
instance_specs: InstanceSpecsFactory | None = None
|
||||
update_instance_config: InstanceConfigUpdater | None = None
|
||||
runtime_name: RuntimeNameFactory | None = None
|
||||
feature_instances: FeatureInstancesFactory | None = None
|
||||
local_state_present: LocalStatePresent | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
multi_instance_callbacks = {
|
||||
"instance_specs": self.instance_specs,
|
||||
"update_instance_config": self.update_instance_config,
|
||||
"runtime_name": self.runtime_name,
|
||||
"feature_instances": self.feature_instances,
|
||||
}
|
||||
if not self.multi_instance:
|
||||
unexpected = [
|
||||
name for name, callback in multi_instance_callbacks.items() if callback is not None
|
||||
]
|
||||
if unexpected:
|
||||
raise ValueError(
|
||||
"single-instance channel management cannot define "
|
||||
+ ", ".join(unexpected)
|
||||
)
|
||||
if self.multi_instance and self.instance_specs is None:
|
||||
raise ValueError("multi-instance channel management requires instance_specs")
|
||||
if self.multi_instance and self.update_instance_config is None:
|
||||
raise ValueError("multi-instance channel management requires update_instance_config")
|
||||
|
||||
|
||||
def channel_default_config(plugin: ChannelPlugin) -> dict[str, Any]:
|
||||
from nanobot.config.loader import merge_missing_defaults
|
||||
|
||||
defaults: dict[str, Any] = {"enabled": plugin.default_enabled}
|
||||
if plugin.setup is not None:
|
||||
for name, field in plugin.setup.fields.items():
|
||||
value = field.default
|
||||
if value is None:
|
||||
value = {
|
||||
"string": "",
|
||||
"secret": "",
|
||||
"list": [],
|
||||
"bool": False,
|
||||
}.get(field.kind, _MISSING)
|
||||
if value is not _MISSING:
|
||||
_assign_channel_field(defaults, name, deepcopy(value))
|
||||
|
||||
factory = plugin.management.default_config
|
||||
if factory is None:
|
||||
return defaults
|
||||
values = factory()
|
||||
if not isinstance(values, dict):
|
||||
raise TypeError(f"ChannelPlugin.management.default_config for '{plugin.name}' must return a dict")
|
||||
return merge_missing_defaults(values, defaults)
|
||||
|
||||
|
||||
def _assign_channel_field(values: dict[str, Any], field: str, value: Any) -> None:
|
||||
target = values
|
||||
parts = field.split(".")
|
||||
for part in parts[:-1]:
|
||||
nested = target.get(part)
|
||||
if not isinstance(nested, dict):
|
||||
nested = {}
|
||||
target[part] = nested
|
||||
target = nested
|
||||
target[parts[-1]] = value
|
||||
|
||||
|
||||
def channel_local_state_present(plugin: ChannelPlugin, section: Any) -> bool:
|
||||
checker = plugin.management.local_state_present
|
||||
return bool(checker and checker(section))
|
||||
|
||||
|
||||
def channel_runtime_name(plugin: ChannelPlugin, instance_id: str = "default") -> str:
|
||||
factory = plugin.management.runtime_name
|
||||
if factory is None:
|
||||
if instance_id not in {"", "default"}:
|
||||
raise ValueError(f"{plugin.name} does not support multiple instances")
|
||||
runtime_name = plugin.name
|
||||
else:
|
||||
runtime_name = str(factory(plugin.name, instance_id))
|
||||
_validate_runtime_name(plugin, runtime_name)
|
||||
return runtime_name
|
||||
|
||||
|
||||
def channel_instance_specs(
|
||||
plugin: ChannelPlugin,
|
||||
section: Any,
|
||||
*,
|
||||
enabled_only: bool = True,
|
||||
) -> list[ChannelInstanceSpec]:
|
||||
"""Expand persisted config through the dependency-free management adapter."""
|
||||
factory = plugin.management.instance_specs
|
||||
if factory is None:
|
||||
activation = ChannelActivation.from_config(section)
|
||||
raw_specs: Iterable[ChannelInstanceSpec] = (
|
||||
[]
|
||||
if enabled_only and not activation.resolve(default=plugin.default_enabled)
|
||||
else [ChannelInstanceSpec(instance_id="default", config=section)]
|
||||
)
|
||||
else:
|
||||
raw_specs = factory(section, enabled_only=enabled_only)
|
||||
if not isinstance(raw_specs, Iterable):
|
||||
raise TypeError(
|
||||
f"ChannelPlugin.management.instance_specs for '{plugin.name}' must return an iterable"
|
||||
)
|
||||
specs = list(raw_specs)
|
||||
|
||||
instance_ids: set[str] = set()
|
||||
runtime_names: set[str] = set()
|
||||
for spec in specs:
|
||||
if not isinstance(spec, ChannelInstanceSpec):
|
||||
raise TypeError(
|
||||
f"ChannelPlugin.management.instance_specs for '{plugin.name}' returned an invalid item"
|
||||
)
|
||||
if not isinstance(spec.instance_id, str) or not spec.instance_id.strip():
|
||||
raise ValueError(
|
||||
f"ChannelPlugin.management.instance_specs for '{plugin.name}' returned an empty instance id"
|
||||
)
|
||||
if spec.instance_id in instance_ids:
|
||||
raise ValueError(
|
||||
f"ChannelPlugin.management.instance_specs for '{plugin.name}' returned duplicate instance id "
|
||||
f"'{spec.instance_id}'"
|
||||
)
|
||||
runtime_name = channel_runtime_name(plugin, spec.instance_id)
|
||||
if runtime_name in runtime_names:
|
||||
raise ValueError(
|
||||
f"ChannelPlugin.management.instance_specs for '{plugin.name}' returned duplicate runtime name "
|
||||
f"'{runtime_name}'"
|
||||
)
|
||||
instance_ids.add(spec.instance_id)
|
||||
runtime_names.add(runtime_name)
|
||||
return specs
|
||||
|
||||
|
||||
def resolve_channel_action_target(
|
||||
requested_instance_id: str | None,
|
||||
) -> str:
|
||||
"""Resolve a feature action to an explicit or default instance."""
|
||||
return (requested_instance_id or "").strip() or "default"
|
||||
|
||||
|
||||
def channel_instance_config(
|
||||
plugin: ChannelPlugin,
|
||||
section: Any,
|
||||
*,
|
||||
instance_id: str = "default",
|
||||
) -> dict[str, Any]:
|
||||
"""Return editable config for one instance."""
|
||||
selected = next(
|
||||
(
|
||||
spec
|
||||
for spec in channel_instance_specs(plugin, section, enabled_only=False)
|
||||
if spec.instance_id == instance_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if selected is None:
|
||||
return {}
|
||||
config = selected.config
|
||||
if hasattr(config, "model_dump"):
|
||||
return dict(config.model_dump(mode="json", by_alias=True))
|
||||
return dict(config) if isinstance(config, dict) else {}
|
||||
|
||||
|
||||
def channel_update_instance_config(
|
||||
plugin: ChannelPlugin,
|
||||
section: Any,
|
||||
values: dict[str, Any],
|
||||
*,
|
||||
instance_id: str = "default",
|
||||
) -> dict[str, Any]:
|
||||
updater = plugin.management.update_instance_config
|
||||
if updater is None:
|
||||
if instance_id not in {"", "default"}:
|
||||
raise ValueError(f"{plugin.name} does not support multiple instances")
|
||||
return values
|
||||
return updater(section, values, instance_id=instance_id)
|
||||
|
||||
|
||||
def channel_set_config_enabled(
|
||||
plugin: ChannelPlugin,
|
||||
section: Any,
|
||||
enabled: bool,
|
||||
*,
|
||||
instance_id: str = "default",
|
||||
) -> dict[str, Any]:
|
||||
"""Toggle one instance while preserving channel-owned config shape."""
|
||||
from nanobot.config.loader import merge_missing_defaults
|
||||
|
||||
values = channel_instance_config(plugin, section, instance_id=instance_id)
|
||||
values = merge_missing_defaults(values, channel_default_config(plugin))
|
||||
values["enabled"] = enabled
|
||||
return channel_update_instance_config(
|
||||
plugin,
|
||||
section,
|
||||
values,
|
||||
instance_id=instance_id,
|
||||
)
|
||||
|
||||
|
||||
def channel_feature_instances(
|
||||
plugin: ChannelPlugin,
|
||||
section: Any,
|
||||
*,
|
||||
setup_spec: ChannelSetupSpec | None = None,
|
||||
) -> list[dict[str, Any]] | None:
|
||||
factory = plugin.management.feature_instances
|
||||
overrides = factory(section, setup_spec=setup_spec) if factory is not None else None
|
||||
if overrides is None and not plugin.management.multi_instance:
|
||||
return None
|
||||
if overrides is not None and (
|
||||
not isinstance(overrides, list)
|
||||
or any(not isinstance(instance, dict) for instance in overrides)
|
||||
):
|
||||
raise TypeError(
|
||||
f"ChannelPlugin.management.feature_instances for '{plugin.name}' "
|
||||
"must return a list of dicts or None"
|
||||
)
|
||||
|
||||
enabled_ids = {
|
||||
spec.instance_id for spec in channel_instance_specs(plugin, section, enabled_only=True)
|
||||
}
|
||||
|
||||
instances = [
|
||||
_channel_feature_instance(
|
||||
plugin.name,
|
||||
spec,
|
||||
setup_spec,
|
||||
enabled=spec.instance_id in enabled_ids,
|
||||
)
|
||||
for spec in channel_instance_specs(plugin, section, enabled_only=False)
|
||||
]
|
||||
if overrides is None:
|
||||
return instances
|
||||
|
||||
by_id = {instance["id"]: instance for instance in instances}
|
||||
seen: set[str] = set()
|
||||
for override in overrides:
|
||||
instance_id = override.get("id")
|
||||
if not isinstance(instance_id, str) or instance_id not in by_id:
|
||||
raise ValueError(
|
||||
f"ChannelPlugin.management.feature_instances for '{plugin.name}' "
|
||||
"returned unknown instance id "
|
||||
f"'{instance_id}'"
|
||||
)
|
||||
if instance_id in seen:
|
||||
raise ValueError(
|
||||
f"ChannelPlugin.management.feature_instances for '{plugin.name}' "
|
||||
"returned duplicate instance id "
|
||||
f"'{instance_id}'"
|
||||
)
|
||||
seen.add(instance_id)
|
||||
for field in ("name", "display_name", "avatar_url"):
|
||||
if field in override:
|
||||
by_id[instance_id][field] = str(override[field] or "")
|
||||
return instances
|
||||
|
||||
|
||||
def refresh_channel_feature_metadata(
|
||||
channel_cls: type[Any],
|
||||
config_path: Path,
|
||||
*,
|
||||
instance_id: str = "default",
|
||||
) -> bool:
|
||||
return bool(channel_cls.refresh_feature_metadata(config_path, instance_id=instance_id))
|
||||
|
||||
|
||||
def _validate_runtime_name(plugin: ChannelPlugin, runtime_name: Any) -> None:
|
||||
channel_name = str(plugin.name).strip()
|
||||
if not channel_name:
|
||||
raise ValueError("ChannelPlugin.name must not be empty")
|
||||
if not isinstance(runtime_name, str) or not runtime_name.strip():
|
||||
raise ValueError(f"ChannelPlugin.management for '{plugin.name}' returned an empty runtime name")
|
||||
if runtime_name != channel_name and not runtime_name.startswith(f"{channel_name}."):
|
||||
raise ValueError(
|
||||
f"ChannelPlugin.management runtime name '{runtime_name}' must be scoped under "
|
||||
f"'{channel_name}'"
|
||||
)
|
||||
|
||||
|
||||
def channel_field_value(values: Any, field_path: str) -> Any:
|
||||
current = values
|
||||
for part in field_path.split("."):
|
||||
candidates = (part, _camel_to_snake(part))
|
||||
if isinstance(current, dict):
|
||||
for candidate in candidates:
|
||||
if candidate in current:
|
||||
current = current[candidate]
|
||||
break
|
||||
else:
|
||||
return None
|
||||
continue
|
||||
for candidate in candidates:
|
||||
if hasattr(current, candidate):
|
||||
current = getattr(current, candidate)
|
||||
break
|
||||
else:
|
||||
return None
|
||||
return current
|
||||
|
||||
|
||||
def channel_value_present(value: Any) -> bool:
|
||||
return value not in (None, "", [], {})
|
||||
|
||||
|
||||
def stringify_channel_value(value: Any) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, list):
|
||||
return ", ".join(str(item) for item in value)
|
||||
return str(value)
|
||||
|
||||
|
||||
def _channel_feature_instance(
|
||||
channel_name: str,
|
||||
instance: ChannelInstanceSpec,
|
||||
setup_spec: ChannelSetupSpec | None,
|
||||
*,
|
||||
enabled: bool,
|
||||
) -> dict[str, Any]:
|
||||
config = instance.config
|
||||
name = str(channel_field_value(config, "name") or instance.instance_id).strip()
|
||||
display_name = str(channel_field_value(config, "displayName") or name).strip()
|
||||
avatar_url = str(channel_field_value(config, "avatarUrl") or "").strip()
|
||||
config_values: dict[str, str] = {}
|
||||
configured_fields: list[str] = []
|
||||
setup_fields = setup_spec.fields.items() if setup_spec else ()
|
||||
for field_name, field_spec in setup_fields:
|
||||
if not field_spec.writable:
|
||||
continue
|
||||
value = channel_field_value(config, field_name)
|
||||
if not channel_value_present(value):
|
||||
continue
|
||||
key = f"channels.{channel_name}.{field_name}"
|
||||
configured_fields.append(key)
|
||||
if field_spec.kind != "secret":
|
||||
config_values[key] = stringify_channel_value(value)
|
||||
|
||||
return {
|
||||
"id": instance.instance_id,
|
||||
"name": name,
|
||||
"display_name": display_name,
|
||||
"avatar_url": avatar_url,
|
||||
"enabled": enabled,
|
||||
"configured": bool(setup_spec and setup_spec.is_configured(config)),
|
||||
"config_values": config_values,
|
||||
"configured_fields": configured_fields,
|
||||
}
|
||||
|
||||
|
||||
def _config_mapping(value: Any) -> dict[str, Any] | None:
|
||||
if hasattr(value, "model_dump"):
|
||||
dumped = value.model_dump(mode="json", by_alias=True)
|
||||
return dumped if isinstance(dumped, dict) else None
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def _camel_to_snake(value: str) -> str:
|
||||
chars: list[str] = []
|
||||
for char in value:
|
||||
if char.isupper():
|
||||
if chars:
|
||||
chars.append("_")
|
||||
chars.append(char.lower())
|
||||
else:
|
||||
chars.append(char)
|
||||
return "".join(chars)
|
||||
@@ -0,0 +1 @@
|
||||
"""DingTalk channel package."""
|
||||
@@ -0,0 +1,24 @@
|
||||
"""DingTalk management contract."""
|
||||
|
||||
from nanobot.channels._manifest import field, required_fields
|
||||
from nanobot.channels.contracts import ChannelSetupSpec
|
||||
from nanobot.channels.plugin import ChannelPlugin
|
||||
|
||||
SETUP_SPEC = ChannelSetupSpec(
|
||||
fields={
|
||||
"clientId": field(),
|
||||
"clientSecret": field("secret"),
|
||||
"allowFrom": field("list"),
|
||||
},
|
||||
required=required_fields("clientId", "clientSecret"),
|
||||
official_url="https://open.dingtalk.com/",
|
||||
)
|
||||
|
||||
PLUGIN = ChannelPlugin(
|
||||
name="dingtalk",
|
||||
display_name="DingTalk",
|
||||
runtime=f"{__package__}.runtime:DingTalkChannel",
|
||||
setup=SETUP_SPEC,
|
||||
dependencies=("dingtalk-stream>=0.24.0,<1.0.0",),
|
||||
webui="webui/index.ts",
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the DingTalk channel package."""
|
||||
+6
-2
@@ -17,10 +17,14 @@ except ImportError:
|
||||
if not DINGTALK_AVAILABLE:
|
||||
pytest.skip("DingTalk dependencies not installed (dingtalk-stream)", allow_module_level=True)
|
||||
|
||||
import nanobot.channels.dingtalk as dingtalk_module
|
||||
import nanobot.channels.dingtalk.runtime as dingtalk_module
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.dingtalk import DingTalkChannel, DingTalkConfig, NanobotDingTalkHandler
|
||||
from nanobot.channels.dingtalk.runtime import (
|
||||
DingTalkChannel,
|
||||
DingTalkConfig,
|
||||
NanobotDingTalkHandler,
|
||||
)
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.channels.validation import validate_channel_config
|
||||
from nanobot.config.loader import save_config
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
|
||||
def test_validate_manual_channel_returns_configured(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(
|
||||
Config.model_validate(
|
||||
{
|
||||
"channels": {
|
||||
"dingtalk": {
|
||||
"clientId": "ding-client",
|
||||
"clientSecret": "ding-secret",
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
config_path,
|
||||
)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
result = validate_channel_config("dingtalk", {})
|
||||
|
||||
assert result["status"] == "configured"
|
||||
assert result["can_enable"] is True
|
||||
assert any(check["status"] == "skipped" for check in result["checks"])
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ChannelUiContribution } from "@/channel-plugins/types";
|
||||
import { chatAppGuideUrl } from "@/components/settings/channels/catalog";
|
||||
|
||||
export default {
|
||||
presentation: {
|
||||
displayName: "DingTalk",
|
||||
initials: "DT",
|
||||
color: "#1677FF",
|
||||
logoUrl:
|
||||
"https://img.alicdn.com/imgextra/i3/O1CN01WMvMRG1ks3Ixc9x1v_!!6000000004738-55-tps-32-32.svg",
|
||||
setup: {
|
||||
mode: "credentials",
|
||||
docsUrl: chatAppGuideUrl("dingtalk"),
|
||||
fields: [
|
||||
{ key: "channels.dingtalk.clientId" },
|
||||
{ key: "channels.dingtalk.clientSecret" },
|
||||
{ key: "channels.dingtalk.allowFrom" },
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies ChannelUiContribution;
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"description": "Use nanobot from DingTalk groups.",
|
||||
"requirements": "DingTalk app credentials and gateway",
|
||||
"setup": {
|
||||
"docsLabel": "Open DingTalk setup",
|
||||
"officialLabel": "Open DingTalk console",
|
||||
"tryIt": "Send a test message from the DingTalk group where the app is installed.",
|
||||
"summary": "DingTalk needs app credentials from Stream mode.",
|
||||
"steps": [
|
||||
"Create or choose a DingTalk app with Stream mode enabled.",
|
||||
"Add Client ID and Client Secret.",
|
||||
"Save and enable DingTalk, then send a test message."
|
||||
],
|
||||
"fields": {
|
||||
"clientId": {
|
||||
"label": "Client ID",
|
||||
"placeholder": "DingTalk client ID",
|
||||
"help": "Copy it from DingTalk app credentials."
|
||||
},
|
||||
"clientSecret": {
|
||||
"label": "Client Secret",
|
||||
"placeholder": "••••••",
|
||||
"help": "Copy it from the same DingTalk app credentials page."
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "Allowed users",
|
||||
"placeholder": "User IDs, comma separated"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"description": "Usa nanobot desde grupos de DingTalk.",
|
||||
"requirements": "Credenciales de la app de DingTalk y gateway",
|
||||
"setup": {
|
||||
"docsLabel": "Abrir guía de DingTalk",
|
||||
"officialLabel": "Abrir consola de DingTalk",
|
||||
"tryIt": "Envía un mensaje de prueba desde el grupo de DingTalk donde está instalada la app.",
|
||||
"summary": "DingTalk necesita credenciales de una app en modo Stream.",
|
||||
"steps": [
|
||||
"Crea o elige una app de DingTalk con el modo Stream activado.",
|
||||
"Añade el Client ID y el Client Secret.",
|
||||
"Guarda y activa DingTalk; después envía un mensaje de prueba."
|
||||
],
|
||||
"fields": {
|
||||
"clientId": {
|
||||
"label": "Client ID",
|
||||
"placeholder": "Client ID de DingTalk",
|
||||
"help": "Cópialo de las credenciales de la app de DingTalk."
|
||||
},
|
||||
"clientSecret": {
|
||||
"label": "Client Secret",
|
||||
"placeholder": "••••••",
|
||||
"help": "Cópialo de la misma página de credenciales."
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "Usuarios permitidos",
|
||||
"placeholder": "ID de usuario separados por comas"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"description": "Utilisez nanobot depuis les groupes DingTalk.",
|
||||
"requirements": "Identifiants d’application DingTalk et passerelle",
|
||||
"setup": {
|
||||
"docsLabel": "Ouvrir le guide DingTalk",
|
||||
"officialLabel": "Ouvrir la console DingTalk",
|
||||
"tryIt": "Envoyez un message test dans le groupe DingTalk où l’application est installée.",
|
||||
"summary": "DingTalk nécessite les identifiants d’une application en mode Stream.",
|
||||
"steps": [
|
||||
"Créez ou choisissez une application DingTalk avec le mode Stream activé.",
|
||||
"Ajoutez le Client ID et le Client Secret.",
|
||||
"Enregistrez et activez DingTalk, puis envoyez un message test."
|
||||
],
|
||||
"fields": {
|
||||
"clientId": {
|
||||
"label": "Client ID",
|
||||
"placeholder": "Client ID DingTalk",
|
||||
"help": "Copiez-le depuis les identifiants de l’application DingTalk."
|
||||
},
|
||||
"clientSecret": {
|
||||
"label": "Client Secret",
|
||||
"placeholder": "••••••",
|
||||
"help": "Copiez-le depuis la même page d’identifiants DingTalk."
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "Utilisateurs autorisés",
|
||||
"placeholder": "ID utilisateur séparés par des virgules"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"description": "Gunakan nanobot dari grup DingTalk.",
|
||||
"requirements": "Kredensial aplikasi DingTalk dan gateway",
|
||||
"setup": {
|
||||
"docsLabel": "Buka panduan DingTalk",
|
||||
"officialLabel": "Buka konsol DingTalk",
|
||||
"tryIt": "Kirim pesan uji dari grup DingTalk tempat aplikasi dipasang.",
|
||||
"summary": "DingTalk memerlukan kredensial aplikasi dari mode Stream.",
|
||||
"steps": [
|
||||
"Buat atau pilih aplikasi DingTalk dengan mode Stream aktif.",
|
||||
"Tambahkan Client ID dan Client Secret.",
|
||||
"Simpan dan aktifkan DingTalk, lalu kirim pesan uji."
|
||||
],
|
||||
"fields": {
|
||||
"clientId": {
|
||||
"label": "Client ID",
|
||||
"placeholder": "Client ID DingTalk",
|
||||
"help": "Salin dari kredensial aplikasi DingTalk."
|
||||
},
|
||||
"clientSecret": {
|
||||
"label": "Client Secret",
|
||||
"placeholder": "••••••",
|
||||
"help": "Salin dari halaman kredensial yang sama."
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "Pengguna yang diizinkan",
|
||||
"placeholder": "ID pengguna, dipisahkan koma"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"description": "DingTalk グループから nanobot を利用します。",
|
||||
"requirements": "DingTalk アプリの認証情報とゲートウェイ",
|
||||
"setup": {
|
||||
"docsLabel": "DingTalk 設定ガイドを開く",
|
||||
"officialLabel": "DingTalk コンソールを開く",
|
||||
"tryIt": "アプリをインストールした DingTalk グループからテストメッセージを送信します。",
|
||||
"summary": "DingTalk には Stream モードのアプリ認証情報が必要です。",
|
||||
"steps": [
|
||||
"Stream モードを有効にした DingTalk アプリを作成または選択します。",
|
||||
"Client ID と Client Secret を追加します。",
|
||||
"保存して DingTalk を有効にし、テストメッセージを送信します。"
|
||||
],
|
||||
"fields": {
|
||||
"clientId": {
|
||||
"label": "Client ID",
|
||||
"placeholder": "DingTalk Client ID",
|
||||
"help": "DingTalk アプリの認証情報からコピーします。"
|
||||
},
|
||||
"clientSecret": {
|
||||
"label": "Client Secret",
|
||||
"placeholder": "••••••",
|
||||
"help": "同じ認証情報ページからコピーします。"
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "許可するユーザー",
|
||||
"placeholder": "ユーザー ID(カンマ区切り)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"description": "DingTalk 그룹에서 nanobot을 사용합니다.",
|
||||
"requirements": "DingTalk 앱 자격 증명 및 게이트웨이",
|
||||
"setup": {
|
||||
"docsLabel": "DingTalk 설정 가이드 열기",
|
||||
"officialLabel": "DingTalk 콘솔 열기",
|
||||
"tryIt": "앱이 설치된 DingTalk 그룹에서 테스트 메시지를 보내세요.",
|
||||
"summary": "DingTalk에는 Stream 모드 앱 자격 증명이 필요합니다.",
|
||||
"steps": [
|
||||
"Stream 모드가 활성화된 DingTalk 앱을 만들거나 선택하세요.",
|
||||
"Client ID와 Client Secret을 추가하세요.",
|
||||
"저장하고 DingTalk을 활성화한 다음 테스트 메시지를 보내세요."
|
||||
],
|
||||
"fields": {
|
||||
"clientId": {
|
||||
"label": "Client ID",
|
||||
"placeholder": "DingTalk Client ID",
|
||||
"help": "DingTalk 앱 자격 증명에서 복사하세요."
|
||||
},
|
||||
"clientSecret": {
|
||||
"label": "Client Secret",
|
||||
"placeholder": "••••••",
|
||||
"help": "같은 자격 증명 페이지에서 복사하세요."
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "허용된 사용자",
|
||||
"placeholder": "사용자 ID, 쉼표로 구분"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"description": "Use o nanobot em grupos do DingTalk.",
|
||||
"requirements": "Credenciais do app DingTalk e gateway",
|
||||
"setup": {
|
||||
"docsLabel": "Abrir guia do DingTalk",
|
||||
"officialLabel": "Abrir console do DingTalk",
|
||||
"tryIt": "Envie uma mensagem de teste no grupo do DingTalk onde o app está instalado.",
|
||||
"summary": "O DingTalk precisa das credenciais de um app no modo Stream.",
|
||||
"steps": [
|
||||
"Crie ou escolha um app do DingTalk com o modo Stream ativado.",
|
||||
"Adicione o Client ID e o Client Secret.",
|
||||
"Salve e ative o DingTalk; depois, envie uma mensagem de teste."
|
||||
],
|
||||
"fields": {
|
||||
"clientId": {
|
||||
"label": "Client ID",
|
||||
"placeholder": "Client ID do DingTalk",
|
||||
"help": "Copie das credenciais do app DingTalk."
|
||||
},
|
||||
"clientSecret": {
|
||||
"label": "Client Secret",
|
||||
"placeholder": "••••••",
|
||||
"help": "Copie da mesma página de credenciais."
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "Usuários permitidos",
|
||||
"placeholder": "IDs de usuário separados por vírgulas"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"description": "Sử dụng nanobot trong các nhóm DingTalk.",
|
||||
"requirements": "Thông tin xác thực ứng dụng DingTalk và gateway",
|
||||
"setup": {
|
||||
"docsLabel": "Mở hướng dẫn DingTalk",
|
||||
"officialLabel": "Mở bảng điều khiển DingTalk",
|
||||
"tryIt": "Gửi tin nhắn thử từ nhóm DingTalk đã cài ứng dụng.",
|
||||
"summary": "DingTalk cần thông tin xác thực ứng dụng ở chế độ Stream.",
|
||||
"steps": [
|
||||
"Tạo hoặc chọn ứng dụng DingTalk đã bật chế độ Stream.",
|
||||
"Thêm Client ID và Client Secret.",
|
||||
"Lưu và bật DingTalk, sau đó gửi tin nhắn thử."
|
||||
],
|
||||
"fields": {
|
||||
"clientId": {
|
||||
"label": "Client ID",
|
||||
"placeholder": "Client ID DingTalk",
|
||||
"help": "Sao chép từ thông tin xác thực ứng dụng DingTalk."
|
||||
},
|
||||
"clientSecret": {
|
||||
"label": "Client Secret",
|
||||
"placeholder": "••••••",
|
||||
"help": "Sao chép từ cùng trang thông tin xác thực."
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "Người dùng được phép",
|
||||
"placeholder": "ID người dùng, phân tách bằng dấu phẩy"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"displayName": "钉钉",
|
||||
"description": "在钉钉群中使用 nanobot。",
|
||||
"requirements": "钉钉应用凭据和网关",
|
||||
"setup": {
|
||||
"docsLabel": "打开钉钉配置指南",
|
||||
"officialLabel": "打开钉钉开发者后台",
|
||||
"tryIt": "在已安装应用的钉钉群中发送一条测试消息。",
|
||||
"summary": "钉钉需要 Stream 模式的应用凭据。",
|
||||
"steps": [
|
||||
"创建或选择一个已启用 Stream 模式的钉钉应用。",
|
||||
"填写 Client ID 和 Client Secret。",
|
||||
"保存并启用钉钉,然后发送一条测试消息。"
|
||||
],
|
||||
"fields": {
|
||||
"clientId": {
|
||||
"label": "Client ID",
|
||||
"placeholder": "钉钉 Client ID",
|
||||
"help": "从钉钉应用凭据页面复制。"
|
||||
},
|
||||
"clientSecret": {
|
||||
"label": "Client Secret",
|
||||
"placeholder": "••••••",
|
||||
"help": "从同一个钉钉应用凭据页面复制。"
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "允许的用户",
|
||||
"placeholder": "用户 ID,用逗号分隔"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"displayName": "釘釘",
|
||||
"description": "在釘釘群組中使用 nanobot。",
|
||||
"requirements": "釘釘應用程式憑證和閘道",
|
||||
"setup": {
|
||||
"docsLabel": "開啟釘釘設定指南",
|
||||
"officialLabel": "開啟釘釘開發者後台",
|
||||
"tryIt": "在已安裝應用程式的釘釘群組中傳送一則測試訊息。",
|
||||
"summary": "釘釘需要 Stream 模式的應用程式憑證。",
|
||||
"steps": [
|
||||
"建立或選擇一個已啟用 Stream 模式的釘釘應用程式。",
|
||||
"填入 Client ID 和 Client Secret。",
|
||||
"儲存並啟用釘釘,然後傳送一則測試訊息。"
|
||||
],
|
||||
"fields": {
|
||||
"clientId": {
|
||||
"label": "Client ID",
|
||||
"placeholder": "釘釘 Client ID",
|
||||
"help": "從釘釘應用程式憑證頁面複製。"
|
||||
},
|
||||
"clientSecret": {
|
||||
"label": "Client Secret",
|
||||
"placeholder": "••••••",
|
||||
"help": "從同一個釘釘應用程式憑證頁面複製。"
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "允許的使用者",
|
||||
"placeholder": "使用者 ID,以逗號分隔"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""Discord channel package."""
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Discord management contract."""
|
||||
|
||||
from nanobot.channels._manifest import DIRECT_GROUP_POLICIES, field, required
|
||||
from nanobot.channels.contracts import ChannelSetupSpec
|
||||
from nanobot.channels.discord.validation import validate
|
||||
from nanobot.channels.plugin import ChannelPlugin
|
||||
|
||||
SETUP_SPEC = ChannelSetupSpec(
|
||||
fields={
|
||||
"token": field("secret"),
|
||||
"allowFrom": field("list", snapshot=False),
|
||||
"allowChannels": field("list"),
|
||||
"groupPolicy": field("enum", choices=DIRECT_GROUP_POLICIES, default="mention"),
|
||||
},
|
||||
required=(required("token"),),
|
||||
official_url="https://discord.com/developers/applications",
|
||||
validator=validate,
|
||||
)
|
||||
|
||||
PLUGIN = ChannelPlugin(
|
||||
name="discord",
|
||||
display_name="Discord",
|
||||
runtime=f"{__package__}.runtime:DiscordChannel",
|
||||
setup=SETUP_SPEC,
|
||||
dependencies=("discord.py>=2.5.2,<3.0.0",),
|
||||
webui="webui/index.ts",
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the Discord channel package."""
|
||||
+14
-12
@@ -1,3 +1,5 @@
|
||||
# ruff: noqa: E402
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
@@ -12,7 +14,7 @@ import discord
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.outbound_events import ProgressEvent
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.discord import (
|
||||
from nanobot.channels.discord.runtime import (
|
||||
MAX_MESSAGE_LEN,
|
||||
DiscordBotClient,
|
||||
DiscordChannel,
|
||||
@@ -230,7 +232,7 @@ async def test_start_returns_when_discord_dependency_missing(monkeypatch) -> Non
|
||||
DiscordConfig(enabled=True, token="token", allow_from=["*"]),
|
||||
MessageBus(),
|
||||
)
|
||||
monkeypatch.setattr("nanobot.channels.discord.DISCORD_AVAILABLE", False)
|
||||
monkeypatch.setattr("nanobot.channels.discord.runtime.DISCORD_AVAILABLE", False)
|
||||
|
||||
await channel.start()
|
||||
|
||||
@@ -249,7 +251,7 @@ async def test_start_handles_client_construction_failure(monkeypatch) -> None:
|
||||
def _boom(owner, *, intents, proxy=None, proxy_auth=None):
|
||||
raise RuntimeError("bad client")
|
||||
|
||||
monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _boom)
|
||||
monkeypatch.setattr("nanobot.channels.discord.runtime.DiscordBotClient", _boom)
|
||||
|
||||
await channel.start()
|
||||
|
||||
@@ -267,7 +269,7 @@ async def test_start_handles_client_start_failure(monkeypatch) -> None:
|
||||
|
||||
_FakeDiscordClient.instances.clear()
|
||||
_FakeDiscordClient.start_error = RuntimeError("connect failed")
|
||||
monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _FakeDiscordClient)
|
||||
monkeypatch.setattr("nanobot.channels.discord.runtime.DiscordBotClient", _FakeDiscordClient)
|
||||
|
||||
await channel.start()
|
||||
|
||||
@@ -620,7 +622,7 @@ async def test_on_message_downloads_attachments(tmp_path, monkeypatch) -> None:
|
||||
handled.append(kwargs)
|
||||
|
||||
channel._handle_message = capture_handle # type: ignore[method-assign]
|
||||
monkeypatch.setattr("nanobot.channels.discord.get_media_dir", lambda _name: tmp_path)
|
||||
monkeypatch.setattr("nanobot.channels.discord.runtime.get_media_dir", lambda _name: tmp_path)
|
||||
|
||||
await channel._on_message(
|
||||
_make_message(
|
||||
@@ -644,7 +646,7 @@ async def test_on_message_marks_failed_attachment_download(tmp_path, monkeypatch
|
||||
handled.append(kwargs)
|
||||
|
||||
channel._handle_message = capture_handle # type: ignore[method-assign]
|
||||
monkeypatch.setattr("nanobot.channels.discord.get_media_dir", lambda _name: tmp_path)
|
||||
monkeypatch.setattr("nanobot.channels.discord.runtime.get_media_dir", lambda _name: tmp_path)
|
||||
|
||||
await channel._on_message(
|
||||
_make_message(
|
||||
@@ -741,7 +743,7 @@ async def test_send_delta_streams_by_editing_message(monkeypatch) -> None:
|
||||
client.channels[123] = target
|
||||
|
||||
times = iter([1.0, 3.0, 5.0])
|
||||
monkeypatch.setattr("nanobot.channels.discord.time.monotonic", lambda: next(times, 5.0))
|
||||
monkeypatch.setattr("nanobot.channels.discord.runtime.time.monotonic", lambda: next(times, 5.0))
|
||||
|
||||
await owner.send_delta("123", "hel", stream_id="s1")
|
||||
await owner.send_delta("123", "lo", stream_id="s1")
|
||||
@@ -768,7 +770,7 @@ async def test_send_delta_stream_end_splits_oversized_reply(monkeypatch) -> None
|
||||
assert len(chunks) == 2
|
||||
|
||||
times = iter([1.0, 3.0])
|
||||
monkeypatch.setattr("nanobot.channels.discord.time.monotonic", lambda: next(times, 3.0))
|
||||
monkeypatch.setattr("nanobot.channels.discord.runtime.time.monotonic", lambda: next(times, 3.0))
|
||||
|
||||
await owner.send_delta("123", prefix, stream_id="s1")
|
||||
await owner.send_delta("123", suffix, stream_id="s1")
|
||||
@@ -1213,7 +1215,7 @@ async def test_start_passes_proxy_to_client(monkeypatch) -> None:
|
||||
),
|
||||
MessageBus(),
|
||||
)
|
||||
monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _FakeDiscordClient)
|
||||
monkeypatch.setattr("nanobot.channels.discord.runtime.DiscordBotClient", _FakeDiscordClient)
|
||||
|
||||
await channel.start()
|
||||
|
||||
@@ -1238,7 +1240,7 @@ async def test_start_passes_proxy_auth_when_credentials_provided(monkeypatch) ->
|
||||
),
|
||||
MessageBus(),
|
||||
)
|
||||
monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _FakeDiscordClient)
|
||||
monkeypatch.setattr("nanobot.channels.discord.runtime.DiscordBotClient", _FakeDiscordClient)
|
||||
|
||||
await channel.start()
|
||||
|
||||
@@ -1264,7 +1266,7 @@ async def test_start_no_proxy_auth_when_only_username(monkeypatch) -> None:
|
||||
),
|
||||
MessageBus(),
|
||||
)
|
||||
monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _FakeDiscordClient)
|
||||
monkeypatch.setattr("nanobot.channels.discord.runtime.DiscordBotClient", _FakeDiscordClient)
|
||||
|
||||
await channel.start()
|
||||
|
||||
@@ -1285,7 +1287,7 @@ async def test_start_no_proxy_auth_when_only_password(monkeypatch) -> None:
|
||||
),
|
||||
MessageBus(),
|
||||
)
|
||||
monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _FakeDiscordClient)
|
||||
monkeypatch.setattr("nanobot.channels.discord.runtime.DiscordBotClient", _FakeDiscordClient)
|
||||
|
||||
await channel.start()
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Discord setup validation owned by the channel package."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from nanobot.channels.contracts import ChannelValidationContext
|
||||
from nanobot.channels.validation import (
|
||||
check,
|
||||
http_get,
|
||||
payload,
|
||||
required_checks,
|
||||
status_from_checks,
|
||||
string_value,
|
||||
)
|
||||
|
||||
|
||||
def validate(values: dict[str, Any], _context: ChannelValidationContext) -> dict[str, Any]:
|
||||
checks, missing = required_checks("discord", values)
|
||||
token = string_value(values.get("token"))
|
||||
if token:
|
||||
try:
|
||||
data = http_get(
|
||||
"https://discord.com/api/v10/users/@me",
|
||||
headers={"Authorization": f"Bot {token}"},
|
||||
)
|
||||
bot_id = str(data.get("id") or "")
|
||||
checks.append(check("bot_token", "Bot token", "pass", "Discord accepted the bot token."))
|
||||
identity = {
|
||||
"name": data.get("global_name") or data.get("username"),
|
||||
"account": bot_id,
|
||||
}
|
||||
if bot_id:
|
||||
checks.append(
|
||||
check(
|
||||
"invite",
|
||||
"Server invite",
|
||||
"pass",
|
||||
"Use this generated OAuth URL to invite the bot.",
|
||||
action_url=(
|
||||
"https://discord.com/oauth2/authorize"
|
||||
f"?client_id={bot_id}&scope=bot%20applications.commands"
|
||||
),
|
||||
)
|
||||
)
|
||||
return payload(
|
||||
"discord",
|
||||
"connected",
|
||||
checks,
|
||||
identity=identity,
|
||||
missing_fields=missing,
|
||||
)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
checks.append(
|
||||
check(
|
||||
"bot_token",
|
||||
"Bot token",
|
||||
"fail",
|
||||
f"Discord rejected the token: HTTP {exc.response.status_code}",
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
checks.append(
|
||||
check("bot_token", "Bot token", "warn", f"Could not reach Discord now: {exc}")
|
||||
)
|
||||
return status_from_checks("discord", checks, missing)
|
||||
|
||||
|
||||
__all__ = ["validate"]
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ChannelUiContribution } from "@/channel-plugins/types";
|
||||
import { chatAppGuideUrl } from "@/components/settings/channels/catalog";
|
||||
|
||||
export default {
|
||||
presentation: {
|
||||
displayName: "Discord",
|
||||
initials: "DC",
|
||||
color: "#5865F2",
|
||||
logoUrl: "https://discord.com/favicon.ico",
|
||||
setup: {
|
||||
mode: "credentials",
|
||||
docsUrl: chatAppGuideUrl("discord"),
|
||||
fields: [
|
||||
{ key: "channels.discord.token" },
|
||||
{ key: "channels.discord.allowChannels" },
|
||||
{ key: "channels.discord.groupPolicy" },
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies ChannelUiContribution;
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"description": "Use nanobot from Discord servers and DMs.",
|
||||
"requirements": "Discord bot token, permissions, gateway",
|
||||
"setup": {
|
||||
"docsLabel": "Open Discord setup",
|
||||
"officialLabel": "Open Discord portal",
|
||||
"tryIt": "Mention the bot in a server or send it a direct message.",
|
||||
"summary": "Enable turns on Discord support. Discord still needs a bot token and server permissions.",
|
||||
"steps": [
|
||||
"Create a bot in Discord Developer Portal and copy its token.",
|
||||
"Invite the bot to your server with message read/send and slash command permissions.",
|
||||
"Save and enable Discord, then mention the bot or send a direct message."
|
||||
],
|
||||
"fields": {
|
||||
"token": {
|
||||
"label": "Bot token",
|
||||
"placeholder": "Discord bot token",
|
||||
"help": "Create it from the Bot page in Discord Developer Portal."
|
||||
},
|
||||
"allowChannels": {
|
||||
"label": "Allowed channels",
|
||||
"placeholder": "Channel IDs, comma separated",
|
||||
"help": "Leave empty to allow any channel the bot can read."
|
||||
},
|
||||
"groupPolicy": {
|
||||
"label": "Group behavior",
|
||||
"choices": {
|
||||
"mention": "Mention only",
|
||||
"open": "All messages",
|
||||
"allowlist": "Allowlist"
|
||||
}
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "Allowed users",
|
||||
"placeholder": "User IDs, comma separated"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"description": "Usa nanobot en servidores y mensajes directos de Discord.",
|
||||
"requirements": "Token del bot de Discord, permisos y gateway",
|
||||
"setup": {
|
||||
"docsLabel": "Abrir guía de Discord",
|
||||
"officialLabel": "Abrir portal de Discord",
|
||||
"tryIt": "Menciona al bot en un servidor o envíale un mensaje directo.",
|
||||
"summary": "Activar habilita Discord. Aún necesitas el token del bot y permisos del servidor.",
|
||||
"steps": [
|
||||
"Crea un bot en Discord Developer Portal y copia su token.",
|
||||
"Invítalo al servidor con permisos para leer/enviar mensajes y usar comandos slash.",
|
||||
"Guarda y activa Discord; después menciona al bot o envíale un mensaje directo."
|
||||
],
|
||||
"fields": {
|
||||
"token": {
|
||||
"label": "Token del bot",
|
||||
"placeholder": "Token del bot de Discord",
|
||||
"help": "Créalo desde la página Bot de Discord Developer Portal."
|
||||
},
|
||||
"allowChannels": {
|
||||
"label": "Canales permitidos",
|
||||
"placeholder": "ID de canal separados por comas",
|
||||
"help": "Déjalo vacío para permitir cualquier canal que el bot pueda leer."
|
||||
},
|
||||
"groupPolicy": {
|
||||
"label": "Comportamiento en grupos",
|
||||
"choices": {
|
||||
"mention": "Solo menciones",
|
||||
"open": "Todos los mensajes",
|
||||
"allowlist": "Lista permitida"
|
||||
}
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "Usuarios permitidos",
|
||||
"placeholder": "ID de usuario separados por comas"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"description": "Utilisez nanobot sur les serveurs Discord et en messages privés.",
|
||||
"requirements": "Jeton du bot Discord, permissions et passerelle",
|
||||
"setup": {
|
||||
"docsLabel": "Ouvrir le guide Discord",
|
||||
"officialLabel": "Ouvrir le portail Discord",
|
||||
"tryIt": "Mentionnez le bot sur un serveur ou envoyez-lui un message privé.",
|
||||
"summary": "L’activation ouvre la prise en charge de Discord. Un jeton de bot et des permissions serveur restent nécessaires.",
|
||||
"steps": [
|
||||
"Créez un bot dans le portail développeur Discord et copiez son jeton.",
|
||||
"Invitez-le sur votre serveur avec les permissions de lecture, d’envoi et de commandes slash.",
|
||||
"Enregistrez et activez Discord, puis mentionnez le bot ou envoyez-lui un message privé."
|
||||
],
|
||||
"fields": {
|
||||
"token": {
|
||||
"label": "Jeton du bot",
|
||||
"placeholder": "Jeton du bot Discord",
|
||||
"help": "Créez-le depuis la page Bot du portail développeur Discord."
|
||||
},
|
||||
"allowChannels": {
|
||||
"label": "Salons autorisés",
|
||||
"placeholder": "ID de salon séparés par des virgules",
|
||||
"help": "Laissez vide pour autoriser tous les salons lisibles par le bot."
|
||||
},
|
||||
"groupPolicy": {
|
||||
"label": "Comportement en groupe",
|
||||
"choices": {
|
||||
"mention": "Mentions uniquement",
|
||||
"open": "Tous les messages",
|
||||
"allowlist": "Liste d’autorisation"
|
||||
}
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "Utilisateurs autorisés",
|
||||
"placeholder": "ID utilisateur séparés par des virgules"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"description": "Gunakan nanobot dari server dan DM Discord.",
|
||||
"requirements": "Token bot Discord, izin, dan gateway",
|
||||
"setup": {
|
||||
"docsLabel": "Buka panduan Discord",
|
||||
"officialLabel": "Buka portal Discord",
|
||||
"tryIt": "Sebut bot di server atau kirim pesan langsung.",
|
||||
"summary": "Mengaktifkan akan menyalakan dukungan Discord. Token bot dan izin server tetap diperlukan.",
|
||||
"steps": [
|
||||
"Buat bot di Discord Developer Portal dan salin tokennya.",
|
||||
"Undang bot ke server dengan izin baca/kirim pesan dan perintah slash.",
|
||||
"Simpan dan aktifkan Discord, lalu sebut bot atau kirim DM."
|
||||
],
|
||||
"fields": {
|
||||
"token": {
|
||||
"label": "Token bot",
|
||||
"placeholder": "Token bot Discord",
|
||||
"help": "Buat dari halaman Bot di Discord Developer Portal."
|
||||
},
|
||||
"allowChannels": {
|
||||
"label": "Channel yang diizinkan",
|
||||
"placeholder": "ID channel, dipisahkan koma",
|
||||
"help": "Kosongkan untuk mengizinkan semua channel yang dapat dibaca bot."
|
||||
},
|
||||
"groupPolicy": {
|
||||
"label": "Perilaku grup",
|
||||
"choices": {
|
||||
"mention": "Hanya sebutan",
|
||||
"open": "Semua pesan",
|
||||
"allowlist": "Daftar izin"
|
||||
}
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "Pengguna yang diizinkan",
|
||||
"placeholder": "ID pengguna, dipisahkan koma"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"description": "Discord サーバーと DM から nanobot を利用します。",
|
||||
"requirements": "Discord ボットトークン、権限、ゲートウェイ",
|
||||
"setup": {
|
||||
"docsLabel": "Discord 設定ガイドを開く",
|
||||
"officialLabel": "Discord ポータルを開く",
|
||||
"tryIt": "サーバーでボットをメンションするか、DM を送信します。",
|
||||
"summary": "有効化すると Discord 対応がオンになります。ボットトークンとサーバー権限が必要です。",
|
||||
"steps": [
|
||||
"Discord Developer Portal でボットを作成し、トークンをコピーします。",
|
||||
"メッセージの読み書きとスラッシュコマンド権限を付けてサーバーに招待します。",
|
||||
"保存して Discord を有効にし、メンションまたは DM を送信します。"
|
||||
],
|
||||
"fields": {
|
||||
"token": {
|
||||
"label": "ボットトークン",
|
||||
"placeholder": "Discord ボットトークン",
|
||||
"help": "Discord Developer Portal の Bot ページで作成します。"
|
||||
},
|
||||
"allowChannels": {
|
||||
"label": "許可するチャンネル",
|
||||
"placeholder": "チャンネル ID(カンマ区切り)",
|
||||
"help": "空欄の場合、ボットが読めるすべてのチャンネルを許可します。"
|
||||
},
|
||||
"groupPolicy": {
|
||||
"label": "グループでの動作",
|
||||
"choices": {
|
||||
"mention": "メンションのみ",
|
||||
"open": "すべてのメッセージ",
|
||||
"allowlist": "許可リスト"
|
||||
}
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "許可するユーザー",
|
||||
"placeholder": "ユーザー ID(カンマ区切り)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"description": "Discord 서버와 DM에서 nanobot을 사용합니다.",
|
||||
"requirements": "Discord 봇 토큰, 권한 및 게이트웨이",
|
||||
"setup": {
|
||||
"docsLabel": "Discord 설정 가이드 열기",
|
||||
"officialLabel": "Discord 포털 열기",
|
||||
"tryIt": "서버에서 봇을 멘션하거나 DM을 보내세요.",
|
||||
"summary": "활성화하면 Discord 지원이 켜집니다. 봇 토큰과 서버 권한이 필요합니다.",
|
||||
"steps": [
|
||||
"Discord Developer Portal에서 봇을 만들고 토큰을 복사하세요.",
|
||||
"메시지 읽기/보내기 및 슬래시 명령 권한으로 서버에 초대하세요.",
|
||||
"저장하고 Discord를 활성화한 다음 봇을 멘션하거나 DM을 보내세요."
|
||||
],
|
||||
"fields": {
|
||||
"token": {
|
||||
"label": "봇 토큰",
|
||||
"placeholder": "Discord 봇 토큰",
|
||||
"help": "Discord Developer Portal의 Bot 페이지에서 생성하세요."
|
||||
},
|
||||
"allowChannels": {
|
||||
"label": "허용된 채널",
|
||||
"placeholder": "채널 ID, 쉼표로 구분",
|
||||
"help": "비워 두면 봇이 읽을 수 있는 모든 채널을 허용합니다."
|
||||
},
|
||||
"groupPolicy": {
|
||||
"label": "그룹 동작",
|
||||
"choices": {
|
||||
"mention": "멘션만",
|
||||
"open": "모든 메시지",
|
||||
"allowlist": "허용 목록"
|
||||
}
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "허용된 사용자",
|
||||
"placeholder": "사용자 ID, 쉼표로 구분"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"description": "Use o nanobot em servidores e DMs do Discord.",
|
||||
"requirements": "Token do bot Discord, permissões e gateway",
|
||||
"setup": {
|
||||
"docsLabel": "Abrir guia do Discord",
|
||||
"officialLabel": "Abrir portal do Discord",
|
||||
"tryIt": "Mencione o bot em um servidor ou envie uma mensagem direta.",
|
||||
"summary": "Ativar liga o suporte ao Discord. O token do bot e as permissões do servidor ainda são necessários.",
|
||||
"steps": [
|
||||
"Crie um bot no Discord Developer Portal e copie o token.",
|
||||
"Convide-o para o servidor com permissões de leitura/envio e comandos slash.",
|
||||
"Salve e ative o Discord; depois, mencione o bot ou envie uma DM."
|
||||
],
|
||||
"fields": {
|
||||
"token": {
|
||||
"label": "Token do bot",
|
||||
"placeholder": "Token do bot Discord",
|
||||
"help": "Crie-o na página Bot do Discord Developer Portal."
|
||||
},
|
||||
"allowChannels": {
|
||||
"label": "Canais permitidos",
|
||||
"placeholder": "IDs de canal separados por vírgulas",
|
||||
"help": "Deixe vazio para permitir qualquer canal que o bot consiga ler."
|
||||
},
|
||||
"groupPolicy": {
|
||||
"label": "Comportamento em grupos",
|
||||
"choices": {
|
||||
"mention": "Somente menções",
|
||||
"open": "Todas as mensagens",
|
||||
"allowlist": "Lista de permissão"
|
||||
}
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "Usuários permitidos",
|
||||
"placeholder": "IDs de usuário separados por vírgulas"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"description": "Sử dụng nanobot trong máy chủ và tin nhắn riêng Discord.",
|
||||
"requirements": "Token bot Discord, quyền và gateway",
|
||||
"setup": {
|
||||
"docsLabel": "Mở hướng dẫn Discord",
|
||||
"officialLabel": "Mở cổng Discord",
|
||||
"tryIt": "Nhắc bot trong máy chủ hoặc gửi tin nhắn riêng.",
|
||||
"summary": "Bật sẽ kích hoạt hỗ trợ Discord. Bạn vẫn cần token bot và quyền trên máy chủ.",
|
||||
"steps": [
|
||||
"Tạo bot trong Discord Developer Portal và sao chép token.",
|
||||
"Mời bot vào máy chủ với quyền đọc/gửi tin nhắn và lệnh slash.",
|
||||
"Lưu và bật Discord, sau đó nhắc bot hoặc gửi tin nhắn riêng."
|
||||
],
|
||||
"fields": {
|
||||
"token": {
|
||||
"label": "Token bot",
|
||||
"placeholder": "Token bot Discord",
|
||||
"help": "Tạo từ trang Bot trong Discord Developer Portal."
|
||||
},
|
||||
"allowChannels": {
|
||||
"label": "Kênh được phép",
|
||||
"placeholder": "ID kênh, phân tách bằng dấu phẩy",
|
||||
"help": "Để trống để cho phép mọi kênh bot có thể đọc."
|
||||
},
|
||||
"groupPolicy": {
|
||||
"label": "Hành vi trong nhóm",
|
||||
"choices": {
|
||||
"mention": "Chỉ khi được nhắc",
|
||||
"open": "Mọi tin nhắn",
|
||||
"allowlist": "Danh sách cho phép"
|
||||
}
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "Người dùng được phép",
|
||||
"placeholder": "ID người dùng, phân tách bằng dấu phẩy"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"description": "在 Discord 服务器和私信中使用 nanobot。",
|
||||
"requirements": "Discord 机器人令牌、权限和网关",
|
||||
"setup": {
|
||||
"docsLabel": "打开 Discord 配置指南",
|
||||
"officialLabel": "打开 Discord 开发者后台",
|
||||
"tryIt": "在服务器中提及机器人,或向它发送私信。",
|
||||
"summary": "启用只会打开 Discord 支持;还需要机器人令牌和服务器权限。",
|
||||
"steps": [
|
||||
"在 Discord Developer Portal 中创建机器人并复制令牌。",
|
||||
"将机器人邀请到服务器,并授予读取/发送消息及斜杠命令权限。",
|
||||
"保存并启用 Discord,然后提及机器人或发送私信。"
|
||||
],
|
||||
"fields": {
|
||||
"token": {
|
||||
"label": "机器人令牌",
|
||||
"placeholder": "Discord 机器人令牌",
|
||||
"help": "从 Discord Developer Portal 的 Bot 页面创建。"
|
||||
},
|
||||
"allowChannels": {
|
||||
"label": "允许的频道",
|
||||
"placeholder": "频道 ID,用逗号分隔",
|
||||
"help": "留空则允许机器人可读取的所有频道。"
|
||||
},
|
||||
"groupPolicy": {
|
||||
"label": "群组行为",
|
||||
"choices": {
|
||||
"mention": "仅提及时",
|
||||
"open": "所有消息",
|
||||
"allowlist": "白名单"
|
||||
}
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "允许的用户",
|
||||
"placeholder": "用户 ID,用逗号分隔"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"description": "在 Discord 伺服器和私訊中使用 nanobot。",
|
||||
"requirements": "Discord 機器人權杖、權限和閘道",
|
||||
"setup": {
|
||||
"docsLabel": "開啟 Discord 設定指南",
|
||||
"officialLabel": "開啟 Discord 開發者後台",
|
||||
"tryIt": "在伺服器中提及機器人,或向它傳送私訊。",
|
||||
"summary": "啟用只會開啟 Discord 支援;還需要機器人權杖和伺服器權限。",
|
||||
"steps": [
|
||||
"在 Discord Developer Portal 中建立機器人並複製權杖。",
|
||||
"將機器人邀請到伺服器,並授予讀取/傳送訊息及斜線指令權限。",
|
||||
"儲存並啟用 Discord,然後提及機器人或傳送私訊。"
|
||||
],
|
||||
"fields": {
|
||||
"token": {
|
||||
"label": "機器人權杖",
|
||||
"placeholder": "Discord 機器人權杖",
|
||||
"help": "從 Discord Developer Portal 的 Bot 頁面建立。"
|
||||
},
|
||||
"allowChannels": {
|
||||
"label": "允許的頻道",
|
||||
"placeholder": "頻道 ID,以逗號分隔",
|
||||
"help": "留空則允許機器人可讀取的所有頻道。"
|
||||
},
|
||||
"groupPolicy": {
|
||||
"label": "群組行為",
|
||||
"choices": {
|
||||
"mention": "僅提及時",
|
||||
"open": "所有訊息",
|
||||
"allowlist": "允許清單"
|
||||
}
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "允許的使用者",
|
||||
"placeholder": "使用者 ID,以逗號分隔"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""Email channel package."""
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Email management contract."""
|
||||
|
||||
from nanobot.channels._manifest import field, required_fields
|
||||
from nanobot.channels.contracts import ChannelSetupSpec
|
||||
from nanobot.channels.email.validation import validate
|
||||
from nanobot.channels.plugin import ChannelPlugin
|
||||
|
||||
SETUP_SPEC = ChannelSetupSpec(
|
||||
fields={
|
||||
"consentGranted": field("bool", default=False),
|
||||
"imapHost": field(),
|
||||
"imapPort": field("int", default=993),
|
||||
"imapUsername": field(),
|
||||
"imapPassword": field("secret"),
|
||||
"smtpHost": field(),
|
||||
"smtpPort": field("int", default=587),
|
||||
"smtpUsername": field(),
|
||||
"smtpPassword": field("secret"),
|
||||
"fromAddress": field(),
|
||||
"pollIntervalSeconds": field("int", default=30),
|
||||
"allowFrom": field("list"),
|
||||
"verifyDkim": field("bool", default=True),
|
||||
"verifySpf": field("bool", default=True),
|
||||
},
|
||||
required=required_fields(
|
||||
"consentGranted",
|
||||
"imapHost",
|
||||
"imapUsername",
|
||||
"imapPassword",
|
||||
"smtpHost",
|
||||
"smtpUsername",
|
||||
"smtpPassword",
|
||||
),
|
||||
official_url="https://support.google.com/accounts/answer/185833",
|
||||
validator=validate,
|
||||
)
|
||||
|
||||
PLUGIN = ChannelPlugin(
|
||||
name="email",
|
||||
display_name="Email",
|
||||
runtime=f"{__package__}.runtime:EmailChannel",
|
||||
setup=SETUP_SPEC,
|
||||
webui="webui/index.ts",
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the email channel package."""
|
||||
+48
-48
@@ -8,7 +8,7 @@ import pytest
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.outbound_events import ProgressEvent
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.email import EmailChannel, EmailConfig
|
||||
from nanobot.channels.email.runtime import EmailChannel, EmailConfig
|
||||
|
||||
|
||||
def _make_config(**overrides) -> EmailConfig:
|
||||
@@ -77,7 +77,7 @@ def test_fetch_new_messages_parses_unseen_and_marks_seen(monkeypatch) -> None:
|
||||
return "BYE", [b""]
|
||||
|
||||
fake = FakeIMAP()
|
||||
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
|
||||
channel = EmailChannel(_make_config(), MessageBus())
|
||||
items, skipped_uids = channel._fetch_new_messages()
|
||||
@@ -117,7 +117,7 @@ def test_fetch_new_messages_returns_accepted_and_skipped_uids(monkeypatch) -> No
|
||||
def logout(self):
|
||||
return "BYE", [b""]
|
||||
|
||||
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: FakeIMAP())
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: FakeIMAP())
|
||||
|
||||
channel = EmailChannel(_make_config(post_action="delete"), MessageBus())
|
||||
items, skipped_uids = channel._fetch_new_messages()
|
||||
@@ -149,7 +149,7 @@ def test_fetch_new_messages_rejected_returns_skipped_uid(monkeypatch) -> None:
|
||||
def logout(self):
|
||||
return "BYE", [b""]
|
||||
|
||||
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: FakeIMAP())
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: FakeIMAP())
|
||||
|
||||
channel_skip = EmailChannel(
|
||||
_make_config(from_address="bot@example.com", post_action="delete", post_action_ignore_skipped=True),
|
||||
@@ -214,7 +214,7 @@ def test_apply_post_actions_batch_delete_uses_one_connection(monkeypatch) -> Non
|
||||
return "BYE", [b""]
|
||||
|
||||
fake = FakeIMAP()
|
||||
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
|
||||
channel = EmailChannel(_make_config(post_action="delete"), MessageBus())
|
||||
channel._apply_post_actions_batch(["123", "124"])
|
||||
@@ -271,7 +271,7 @@ def test_apply_post_actions_batch_move_copies_then_deletes(monkeypatch) -> None:
|
||||
return "BYE", [b""]
|
||||
|
||||
fake = FakeIMAP()
|
||||
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
|
||||
channel = EmailChannel(
|
||||
_make_config(post_action="move", post_action_move_mailbox="Processed"),
|
||||
@@ -312,7 +312,7 @@ def test_apply_post_actions_batch_move_prefers_uid_move_when_supported(monkeypat
|
||||
return "BYE", [b""]
|
||||
|
||||
fake = FakeIMAP()
|
||||
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
|
||||
channel = EmailChannel(
|
||||
_make_config(post_action="move", post_action_move_mailbox="Processed"),
|
||||
@@ -366,7 +366,7 @@ def test_apply_post_actions_batch_fallback_caches_uid_store_failure(monkeypatch)
|
||||
return "BYE", [b""]
|
||||
|
||||
fake = FakeIMAP()
|
||||
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
|
||||
channel = EmailChannel(_make_config(post_action="delete"), MessageBus())
|
||||
channel._apply_post_actions_batch(["123", "124"])
|
||||
@@ -420,7 +420,7 @@ def test_apply_post_actions_batch_delete_with_post_action_expunge_true_no_uidplu
|
||||
return "BYE", [b""]
|
||||
|
||||
fake = FakeIMAP()
|
||||
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
|
||||
channel = EmailChannel(_make_config(post_action="delete", post_action_expunge=True), MessageBus())
|
||||
channel._apply_post_actions_batch(["123", "124"])
|
||||
@@ -569,7 +569,7 @@ def test_fetch_new_messages_skips_self_sent_email_and_marks_seen(monkeypatch) ->
|
||||
return "BYE", [b""]
|
||||
|
||||
fake = FakeIMAP()
|
||||
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
|
||||
channel = EmailChannel(_make_config(from_address="bot@example.com"), MessageBus())
|
||||
items, skipped_uids = channel._fetch_new_messages()
|
||||
@@ -638,7 +638,7 @@ def test_fetch_new_messages_skips_self_sent_across_identity_sources(
|
||||
return "BYE", [b""]
|
||||
|
||||
fake = FakeIMAP()
|
||||
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
|
||||
channel = EmailChannel(_make_config(**config_override), MessageBus())
|
||||
items, _ = channel._fetch_new_messages()
|
||||
@@ -686,7 +686,7 @@ def test_fetch_new_messages_retries_once_when_imap_connection_goes_stale(monkeyp
|
||||
fake_instances.append(instance)
|
||||
return instance
|
||||
|
||||
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", _factory)
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", _factory)
|
||||
|
||||
channel = EmailChannel(_make_config(), MessageBus())
|
||||
items, _ = channel._fetch_new_messages()
|
||||
@@ -732,7 +732,7 @@ def test_fetch_new_messages_keeps_messages_collected_before_stale_retry(monkeypa
|
||||
def logout(self):
|
||||
return "BYE", [b""]
|
||||
|
||||
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: FlakyIMAP())
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: FlakyIMAP())
|
||||
|
||||
channel = EmailChannel(_make_config(), MessageBus())
|
||||
items, _ = channel._fetch_new_messages()
|
||||
@@ -752,7 +752,7 @@ def test_fetch_new_messages_skips_missing_mailbox(monkeypatch) -> None:
|
||||
return "BYE", [b""]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.channels.email.imaplib.IMAP4_SSL",
|
||||
"nanobot.channels.email.runtime.imaplib.IMAP4_SSL",
|
||||
lambda _h, _p: MissingMailboxIMAP(),
|
||||
)
|
||||
|
||||
@@ -827,7 +827,7 @@ async def test_send_uses_smtp_and_reply_subject(monkeypatch) -> None:
|
||||
fake_instances.append(instance)
|
||||
return instance
|
||||
|
||||
monkeypatch.setattr("nanobot.channels.email.smtplib.SMTP", _smtp_factory)
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.smtplib.SMTP", _smtp_factory)
|
||||
|
||||
channel = EmailChannel(_make_config(), MessageBus())
|
||||
channel._last_subject_by_chat["alice@example.com"] = "Invoice #42"
|
||||
@@ -860,7 +860,7 @@ async def test_send_skips_progress_messages_before_smtp(monkeypatch) -> None:
|
||||
called["smtp"] = True
|
||||
raise AssertionError("progress messages must not open an SMTP connection")
|
||||
|
||||
monkeypatch.setattr("nanobot.channels.email.smtplib.SMTP", _smtp_factory)
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.smtplib.SMTP", _smtp_factory)
|
||||
|
||||
channel = EmailChannel(_make_config(), MessageBus())
|
||||
|
||||
@@ -905,7 +905,7 @@ async def test_send_skips_reply_when_auto_reply_disabled(monkeypatch) -> None:
|
||||
fake_instances.append(instance)
|
||||
return instance
|
||||
|
||||
monkeypatch.setattr("nanobot.channels.email.smtplib.SMTP", _smtp_factory)
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.smtplib.SMTP", _smtp_factory)
|
||||
|
||||
cfg = _make_config()
|
||||
cfg.auto_reply_enabled = False
|
||||
@@ -966,7 +966,7 @@ async def test_send_proactive_email_when_auto_reply_disabled(monkeypatch) -> Non
|
||||
fake_instances.append(instance)
|
||||
return instance
|
||||
|
||||
monkeypatch.setattr("nanobot.channels.email.smtplib.SMTP", _smtp_factory)
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.smtplib.SMTP", _smtp_factory)
|
||||
|
||||
cfg = _make_config()
|
||||
cfg.auto_reply_enabled = False
|
||||
@@ -1014,7 +1014,7 @@ async def test_send_skips_when_consent_not_granted(monkeypatch) -> None:
|
||||
called["smtp"] = True
|
||||
return FakeSMTP(host, port, timeout=timeout)
|
||||
|
||||
monkeypatch.setattr("nanobot.channels.email.smtplib.SMTP", _smtp_factory)
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.smtplib.SMTP", _smtp_factory)
|
||||
|
||||
cfg = _make_config()
|
||||
cfg.consent_granted = False
|
||||
@@ -1059,7 +1059,7 @@ def test_fetch_messages_between_dates_uses_imap_since_before_without_mark_seen(m
|
||||
return "BYE", [b""]
|
||||
|
||||
fake = FakeIMAP()
|
||||
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
|
||||
channel = EmailChannel(_make_config(), MessageBus())
|
||||
items = channel.fetch_messages_between_dates(
|
||||
@@ -1112,7 +1112,7 @@ def test_spoofed_email_rejected_when_verify_enabled(monkeypatch) -> None:
|
||||
"""An email without Authentication-Results should be rejected when verify_dkim=True."""
|
||||
raw = _make_raw_email(subject="Spoofed", body="Malicious payload")
|
||||
fake = _make_fake_imap(raw)
|
||||
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
|
||||
cfg = _make_config(verify_dkim=True, verify_spf=True)
|
||||
channel = EmailChannel(cfg, MessageBus())
|
||||
@@ -1129,7 +1129,7 @@ def test_email_with_valid_auth_results_accepted(monkeypatch) -> None:
|
||||
auth_results="mx.example.com; spf=pass smtp.mailfrom=alice@example.com; dkim=pass header.d=example.com",
|
||||
)
|
||||
fake = _make_fake_imap(raw)
|
||||
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
|
||||
cfg = _make_config(verify_dkim=True, verify_spf=True)
|
||||
channel = EmailChannel(cfg, MessageBus())
|
||||
@@ -1148,7 +1148,7 @@ def test_email_with_partial_auth_rejected(monkeypatch) -> None:
|
||||
auth_results="mx.example.com; spf=pass smtp.mailfrom=alice@example.com; dkim=fail",
|
||||
)
|
||||
fake = _make_fake_imap(raw)
|
||||
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
|
||||
cfg = _make_config(verify_dkim=True, verify_spf=True)
|
||||
channel = EmailChannel(cfg, MessageBus())
|
||||
@@ -1161,7 +1161,7 @@ def test_backward_compat_verify_disabled(monkeypatch) -> None:
|
||||
"""When verify_dkim=False and verify_spf=False, emails without auth headers are accepted."""
|
||||
raw = _make_raw_email(subject="NoAuth", body="No auth headers present")
|
||||
fake = _make_fake_imap(raw)
|
||||
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
|
||||
cfg = _make_config(verify_dkim=False, verify_spf=False)
|
||||
channel = EmailChannel(cfg, MessageBus())
|
||||
@@ -1174,7 +1174,7 @@ def test_email_content_tagged_with_email_context(monkeypatch) -> None:
|
||||
"""Email content should be prefixed with [EMAIL-CONTEXT] for LLM isolation."""
|
||||
raw = _make_raw_email(subject="Tagged", body="Check the tag")
|
||||
fake = _make_fake_imap(raw)
|
||||
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
|
||||
cfg = _make_config(verify_dkim=False, verify_spf=False)
|
||||
channel = EmailChannel(cfg, MessageBus())
|
||||
@@ -1272,7 +1272,7 @@ def _make_raw_email_with_attachment(
|
||||
def test_fetch_new_messages_ignores_unauthorized_sender_before_attachments(monkeypatch) -> None:
|
||||
raw = _make_raw_email_with_attachment(from_addr="blocked@example.com")
|
||||
fake = _make_fake_imap(raw)
|
||||
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
|
||||
called = {"attachments": False}
|
||||
|
||||
@@ -1297,11 +1297,11 @@ def test_fetch_new_messages_ignores_unauthorized_sender_before_attachments(monke
|
||||
|
||||
def test_extract_attachments_saves_pdf(tmp_path, monkeypatch) -> None:
|
||||
"""PDF attachment is saved to media dir and path returned in media list."""
|
||||
monkeypatch.setattr("nanobot.channels.email.get_media_dir", lambda ch: tmp_path)
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.get_media_dir", lambda ch: tmp_path)
|
||||
|
||||
raw = _make_raw_email_with_attachment()
|
||||
fake = _make_fake_imap(raw)
|
||||
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
|
||||
cfg = _make_config(allowed_attachment_types=["application/pdf"], verify_dkim=False, verify_spf=False)
|
||||
channel = EmailChannel(cfg, MessageBus())
|
||||
@@ -1320,7 +1320,7 @@ def test_extract_attachments_disabled_by_default(monkeypatch) -> None:
|
||||
"""With no allowed_attachment_types (default), no attachments are extracted."""
|
||||
raw = _make_raw_email_with_attachment()
|
||||
fake = _make_fake_imap(raw)
|
||||
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
|
||||
cfg = _make_config(verify_dkim=False, verify_spf=False)
|
||||
assert cfg.allowed_attachment_types == []
|
||||
@@ -1334,7 +1334,7 @@ def test_extract_attachments_disabled_by_default(monkeypatch) -> None:
|
||||
|
||||
def test_extract_attachments_mime_type_filter(tmp_path, monkeypatch) -> None:
|
||||
"""Non-allowed MIME types are skipped."""
|
||||
monkeypatch.setattr("nanobot.channels.email.get_media_dir", lambda ch: tmp_path)
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.get_media_dir", lambda ch: tmp_path)
|
||||
|
||||
raw = _make_raw_email_with_attachment(
|
||||
attachment_name="image.png",
|
||||
@@ -1342,7 +1342,7 @@ def test_extract_attachments_mime_type_filter(tmp_path, monkeypatch) -> None:
|
||||
attachment_mime="image/png",
|
||||
)
|
||||
fake = _make_fake_imap(raw)
|
||||
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
|
||||
cfg = _make_config(
|
||||
allowed_attachment_types=["application/pdf"],
|
||||
@@ -1358,7 +1358,7 @@ def test_extract_attachments_mime_type_filter(tmp_path, monkeypatch) -> None:
|
||||
|
||||
def test_extract_attachments_empty_allowed_types_rejects_all(tmp_path, monkeypatch) -> None:
|
||||
"""Empty allowed_attachment_types means no types are accepted."""
|
||||
monkeypatch.setattr("nanobot.channels.email.get_media_dir", lambda ch: tmp_path)
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.get_media_dir", lambda ch: tmp_path)
|
||||
|
||||
raw = _make_raw_email_with_attachment(
|
||||
attachment_name="image.png",
|
||||
@@ -1366,7 +1366,7 @@ def test_extract_attachments_empty_allowed_types_rejects_all(tmp_path, monkeypat
|
||||
attachment_mime="image/png",
|
||||
)
|
||||
fake = _make_fake_imap(raw)
|
||||
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
|
||||
cfg = _make_config(
|
||||
allowed_attachment_types=[],
|
||||
@@ -1382,7 +1382,7 @@ def test_extract_attachments_empty_allowed_types_rejects_all(tmp_path, monkeypat
|
||||
|
||||
def test_extract_attachments_wildcard_pattern(tmp_path, monkeypatch) -> None:
|
||||
"""Glob patterns like 'image/*' match attachment MIME types."""
|
||||
monkeypatch.setattr("nanobot.channels.email.get_media_dir", lambda ch: tmp_path)
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.get_media_dir", lambda ch: tmp_path)
|
||||
|
||||
raw = _make_raw_email_with_attachment(
|
||||
attachment_name="photo.jpg",
|
||||
@@ -1390,7 +1390,7 @@ def test_extract_attachments_wildcard_pattern(tmp_path, monkeypatch) -> None:
|
||||
attachment_mime="image/jpeg",
|
||||
)
|
||||
fake = _make_fake_imap(raw)
|
||||
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
|
||||
cfg = _make_config(
|
||||
allowed_attachment_types=["image/*"],
|
||||
@@ -1406,13 +1406,13 @@ def test_extract_attachments_wildcard_pattern(tmp_path, monkeypatch) -> None:
|
||||
|
||||
def test_extract_attachments_size_limit(tmp_path, monkeypatch) -> None:
|
||||
"""Attachments exceeding max_attachment_size are skipped."""
|
||||
monkeypatch.setattr("nanobot.channels.email.get_media_dir", lambda ch: tmp_path)
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.get_media_dir", lambda ch: tmp_path)
|
||||
|
||||
raw = _make_raw_email_with_attachment(
|
||||
attachment_content=b"x" * 1000,
|
||||
)
|
||||
fake = _make_fake_imap(raw)
|
||||
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
|
||||
cfg = _make_config(
|
||||
allowed_attachment_types=["*"],
|
||||
@@ -1429,7 +1429,7 @@ def test_extract_attachments_size_limit(tmp_path, monkeypatch) -> None:
|
||||
|
||||
def test_extract_attachments_max_count(tmp_path, monkeypatch) -> None:
|
||||
"""Only max_attachments_per_email are saved."""
|
||||
monkeypatch.setattr("nanobot.channels.email.get_media_dir", lambda ch: tmp_path)
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.get_media_dir", lambda ch: tmp_path)
|
||||
|
||||
# Build email with 3 attachments
|
||||
msg = EmailMessage()
|
||||
@@ -1448,7 +1448,7 @@ def test_extract_attachments_max_count(tmp_path, monkeypatch) -> None:
|
||||
raw = msg.as_bytes()
|
||||
|
||||
fake = _make_fake_imap(raw)
|
||||
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
|
||||
cfg = _make_config(
|
||||
allowed_attachment_types=["*"],
|
||||
@@ -1465,13 +1465,13 @@ def test_extract_attachments_max_count(tmp_path, monkeypatch) -> None:
|
||||
|
||||
def test_extract_attachments_sanitizes_filename(tmp_path, monkeypatch) -> None:
|
||||
"""Path traversal in filenames is neutralized."""
|
||||
monkeypatch.setattr("nanobot.channels.email.get_media_dir", lambda ch: tmp_path)
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.get_media_dir", lambda ch: tmp_path)
|
||||
|
||||
raw = _make_raw_email_with_attachment(
|
||||
attachment_name="../../../etc/passwd",
|
||||
)
|
||||
fake = _make_fake_imap(raw)
|
||||
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
|
||||
|
||||
cfg = _make_config(allowed_attachment_types=["*"], verify_dkim=False, verify_spf=False)
|
||||
channel = EmailChannel(cfg, MessageBus())
|
||||
@@ -1513,7 +1513,7 @@ async def test_send_with_single_file_attachment(tmp_path, monkeypatch) -> None:
|
||||
def send_message(self, msg: EmailMessage):
|
||||
sent_messages.append(msg)
|
||||
|
||||
monkeypatch.setattr("nanobot.channels.email.smtplib.SMTP", lambda h, p, timeout=30: FakeSMTP(h, p, timeout=timeout))
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.smtplib.SMTP", lambda h, p, timeout=30: FakeSMTP(h, p, timeout=timeout))
|
||||
|
||||
# Create a real temp file to attach
|
||||
attachment = tmp_path / "report.pdf"
|
||||
@@ -1570,7 +1570,7 @@ async def test_send_with_multiple_file_attachments(tmp_path, monkeypatch) -> Non
|
||||
def send_message(self, msg: EmailMessage):
|
||||
sent_messages.append(msg)
|
||||
|
||||
monkeypatch.setattr("nanobot.channels.email.smtplib.SMTP", lambda h, p, timeout=30: FakeSMTP(h, p, timeout=timeout))
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.smtplib.SMTP", lambda h, p, timeout=30: FakeSMTP(h, p, timeout=timeout))
|
||||
|
||||
file1 = tmp_path / "doc.pdf"
|
||||
file1.write_bytes(b"%PDF-1.4 doc")
|
||||
@@ -1627,7 +1627,7 @@ async def test_send_skips_missing_attachment_file(tmp_path, monkeypatch) -> None
|
||||
def send_message(self, msg: EmailMessage):
|
||||
sent_messages.append(msg)
|
||||
|
||||
monkeypatch.setattr("nanobot.channels.email.smtplib.SMTP", lambda h, p, timeout=30: FakeSMTP(h, p, timeout=timeout))
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.smtplib.SMTP", lambda h, p, timeout=30: FakeSMTP(h, p, timeout=timeout))
|
||||
|
||||
existing = tmp_path / "real.txt"
|
||||
existing.write_text("I exist")
|
||||
@@ -1685,7 +1685,7 @@ async def test_send_skips_oversized_attachment_file(tmp_path, monkeypatch) -> No
|
||||
def send_message(self, msg: EmailMessage):
|
||||
sent_messages.append(msg)
|
||||
|
||||
monkeypatch.setattr("nanobot.channels.email.smtplib.SMTP", lambda h, p, timeout=30: FakeSMTP(h, p, timeout=timeout))
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.smtplib.SMTP", lambda h, p, timeout=30: FakeSMTP(h, p, timeout=timeout))
|
||||
|
||||
attachment = tmp_path / "too-large.bin"
|
||||
attachment.write_bytes(b"1234")
|
||||
@@ -1730,7 +1730,7 @@ async def test_send_limits_outbound_attachment_count(tmp_path, monkeypatch) -> N
|
||||
def send_message(self, msg: EmailMessage):
|
||||
sent_messages.append(msg)
|
||||
|
||||
monkeypatch.setattr("nanobot.channels.email.smtplib.SMTP", lambda h, p, timeout=30: FakeSMTP(h, p, timeout=timeout))
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.smtplib.SMTP", lambda h, p, timeout=30: FakeSMTP(h, p, timeout=timeout))
|
||||
|
||||
file1 = tmp_path / "first.txt"
|
||||
file1.write_text("first")
|
||||
@@ -1784,7 +1784,7 @@ async def test_send_with_unknown_mime_type_attachment(tmp_path, monkeypatch) ->
|
||||
def send_message(self, msg: EmailMessage):
|
||||
sent_messages.append(msg)
|
||||
|
||||
monkeypatch.setattr("nanobot.channels.email.smtplib.SMTP", lambda h, p, timeout=30: FakeSMTP(h, p, timeout=timeout))
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.smtplib.SMTP", lambda h, p, timeout=30: FakeSMTP(h, p, timeout=timeout))
|
||||
|
||||
attachment = tmp_path / "data.unknown_ext_xyz"
|
||||
attachment.write_bytes(b"some binary data")
|
||||
@@ -1837,7 +1837,7 @@ async def test_send_with_media_and_reply_subject_and_in_reply_to(tmp_path, monke
|
||||
def send_message(self, msg: EmailMessage):
|
||||
sent_messages.append(msg)
|
||||
|
||||
monkeypatch.setattr("nanobot.channels.email.smtplib.SMTP", lambda h, p, timeout=30: FakeSMTP(h, p, timeout=timeout))
|
||||
monkeypatch.setattr("nanobot.channels.email.runtime.smtplib.SMTP", lambda h, p, timeout=30: FakeSMTP(h, p, timeout=timeout))
|
||||
|
||||
attachment = tmp_path / "summary.pdf"
|
||||
attachment.write_bytes(b"%PDF-1.4 summary")
|
||||
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.channels.email import validation as email_validation
|
||||
from nanobot.channels.validation import validate_channel_config
|
||||
from nanobot.config.loader import load_config, save_config
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
|
||||
def test_validate_email_presets_are_checked_without_saving(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
monkeypatch.setattr(email_validation, "probe_tcp", lambda *_args, **_kwargs: None)
|
||||
|
||||
result = validate_channel_config(
|
||||
"email",
|
||||
{
|
||||
"channels.email.consentGranted": "true",
|
||||
"channels.email.imapHost": "imap.gmail.com",
|
||||
"channels.email.imapUsername": "bot@example.com",
|
||||
"channels.email.imapPassword": "imap-secret",
|
||||
"channels.email.smtpHost": "smtp.gmail.com",
|
||||
"channels.email.smtpUsername": "bot@example.com",
|
||||
"channels.email.smtpPassword": "smtp-secret",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["status"] == "connected"
|
||||
assert result["can_enable"] is True
|
||||
assert not hasattr(load_config(config_path).channels, "email")
|
||||
|
||||
|
||||
def test_validate_email_blocks_private_targets_when_local_access_is_disabled(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config = Config()
|
||||
config.tools.webui_allow_local_service_access = False
|
||||
save_config(config, config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.channels.validation.socket.create_connection",
|
||||
lambda *_args, **_kwargs: pytest.fail("blocked target must not be connected"),
|
||||
)
|
||||
|
||||
result = validate_channel_config(
|
||||
"email",
|
||||
{
|
||||
"channels.email.consentGranted": "true",
|
||||
"channels.email.imapHost": "127.0.0.1",
|
||||
"channels.email.imapUsername": "bot@example.com",
|
||||
"channels.email.imapPassword": "imap-secret",
|
||||
"channels.email.smtpHost": "192.168.1.10",
|
||||
"channels.email.smtpUsername": "bot@example.com",
|
||||
"channels.email.smtpPassword": "smtp-secret",
|
||||
},
|
||||
)
|
||||
|
||||
warnings = [check["message"] for check in result["checks"] if check["status"] == "warn"]
|
||||
assert len(warnings) == 2
|
||||
assert all("private/internal" in message for message in warnings)
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Email setup validation owned by the channel package."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from nanobot.channels.contracts import ChannelValidationContext
|
||||
from nanobot.channels.validation import (
|
||||
check,
|
||||
int_value,
|
||||
probe_tcp,
|
||||
required_checks,
|
||||
status_from_checks,
|
||||
string_value,
|
||||
truthy,
|
||||
)
|
||||
|
||||
|
||||
def validate(
|
||||
values: dict[str, Any],
|
||||
context: ChannelValidationContext,
|
||||
) -> dict[str, Any]:
|
||||
checks, missing = required_checks("email", values)
|
||||
if truthy(values.get("consentGranted")):
|
||||
checks.append(check("consent", "Mailbox consent", "pass", "Consent is enabled for this mailbox."))
|
||||
else:
|
||||
checks.append(
|
||||
check(
|
||||
"consent",
|
||||
"Mailbox consent",
|
||||
"fail",
|
||||
"Grant consent before nanobot reads this mailbox.",
|
||||
)
|
||||
)
|
||||
|
||||
for prefix, default_port in (("imap", 993), ("smtp", 587)):
|
||||
host = string_value(values.get(f"{prefix}Host"))
|
||||
port = int_value(values.get(f"{prefix}Port")) or default_port
|
||||
if not host:
|
||||
continue
|
||||
if port <= 0 or port > 65535:
|
||||
checks.append(
|
||||
check(
|
||||
f"{prefix}_port",
|
||||
f"{prefix.upper()} port",
|
||||
"fail",
|
||||
"Port must be between 1 and 65535.",
|
||||
)
|
||||
)
|
||||
continue
|
||||
checks.append(
|
||||
check(
|
||||
f"{prefix}_settings",
|
||||
f"{prefix.upper()} settings",
|
||||
"pass",
|
||||
f"{host}:{port} is set.",
|
||||
)
|
||||
)
|
||||
try:
|
||||
probe_tcp(
|
||||
host,
|
||||
port,
|
||||
allow_loopback=context.allow_local_service_access,
|
||||
)
|
||||
checks.append(
|
||||
check(
|
||||
f"{prefix}_reachability",
|
||||
f"{prefix.upper()} reachability",
|
||||
"pass",
|
||||
"The server accepted a TCP connection.",
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
checks.append(
|
||||
check(
|
||||
f"{prefix}_reachability",
|
||||
f"{prefix.upper()} reachability",
|
||||
"warn",
|
||||
f"Could not verify network reachability now: {exc}",
|
||||
)
|
||||
)
|
||||
|
||||
identity = {
|
||||
"account": string_value(
|
||||
values.get("fromAddress")
|
||||
or values.get("imapUsername")
|
||||
or values.get("smtpUsername")
|
||||
)
|
||||
}
|
||||
return status_from_checks("email", checks, missing, identity=identity)
|
||||
|
||||
|
||||
__all__ = ["validate"]
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ChannelUiContribution } from "@/channel-plugins/types";
|
||||
import {
|
||||
type ChannelProviderPresetDefinition,
|
||||
chatAppGuideUrl,
|
||||
} from "@/components/settings/channels/catalog";
|
||||
|
||||
const EMAIL_PROVIDER_PRESETS: ChannelProviderPresetDefinition[] = [
|
||||
{
|
||||
id: "gmail",
|
||||
values: {
|
||||
"channels.email.imapHost": "imap.gmail.com",
|
||||
"channels.email.imapPort": "993",
|
||||
"channels.email.smtpHost": "smtp.gmail.com",
|
||||
"channels.email.smtpPort": "587",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "outlook",
|
||||
values: {
|
||||
"channels.email.imapHost": "outlook.office365.com",
|
||||
"channels.email.imapPort": "993",
|
||||
"channels.email.smtpHost": "smtp.office365.com",
|
||||
"channels.email.smtpPort": "587",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "icloud",
|
||||
values: {
|
||||
"channels.email.imapHost": "imap.mail.me.com",
|
||||
"channels.email.imapPort": "993",
|
||||
"channels.email.smtpHost": "smtp.mail.me.com",
|
||||
"channels.email.smtpPort": "587",
|
||||
},
|
||||
},
|
||||
{ id: "custom", values: {} },
|
||||
];
|
||||
|
||||
export default {
|
||||
presentation: {
|
||||
displayName: "Email",
|
||||
initials: "EM",
|
||||
color: "#64748B",
|
||||
logoUrl: "https://gmail.com/favicon.ico",
|
||||
setup: {
|
||||
mode: "credentials",
|
||||
docsUrl: chatAppGuideUrl("email"),
|
||||
presets: EMAIL_PROVIDER_PRESETS,
|
||||
fields: [
|
||||
{ key: "channels.email.consentGranted" },
|
||||
{ key: "channels.email.imapHost" },
|
||||
{ key: "channels.email.imapUsername" },
|
||||
{ key: "channels.email.imapPassword" },
|
||||
{ key: "channels.email.smtpHost" },
|
||||
{ key: "channels.email.smtpUsername" },
|
||||
{ key: "channels.email.smtpPassword" },
|
||||
{ key: "channels.email.imapPort" },
|
||||
{ key: "channels.email.smtpPort" },
|
||||
{ key: "channels.email.fromAddress" },
|
||||
{ key: "channels.email.pollIntervalSeconds" },
|
||||
{ key: "channels.email.allowFrom" },
|
||||
{ key: "channels.email.verifyDkim" },
|
||||
{ key: "channels.email.verifySpf" },
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies ChannelUiContribution;
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"description": "Let nanobot receive and answer email messages.",
|
||||
"requirements": "IMAP inbox, SMTP sender, app password, explicit consent",
|
||||
"setup": {
|
||||
"docsLabel": "Open Email setup",
|
||||
"officialLabel": "Open app password guide",
|
||||
"tryIt": "Send a test email to the connected mailbox.",
|
||||
"summary": "Email reads messages over IMAP and replies over SMTP. Use a dedicated mailbox and grant consent before enabling it.",
|
||||
"steps": [
|
||||
"Create a dedicated mailbox and, when required, an app password.",
|
||||
"Choose a provider preset or enter the IMAP and SMTP settings manually.",
|
||||
"Grant consent, save and enable Email, then send a test message to the mailbox."
|
||||
],
|
||||
"presets": {
|
||||
"gmail": "Gmail",
|
||||
"outlook": "Outlook",
|
||||
"icloud": "iCloud",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"fields": {
|
||||
"consentGranted": {
|
||||
"label": "Consent granted",
|
||||
"help": "Required safety switch. Leave false until this bot mailbox is intentionally connected.",
|
||||
"choices": {
|
||||
"true": "Granted",
|
||||
"false": "Not granted"
|
||||
}
|
||||
},
|
||||
"imapHost": {
|
||||
"label": "IMAP host",
|
||||
"placeholder": "imap.gmail.com"
|
||||
},
|
||||
"imapUsername": {
|
||||
"label": "IMAP username",
|
||||
"placeholder": "bot@example.com"
|
||||
},
|
||||
"imapPassword": {
|
||||
"label": "IMAP password",
|
||||
"placeholder": "App password",
|
||||
"help": "Use an app password when your mail provider requires one."
|
||||
},
|
||||
"smtpHost": {
|
||||
"label": "SMTP host",
|
||||
"placeholder": "smtp.gmail.com"
|
||||
},
|
||||
"smtpUsername": {
|
||||
"label": "SMTP username",
|
||||
"placeholder": "bot@example.com"
|
||||
},
|
||||
"smtpPassword": {
|
||||
"label": "SMTP password",
|
||||
"placeholder": "App password",
|
||||
"help": "Usually the same app password used for IMAP."
|
||||
},
|
||||
"imapPort": {
|
||||
"label": "IMAP port",
|
||||
"placeholder": "993"
|
||||
},
|
||||
"smtpPort": {
|
||||
"label": "SMTP port",
|
||||
"placeholder": "587"
|
||||
},
|
||||
"fromAddress": {
|
||||
"label": "From address",
|
||||
"placeholder": "bot@example.com"
|
||||
},
|
||||
"pollIntervalSeconds": {
|
||||
"label": "Poll interval",
|
||||
"placeholder": "30"
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "Allowed senders",
|
||||
"placeholder": "Email addresses, comma separated",
|
||||
"help": "Leave empty to require pairing before a sender can use email."
|
||||
},
|
||||
"verifyDkim": {
|
||||
"label": "Verify DKIM",
|
||||
"choices": {
|
||||
"true": "On",
|
||||
"false": "Off"
|
||||
}
|
||||
},
|
||||
"verifySpf": {
|
||||
"label": "Verify SPF",
|
||||
"choices": {
|
||||
"true": "On",
|
||||
"false": "Off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"description": "Permite que nanobot reciba y responda correos.",
|
||||
"requirements": "Bandeja IMAP, envío SMTP, contraseña de app y consentimiento explícito",
|
||||
"setup": {
|
||||
"docsLabel": "Abrir guía de Email",
|
||||
"officialLabel": "Abrir guía de contraseñas de app",
|
||||
"tryIt": "Envía un correo de prueba al buzón conectado.",
|
||||
"summary": "Email lee mensajes por IMAP y responde por SMTP. Usa un buzón dedicado y da tu consentimiento antes de activarlo.",
|
||||
"steps": [
|
||||
"Crea un buzón dedicado y, si hace falta, una contraseña de app.",
|
||||
"Elige un proveedor o introduce manualmente IMAP y SMTP.",
|
||||
"Da tu consentimiento, guarda y activa Email; después envía un mensaje de prueba."
|
||||
],
|
||||
"presets": {
|
||||
"gmail": "Gmail",
|
||||
"outlook": "Outlook",
|
||||
"icloud": "iCloud",
|
||||
"custom": "Personalizado"
|
||||
},
|
||||
"fields": {
|
||||
"consentGranted": {
|
||||
"label": "Consentimiento concedido",
|
||||
"help": "Control de seguridad obligatorio. Déjalo desactivado hasta decidir conectar este buzón al bot.",
|
||||
"choices": {
|
||||
"true": "Concedido",
|
||||
"false": "No concedido"
|
||||
}
|
||||
},
|
||||
"imapHost": {
|
||||
"label": "Host IMAP",
|
||||
"placeholder": "imap.gmail.com"
|
||||
},
|
||||
"imapUsername": {
|
||||
"label": "Usuario IMAP",
|
||||
"placeholder": "bot@example.com"
|
||||
},
|
||||
"imapPassword": {
|
||||
"label": "Contraseña IMAP",
|
||||
"placeholder": "Contraseña de app",
|
||||
"help": "Usa una contraseña de app si el proveedor la exige."
|
||||
},
|
||||
"smtpHost": {
|
||||
"label": "Host SMTP",
|
||||
"placeholder": "smtp.gmail.com"
|
||||
},
|
||||
"smtpUsername": {
|
||||
"label": "Usuario SMTP",
|
||||
"placeholder": "bot@example.com"
|
||||
},
|
||||
"smtpPassword": {
|
||||
"label": "Contraseña SMTP",
|
||||
"placeholder": "Contraseña de app",
|
||||
"help": "Normalmente es la misma que para IMAP."
|
||||
},
|
||||
"imapPort": {
|
||||
"label": "Puerto IMAP",
|
||||
"placeholder": "993"
|
||||
},
|
||||
"smtpPort": {
|
||||
"label": "Puerto SMTP",
|
||||
"placeholder": "587"
|
||||
},
|
||||
"fromAddress": {
|
||||
"label": "Dirección remitente",
|
||||
"placeholder": "bot@example.com"
|
||||
},
|
||||
"pollIntervalSeconds": {
|
||||
"label": "Intervalo de consulta",
|
||||
"placeholder": "30"
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "Remitentes permitidos",
|
||||
"placeholder": "Correos separados por comas",
|
||||
"help": "Déjalo vacío para exigir vinculación previa."
|
||||
},
|
||||
"verifyDkim": {
|
||||
"label": "Verificar DKIM",
|
||||
"choices": {
|
||||
"true": "Activado",
|
||||
"false": "Desactivado"
|
||||
}
|
||||
},
|
||||
"verifySpf": {
|
||||
"label": "Verificar SPF",
|
||||
"choices": {
|
||||
"true": "Activado",
|
||||
"false": "Desactivado"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"description": "Permettez à nanobot de recevoir et répondre aux e-mails.",
|
||||
"requirements": "Boîte IMAP, envoi SMTP, mot de passe d’application et consentement explicite",
|
||||
"setup": {
|
||||
"docsLabel": "Ouvrir le guide Email",
|
||||
"officialLabel": "Ouvrir le guide des mots de passe d’application",
|
||||
"tryIt": "Envoyez un e-mail test à la boîte connectée.",
|
||||
"summary": "Email lit les messages via IMAP et répond via SMTP. Utilisez une boîte dédiée et accordez votre consentement avant l’activation.",
|
||||
"steps": [
|
||||
"Créez une boîte dédiée et, si nécessaire, un mot de passe d’application.",
|
||||
"Choisissez un fournisseur ou saisissez les paramètres IMAP et SMTP.",
|
||||
"Accordez le consentement, enregistrez et activez Email, puis envoyez un message test."
|
||||
],
|
||||
"presets": {
|
||||
"gmail": "Gmail",
|
||||
"outlook": "Outlook",
|
||||
"icloud": "iCloud",
|
||||
"custom": "Personnalisé"
|
||||
},
|
||||
"fields": {
|
||||
"consentGranted": {
|
||||
"label": "Consentement accordé",
|
||||
"help": "Sécurité obligatoire. N’activez qu’après avoir choisi de connecter cette boîte au bot.",
|
||||
"choices": {
|
||||
"true": "Accordé",
|
||||
"false": "Non accordé"
|
||||
}
|
||||
},
|
||||
"imapHost": {
|
||||
"label": "Hôte IMAP",
|
||||
"placeholder": "imap.gmail.com"
|
||||
},
|
||||
"imapUsername": {
|
||||
"label": "Nom d’utilisateur IMAP",
|
||||
"placeholder": "bot@example.com"
|
||||
},
|
||||
"imapPassword": {
|
||||
"label": "Mot de passe IMAP",
|
||||
"placeholder": "Mot de passe d’application",
|
||||
"help": "Utilisez un mot de passe d’application si le fournisseur l’exige."
|
||||
},
|
||||
"smtpHost": {
|
||||
"label": "Hôte SMTP",
|
||||
"placeholder": "smtp.gmail.com"
|
||||
},
|
||||
"smtpUsername": {
|
||||
"label": "Nom d’utilisateur SMTP",
|
||||
"placeholder": "bot@example.com"
|
||||
},
|
||||
"smtpPassword": {
|
||||
"label": "Mot de passe SMTP",
|
||||
"placeholder": "Mot de passe d’application",
|
||||
"help": "Généralement identique à celui d’IMAP."
|
||||
},
|
||||
"imapPort": {
|
||||
"label": "Port IMAP",
|
||||
"placeholder": "993"
|
||||
},
|
||||
"smtpPort": {
|
||||
"label": "Port SMTP",
|
||||
"placeholder": "587"
|
||||
},
|
||||
"fromAddress": {
|
||||
"label": "Adresse d’envoi",
|
||||
"placeholder": "bot@example.com"
|
||||
},
|
||||
"pollIntervalSeconds": {
|
||||
"label": "Intervalle de relève",
|
||||
"placeholder": "30"
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "Expéditeurs autorisés",
|
||||
"placeholder": "Adresses séparées par des virgules",
|
||||
"help": "Laissez vide pour imposer l’association avant utilisation."
|
||||
},
|
||||
"verifyDkim": {
|
||||
"label": "Vérifier DKIM",
|
||||
"choices": {
|
||||
"true": "Activé",
|
||||
"false": "Désactivé"
|
||||
}
|
||||
},
|
||||
"verifySpf": {
|
||||
"label": "Vérifier SPF",
|
||||
"choices": {
|
||||
"true": "Activé",
|
||||
"false": "Désactivé"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"description": "Izinkan nanobot menerima dan membalas email.",
|
||||
"requirements": "Kotak masuk IMAP, pengirim SMTP, kata sandi aplikasi, dan persetujuan eksplisit",
|
||||
"setup": {
|
||||
"docsLabel": "Buka panduan Email",
|
||||
"officialLabel": "Buka panduan kata sandi aplikasi",
|
||||
"tryIt": "Kirim email uji ke kotak surat yang terhubung.",
|
||||
"summary": "Email membaca pesan melalui IMAP dan membalas melalui SMTP. Gunakan kotak surat khusus dan berikan persetujuan sebelum mengaktifkan.",
|
||||
"steps": [
|
||||
"Buat kotak surat khusus dan kata sandi aplikasi bila diperlukan.",
|
||||
"Pilih preset penyedia atau masukkan IMAP dan SMTP secara manual.",
|
||||
"Berikan persetujuan, simpan dan aktifkan Email, lalu kirim pesan uji."
|
||||
],
|
||||
"presets": {
|
||||
"gmail": "Gmail",
|
||||
"outlook": "Outlook",
|
||||
"icloud": "iCloud",
|
||||
"custom": "Kustom"
|
||||
},
|
||||
"fields": {
|
||||
"consentGranted": {
|
||||
"label": "Persetujuan diberikan",
|
||||
"help": "Sakelar keamanan wajib. Aktifkan hanya setelah sengaja menghubungkan kotak surat ini ke bot.",
|
||||
"choices": {
|
||||
"true": "Diberikan",
|
||||
"false": "Belum diberikan"
|
||||
}
|
||||
},
|
||||
"imapHost": {
|
||||
"label": "Host IMAP",
|
||||
"placeholder": "imap.gmail.com"
|
||||
},
|
||||
"imapUsername": {
|
||||
"label": "Nama pengguna IMAP",
|
||||
"placeholder": "bot@example.com"
|
||||
},
|
||||
"imapPassword": {
|
||||
"label": "Kata sandi IMAP",
|
||||
"placeholder": "Kata sandi aplikasi",
|
||||
"help": "Gunakan kata sandi aplikasi jika diwajibkan penyedia."
|
||||
},
|
||||
"smtpHost": {
|
||||
"label": "Host SMTP",
|
||||
"placeholder": "smtp.gmail.com"
|
||||
},
|
||||
"smtpUsername": {
|
||||
"label": "Nama pengguna SMTP",
|
||||
"placeholder": "bot@example.com"
|
||||
},
|
||||
"smtpPassword": {
|
||||
"label": "Kata sandi SMTP",
|
||||
"placeholder": "Kata sandi aplikasi",
|
||||
"help": "Biasanya sama dengan kata sandi aplikasi IMAP."
|
||||
},
|
||||
"imapPort": {
|
||||
"label": "Port IMAP",
|
||||
"placeholder": "993"
|
||||
},
|
||||
"smtpPort": {
|
||||
"label": "Port SMTP",
|
||||
"placeholder": "587"
|
||||
},
|
||||
"fromAddress": {
|
||||
"label": "Alamat pengirim",
|
||||
"placeholder": "bot@example.com"
|
||||
},
|
||||
"pollIntervalSeconds": {
|
||||
"label": "Interval pemeriksaan",
|
||||
"placeholder": "30"
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "Pengirim yang diizinkan",
|
||||
"placeholder": "Alamat email, dipisahkan koma",
|
||||
"help": "Kosongkan untuk mewajibkan pairing terlebih dahulu."
|
||||
},
|
||||
"verifyDkim": {
|
||||
"label": "Verifikasi DKIM",
|
||||
"choices": {
|
||||
"true": "Aktif",
|
||||
"false": "Nonaktif"
|
||||
}
|
||||
},
|
||||
"verifySpf": {
|
||||
"label": "Verifikasi SPF",
|
||||
"choices": {
|
||||
"true": "Aktif",
|
||||
"false": "Nonaktif"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"description": "nanobot でメールを受信し、返信します。",
|
||||
"requirements": "IMAP 受信箱、SMTP 送信、アプリパスワード、明示的な同意",
|
||||
"setup": {
|
||||
"docsLabel": "メール設定ガイドを開く",
|
||||
"officialLabel": "アプリパスワードガイドを開く",
|
||||
"tryIt": "接続したメールボックスにテストメールを送信します。",
|
||||
"summary": "メールは IMAP で受信し SMTP で返信します。専用メールボックスを使い、有効化前に同意してください。",
|
||||
"steps": [
|
||||
"専用メールボックスを作成し、必要ならアプリパスワードを発行します。",
|
||||
"プロバイダープリセットを選ぶか、IMAP と SMTP を手動入力します。",
|
||||
"同意して保存し、メールを有効にしてテストメールを送信します。"
|
||||
],
|
||||
"presets": {
|
||||
"gmail": "Gmail",
|
||||
"outlook": "Outlook",
|
||||
"icloud": "iCloud",
|
||||
"custom": "カスタム"
|
||||
},
|
||||
"fields": {
|
||||
"consentGranted": {
|
||||
"label": "同意済み",
|
||||
"help": "必須の安全設定です。このボット用メールボックスを接続すると決めるまでオフにしてください。",
|
||||
"choices": {
|
||||
"true": "同意済み",
|
||||
"false": "未同意"
|
||||
}
|
||||
},
|
||||
"imapHost": {
|
||||
"label": "IMAP ホスト",
|
||||
"placeholder": "imap.gmail.com"
|
||||
},
|
||||
"imapUsername": {
|
||||
"label": "IMAP ユーザー名",
|
||||
"placeholder": "bot@example.com"
|
||||
},
|
||||
"imapPassword": {
|
||||
"label": "IMAP パスワード",
|
||||
"placeholder": "アプリパスワード",
|
||||
"help": "プロバイダーが求める場合はアプリパスワードを使います。"
|
||||
},
|
||||
"smtpHost": {
|
||||
"label": "SMTP ホスト",
|
||||
"placeholder": "smtp.gmail.com"
|
||||
},
|
||||
"smtpUsername": {
|
||||
"label": "SMTP ユーザー名",
|
||||
"placeholder": "bot@example.com"
|
||||
},
|
||||
"smtpPassword": {
|
||||
"label": "SMTP パスワード",
|
||||
"placeholder": "アプリパスワード",
|
||||
"help": "通常は IMAP と同じアプリパスワードです。"
|
||||
},
|
||||
"imapPort": {
|
||||
"label": "IMAP ポート",
|
||||
"placeholder": "993"
|
||||
},
|
||||
"smtpPort": {
|
||||
"label": "SMTP ポート",
|
||||
"placeholder": "587"
|
||||
},
|
||||
"fromAddress": {
|
||||
"label": "送信元アドレス",
|
||||
"placeholder": "bot@example.com"
|
||||
},
|
||||
"pollIntervalSeconds": {
|
||||
"label": "確認間隔",
|
||||
"placeholder": "30"
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "許可する送信者",
|
||||
"placeholder": "メールアドレス(カンマ区切り)",
|
||||
"help": "空欄の場合、送信者は先にペアリングが必要です。"
|
||||
},
|
||||
"verifyDkim": {
|
||||
"label": "DKIM を検証",
|
||||
"choices": {
|
||||
"true": "オン",
|
||||
"false": "オフ"
|
||||
}
|
||||
},
|
||||
"verifySpf": {
|
||||
"label": "SPF を検証",
|
||||
"choices": {
|
||||
"true": "オン",
|
||||
"false": "オフ"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"description": "nanobot이 이메일을 받고 답장하도록 합니다.",
|
||||
"requirements": "IMAP 받은편지함, SMTP 발신, 앱 비밀번호 및 명시적 동의",
|
||||
"setup": {
|
||||
"docsLabel": "이메일 설정 가이드 열기",
|
||||
"officialLabel": "앱 비밀번호 가이드 열기",
|
||||
"tryIt": "연결된 사서함으로 테스트 이메일을 보내세요.",
|
||||
"summary": "이메일은 IMAP으로 읽고 SMTP로 답장합니다. 전용 사서함을 사용하고 활성화 전에 동의하세요.",
|
||||
"steps": [
|
||||
"전용 사서함을 만들고 필요하면 앱 비밀번호를 생성하세요.",
|
||||
"제공자 프리셋을 선택하거나 IMAP 및 SMTP 설정을 직접 입력하세요.",
|
||||
"동의하고 저장한 뒤 이메일을 활성화하고 테스트 메시지를 보내세요."
|
||||
],
|
||||
"presets": {
|
||||
"gmail": "Gmail",
|
||||
"outlook": "Outlook",
|
||||
"icloud": "iCloud",
|
||||
"custom": "사용자 지정"
|
||||
},
|
||||
"fields": {
|
||||
"consentGranted": {
|
||||
"label": "동의함",
|
||||
"help": "필수 안전 스위치입니다. 이 봇 사서함을 연결하기로 결정하기 전에는 끄세요.",
|
||||
"choices": {
|
||||
"true": "동의함",
|
||||
"false": "동의하지 않음"
|
||||
}
|
||||
},
|
||||
"imapHost": {
|
||||
"label": "IMAP 호스트",
|
||||
"placeholder": "imap.gmail.com"
|
||||
},
|
||||
"imapUsername": {
|
||||
"label": "IMAP 사용자 이름",
|
||||
"placeholder": "bot@example.com"
|
||||
},
|
||||
"imapPassword": {
|
||||
"label": "IMAP 비밀번호",
|
||||
"placeholder": "앱 비밀번호",
|
||||
"help": "메일 제공자가 요구하면 앱 비밀번호를 사용하세요."
|
||||
},
|
||||
"smtpHost": {
|
||||
"label": "SMTP 호스트",
|
||||
"placeholder": "smtp.gmail.com"
|
||||
},
|
||||
"smtpUsername": {
|
||||
"label": "SMTP 사용자 이름",
|
||||
"placeholder": "bot@example.com"
|
||||
},
|
||||
"smtpPassword": {
|
||||
"label": "SMTP 비밀번호",
|
||||
"placeholder": "앱 비밀번호",
|
||||
"help": "보통 IMAP과 같은 앱 비밀번호를 사용합니다."
|
||||
},
|
||||
"imapPort": {
|
||||
"label": "IMAP 포트",
|
||||
"placeholder": "993"
|
||||
},
|
||||
"smtpPort": {
|
||||
"label": "SMTP 포트",
|
||||
"placeholder": "587"
|
||||
},
|
||||
"fromAddress": {
|
||||
"label": "보내는 주소",
|
||||
"placeholder": "bot@example.com"
|
||||
},
|
||||
"pollIntervalSeconds": {
|
||||
"label": "확인 간격",
|
||||
"placeholder": "30"
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "허용된 발신자",
|
||||
"placeholder": "이메일 주소, 쉼표로 구분",
|
||||
"help": "비워 두면 발신자가 먼저 페어링해야 합니다."
|
||||
},
|
||||
"verifyDkim": {
|
||||
"label": "DKIM 확인",
|
||||
"choices": {
|
||||
"true": "켜짐",
|
||||
"false": "꺼짐"
|
||||
}
|
||||
},
|
||||
"verifySpf": {
|
||||
"label": "SPF 확인",
|
||||
"choices": {
|
||||
"true": "켜짐",
|
||||
"false": "꺼짐"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"description": "Permita que o nanobot receba e responda e-mails.",
|
||||
"requirements": "Caixa IMAP, envio SMTP, senha de app e consentimento explícito",
|
||||
"setup": {
|
||||
"docsLabel": "Abrir guia de Email",
|
||||
"officialLabel": "Abrir guia de senhas de app",
|
||||
"tryIt": "Envie um e-mail de teste para a caixa conectada.",
|
||||
"summary": "Email lê mensagens por IMAP e responde por SMTP. Use uma caixa dedicada e dê consentimento antes de ativar.",
|
||||
"steps": [
|
||||
"Crie uma caixa dedicada e, quando necessário, uma senha de app.",
|
||||
"Escolha um provedor ou informe IMAP e SMTP manualmente.",
|
||||
"Dê consentimento, salve e ative Email; depois, envie uma mensagem de teste."
|
||||
],
|
||||
"presets": {
|
||||
"gmail": "Gmail",
|
||||
"outlook": "Outlook",
|
||||
"icloud": "iCloud",
|
||||
"custom": "Personalizado"
|
||||
},
|
||||
"fields": {
|
||||
"consentGranted": {
|
||||
"label": "Consentimento concedido",
|
||||
"help": "Controle de segurança obrigatório. Deixe desativado até decidir conectar esta caixa ao bot.",
|
||||
"choices": {
|
||||
"true": "Concedido",
|
||||
"false": "Não concedido"
|
||||
}
|
||||
},
|
||||
"imapHost": {
|
||||
"label": "Host IMAP",
|
||||
"placeholder": "imap.gmail.com"
|
||||
},
|
||||
"imapUsername": {
|
||||
"label": "Usuário IMAP",
|
||||
"placeholder": "bot@example.com"
|
||||
},
|
||||
"imapPassword": {
|
||||
"label": "Senha IMAP",
|
||||
"placeholder": "Senha de app",
|
||||
"help": "Use uma senha de app quando o provedor exigir."
|
||||
},
|
||||
"smtpHost": {
|
||||
"label": "Host SMTP",
|
||||
"placeholder": "smtp.gmail.com"
|
||||
},
|
||||
"smtpUsername": {
|
||||
"label": "Usuário SMTP",
|
||||
"placeholder": "bot@example.com"
|
||||
},
|
||||
"smtpPassword": {
|
||||
"label": "Senha SMTP",
|
||||
"placeholder": "Senha de app",
|
||||
"help": "Normalmente é a mesma senha usada no IMAP."
|
||||
},
|
||||
"imapPort": {
|
||||
"label": "Porta IMAP",
|
||||
"placeholder": "993"
|
||||
},
|
||||
"smtpPort": {
|
||||
"label": "Porta SMTP",
|
||||
"placeholder": "587"
|
||||
},
|
||||
"fromAddress": {
|
||||
"label": "Endereço remetente",
|
||||
"placeholder": "bot@example.com"
|
||||
},
|
||||
"pollIntervalSeconds": {
|
||||
"label": "Intervalo de consulta",
|
||||
"placeholder": "30"
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "Remetentes permitidos",
|
||||
"placeholder": "E-mails separados por vírgulas",
|
||||
"help": "Deixe vazio para exigir pareamento prévio."
|
||||
},
|
||||
"verifyDkim": {
|
||||
"label": "Verificar DKIM",
|
||||
"choices": {
|
||||
"true": "Ativado",
|
||||
"false": "Desativado"
|
||||
}
|
||||
},
|
||||
"verifySpf": {
|
||||
"label": "Verificar SPF",
|
||||
"choices": {
|
||||
"true": "Ativado",
|
||||
"false": "Desativado"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"description": "Cho phép nanobot nhận và trả lời email.",
|
||||
"requirements": "Hộp thư IMAP, gửi SMTP, mật khẩu ứng dụng và sự đồng ý rõ ràng",
|
||||
"setup": {
|
||||
"docsLabel": "Mở hướng dẫn Email",
|
||||
"officialLabel": "Mở hướng dẫn mật khẩu ứng dụng",
|
||||
"tryIt": "Gửi email thử đến hộp thư đã kết nối.",
|
||||
"summary": "Email đọc thư qua IMAP và trả lời qua SMTP. Dùng hộp thư riêng và cấp quyền trước khi bật.",
|
||||
"steps": [
|
||||
"Tạo hộp thư riêng và mật khẩu ứng dụng nếu cần.",
|
||||
"Chọn nhà cung cấp hoặc nhập thủ công cài đặt IMAP và SMTP.",
|
||||
"Cấp quyền, lưu và bật Email, sau đó gửi tin nhắn thử."
|
||||
],
|
||||
"presets": {
|
||||
"gmail": "Gmail",
|
||||
"outlook": "Outlook",
|
||||
"icloud": "iCloud",
|
||||
"custom": "Tùy chỉnh"
|
||||
},
|
||||
"fields": {
|
||||
"consentGranted": {
|
||||
"label": "Đã đồng ý",
|
||||
"help": "Công tắc an toàn bắt buộc. Chỉ bật sau khi chủ động kết nối hộp thư này với bot.",
|
||||
"choices": {
|
||||
"true": "Đã đồng ý",
|
||||
"false": "Chưa đồng ý"
|
||||
}
|
||||
},
|
||||
"imapHost": {
|
||||
"label": "Host IMAP",
|
||||
"placeholder": "imap.gmail.com"
|
||||
},
|
||||
"imapUsername": {
|
||||
"label": "Tên người dùng IMAP",
|
||||
"placeholder": "bot@example.com"
|
||||
},
|
||||
"imapPassword": {
|
||||
"label": "Mật khẩu IMAP",
|
||||
"placeholder": "Mật khẩu ứng dụng",
|
||||
"help": "Dùng mật khẩu ứng dụng khi nhà cung cấp yêu cầu."
|
||||
},
|
||||
"smtpHost": {
|
||||
"label": "Host SMTP",
|
||||
"placeholder": "smtp.gmail.com"
|
||||
},
|
||||
"smtpUsername": {
|
||||
"label": "Tên người dùng SMTP",
|
||||
"placeholder": "bot@example.com"
|
||||
},
|
||||
"smtpPassword": {
|
||||
"label": "Mật khẩu SMTP",
|
||||
"placeholder": "Mật khẩu ứng dụng",
|
||||
"help": "Thường giống mật khẩu ứng dụng dùng cho IMAP."
|
||||
},
|
||||
"imapPort": {
|
||||
"label": "Cổng IMAP",
|
||||
"placeholder": "993"
|
||||
},
|
||||
"smtpPort": {
|
||||
"label": "Cổng SMTP",
|
||||
"placeholder": "587"
|
||||
},
|
||||
"fromAddress": {
|
||||
"label": "Địa chỉ gửi",
|
||||
"placeholder": "bot@example.com"
|
||||
},
|
||||
"pollIntervalSeconds": {
|
||||
"label": "Chu kỳ kiểm tra",
|
||||
"placeholder": "30"
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "Người gửi được phép",
|
||||
"placeholder": "Địa chỉ email, phân tách bằng dấu phẩy",
|
||||
"help": "Để trống để yêu cầu ghép nối trước."
|
||||
},
|
||||
"verifyDkim": {
|
||||
"label": "Xác minh DKIM",
|
||||
"choices": {
|
||||
"true": "Bật",
|
||||
"false": "Tắt"
|
||||
}
|
||||
},
|
||||
"verifySpf": {
|
||||
"label": "Xác minh SPF",
|
||||
"choices": {
|
||||
"true": "Bật",
|
||||
"false": "Tắt"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"description": "让 nanobot 接收并回复电子邮件。",
|
||||
"requirements": "IMAP 收件箱、SMTP 发件服务、应用专用密码和明确授权",
|
||||
"setup": {
|
||||
"docsLabel": "打开邮件配置指南",
|
||||
"officialLabel": "打开应用专用密码指南",
|
||||
"tryIt": "向已连接的邮箱发送一封测试邮件。",
|
||||
"summary": "邮件渠道通过 IMAP 读取邮件并通过 SMTP 回复。请使用专用邮箱,并在启用前明确授权。",
|
||||
"steps": [
|
||||
"创建专用邮箱,并在服务商要求时创建应用专用密码。",
|
||||
"选择服务商预设,或手动填写 IMAP 和 SMTP 设置。",
|
||||
"授予授权,保存并启用邮件渠道,然后向邮箱发送一封测试邮件。"
|
||||
],
|
||||
"presets": {
|
||||
"gmail": "Gmail",
|
||||
"outlook": "Outlook",
|
||||
"icloud": "iCloud",
|
||||
"custom": "自定义"
|
||||
},
|
||||
"fields": {
|
||||
"consentGranted": {
|
||||
"label": "已授权",
|
||||
"help": "必需的安全开关。仅在确定要连接此机器人邮箱后才开启。",
|
||||
"choices": {
|
||||
"true": "已授权",
|
||||
"false": "未授权"
|
||||
}
|
||||
},
|
||||
"imapHost": {
|
||||
"label": "IMAP 主机",
|
||||
"placeholder": "imap.gmail.com"
|
||||
},
|
||||
"imapUsername": {
|
||||
"label": "IMAP 用户名",
|
||||
"placeholder": "bot@example.com"
|
||||
},
|
||||
"imapPassword": {
|
||||
"label": "IMAP 密码",
|
||||
"placeholder": "应用专用密码",
|
||||
"help": "如果邮件服务商要求,请使用应用专用密码。"
|
||||
},
|
||||
"smtpHost": {
|
||||
"label": "SMTP 主机",
|
||||
"placeholder": "smtp.gmail.com"
|
||||
},
|
||||
"smtpUsername": {
|
||||
"label": "SMTP 用户名",
|
||||
"placeholder": "bot@example.com"
|
||||
},
|
||||
"smtpPassword": {
|
||||
"label": "SMTP 密码",
|
||||
"placeholder": "应用专用密码",
|
||||
"help": "通常与 IMAP 使用同一个应用专用密码。"
|
||||
},
|
||||
"imapPort": {
|
||||
"label": "IMAP 端口",
|
||||
"placeholder": "993"
|
||||
},
|
||||
"smtpPort": {
|
||||
"label": "SMTP 端口",
|
||||
"placeholder": "587"
|
||||
},
|
||||
"fromAddress": {
|
||||
"label": "发件地址",
|
||||
"placeholder": "bot@example.com"
|
||||
},
|
||||
"pollIntervalSeconds": {
|
||||
"label": "轮询间隔",
|
||||
"placeholder": "30"
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "允许的发件人",
|
||||
"placeholder": "邮箱地址,用逗号分隔",
|
||||
"help": "留空则要求发件人先完成配对。"
|
||||
},
|
||||
"verifyDkim": {
|
||||
"label": "验证 DKIM",
|
||||
"choices": {
|
||||
"true": "开启",
|
||||
"false": "关闭"
|
||||
}
|
||||
},
|
||||
"verifySpf": {
|
||||
"label": "验证 SPF",
|
||||
"choices": {
|
||||
"true": "开启",
|
||||
"false": "关闭"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"description": "讓 nanobot 接收並回覆電子郵件。",
|
||||
"requirements": "IMAP 收件匣、SMTP 寄件服務、應用程式密碼和明確授權",
|
||||
"setup": {
|
||||
"docsLabel": "開啟郵件設定指南",
|
||||
"officialLabel": "開啟應用程式密碼指南",
|
||||
"tryIt": "向已連接的信箱傳送一封測試郵件。",
|
||||
"summary": "郵件渠道透過 IMAP 讀取郵件並透過 SMTP 回覆。請使用專用信箱,並在啟用前明確授權。",
|
||||
"steps": [
|
||||
"建立專用信箱,並在服務商要求時建立應用程式密碼。",
|
||||
"選擇服務商預設,或手動填入 IMAP 和 SMTP 設定。",
|
||||
"授予權限,儲存並啟用郵件渠道,然後向信箱傳送一封測試郵件。"
|
||||
],
|
||||
"presets": {
|
||||
"gmail": "Gmail",
|
||||
"outlook": "Outlook",
|
||||
"icloud": "iCloud",
|
||||
"custom": "自訂"
|
||||
},
|
||||
"fields": {
|
||||
"consentGranted": {
|
||||
"label": "已授權",
|
||||
"help": "必要的安全開關。僅在確定要連接此機器人信箱後才開啟。",
|
||||
"choices": {
|
||||
"true": "已授權",
|
||||
"false": "未授權"
|
||||
}
|
||||
},
|
||||
"imapHost": {
|
||||
"label": "IMAP 主機",
|
||||
"placeholder": "imap.gmail.com"
|
||||
},
|
||||
"imapUsername": {
|
||||
"label": "IMAP 使用者名稱",
|
||||
"placeholder": "bot@example.com"
|
||||
},
|
||||
"imapPassword": {
|
||||
"label": "IMAP 密碼",
|
||||
"placeholder": "應用程式密碼",
|
||||
"help": "若郵件服務商要求,請使用應用程式密碼。"
|
||||
},
|
||||
"smtpHost": {
|
||||
"label": "SMTP 主機",
|
||||
"placeholder": "smtp.gmail.com"
|
||||
},
|
||||
"smtpUsername": {
|
||||
"label": "SMTP 使用者名稱",
|
||||
"placeholder": "bot@example.com"
|
||||
},
|
||||
"smtpPassword": {
|
||||
"label": "SMTP 密碼",
|
||||
"placeholder": "應用程式密碼",
|
||||
"help": "通常與 IMAP 使用同一個應用程式密碼。"
|
||||
},
|
||||
"imapPort": {
|
||||
"label": "IMAP 連接埠",
|
||||
"placeholder": "993"
|
||||
},
|
||||
"smtpPort": {
|
||||
"label": "SMTP 連接埠",
|
||||
"placeholder": "587"
|
||||
},
|
||||
"fromAddress": {
|
||||
"label": "寄件地址",
|
||||
"placeholder": "bot@example.com"
|
||||
},
|
||||
"pollIntervalSeconds": {
|
||||
"label": "輪詢間隔",
|
||||
"placeholder": "30"
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "允許的寄件者",
|
||||
"placeholder": "電子郵件地址,以逗號分隔",
|
||||
"help": "留空則要求寄件者先完成配對。"
|
||||
},
|
||||
"verifyDkim": {
|
||||
"label": "驗證 DKIM",
|
||||
"choices": {
|
||||
"true": "開啟",
|
||||
"false": "關閉"
|
||||
}
|
||||
},
|
||||
"verifySpf": {
|
||||
"label": "驗證 SPF",
|
||||
"choices": {
|
||||
"true": "開啟",
|
||||
"false": "關閉"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""Feishu/Lark channel package."""
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Dependency-free Feishu configuration model shared by management and runtime."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from nanobot.config.schema import Base
|
||||
|
||||
|
||||
class FeishuConfig(Base):
|
||||
"""Feishu/Lark channel configuration using WebSocket long connection."""
|
||||
|
||||
instance_id: str = "default"
|
||||
name: str = "nanobot"
|
||||
identity_key: str = ""
|
||||
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"
|
||||
done_emoji: str | None = None
|
||||
tool_hint_prefix: str = "\U0001f527"
|
||||
group_policy: Literal["open", "mention"] = "mention"
|
||||
reply_to_message: bool = False
|
||||
streaming: bool = True
|
||||
domain: Literal["feishu", "lark"] = "feishu"
|
||||
topic_isolation: bool = True
|
||||
|
||||
|
||||
def feishu_default_config() -> dict[str, object]:
|
||||
return FeishuConfig().model_dump(by_alias=True)
|
||||
|
||||
|
||||
__all__ = ["FeishuConfig", "feishu_default_config"]
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Short-lived WebUI channel connection sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from nanobot.channels.connect import ChannelConnectError, QueryParams, query_first
|
||||
from nanobot.channels.feishu import runtime as feishu
|
||||
from nanobot.channels.feishu.instances import DEFAULT_INSTANCE_ID, validate_instance_id
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FeishuConnectSession:
|
||||
id: str
|
||||
instance_id: str
|
||||
instance_name: str
|
||||
device_code: str
|
||||
qr_url: str
|
||||
domain: str
|
||||
interval: int
|
||||
expire_in: int
|
||||
created_wall: float
|
||||
deadline: float
|
||||
last_error: str | None = None
|
||||
|
||||
|
||||
class FeishuConnectStore:
|
||||
"""In-memory Feishu/Lark QR connection state.
|
||||
|
||||
Sessions intentionally live only in the gateway process and expire quickly.
|
||||
The app secret is never returned to the browser; it is saved directly to
|
||||
config when Feishu/Lark completes authorization.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._sessions: dict[str, FeishuConnectSession] = {}
|
||||
|
||||
async def handle(self, action: str, query: QueryParams) -> dict[str, Any]:
|
||||
"""Handle one generic settings connection action."""
|
||||
if action == "start":
|
||||
return await asyncio.to_thread(
|
||||
self.start,
|
||||
domain=(query_first(query, "domain") or "feishu").strip(),
|
||||
instance_id=(query_first(query, "instance_id") or "default").strip(),
|
||||
mode=(query_first(query, "mode") or "replace").strip(),
|
||||
)
|
||||
|
||||
session_id = (query_first(query, "session_id") or "").strip()
|
||||
if not session_id:
|
||||
raise ChannelConnectError("missing Feishu connect session")
|
||||
if action == "poll":
|
||||
return await asyncio.to_thread(self.poll, session_id)
|
||||
if action == "cancel":
|
||||
return self.cancel(session_id)
|
||||
raise ChannelConnectError(f"unsupported Feishu connect action: {action}", status=404)
|
||||
|
||||
def start(
|
||||
self,
|
||||
*,
|
||||
domain: str = "feishu",
|
||||
instance_id: str = DEFAULT_INSTANCE_ID,
|
||||
mode: str = "replace",
|
||||
) -> dict[str, Any]:
|
||||
domain = _normalize_domain(domain)
|
||||
instance_id = _resolve_instance_id(instance_id, mode)
|
||||
self._cleanup()
|
||||
try:
|
||||
feishu._init_registration(domain)
|
||||
begin = feishu._begin_registration(domain)
|
||||
except (RuntimeError, OSError, json.JSONDecodeError, httpx.HTTPError) as exc:
|
||||
raise ChannelConnectError(
|
||||
f"Unable to start Feishu/Lark connection: {exc}",
|
||||
status=502,
|
||||
) from exc
|
||||
|
||||
session_id = secrets.token_urlsafe(18)
|
||||
now_wall = time.time()
|
||||
now = time.monotonic()
|
||||
expire_in = int(begin["expire_in"])
|
||||
interval = max(2, int(begin["interval"]))
|
||||
session = FeishuConnectSession(
|
||||
id=session_id,
|
||||
instance_id=instance_id,
|
||||
instance_name=_default_instance_name(instance_id),
|
||||
device_code=str(begin["device_code"]),
|
||||
qr_url=str(begin["qr_url"]),
|
||||
domain=domain,
|
||||
interval=interval,
|
||||
expire_in=expire_in,
|
||||
created_wall=now_wall,
|
||||
deadline=now + expire_in,
|
||||
)
|
||||
self._sessions[session_id] = session
|
||||
return _start_payload(session)
|
||||
|
||||
def poll(self, session_id: str) -> dict[str, Any]:
|
||||
self._cleanup()
|
||||
session = self._sessions.get(session_id)
|
||||
if session is None:
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"status": "expired",
|
||||
"message": "This Feishu connection has expired. Start again.",
|
||||
}
|
||||
|
||||
if time.monotonic() >= session.deadline:
|
||||
self._sessions.pop(session_id, None)
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"status": "expired",
|
||||
"message": "This Feishu connection has expired. Start again.",
|
||||
}
|
||||
|
||||
try:
|
||||
result = feishu.poll_registration_once(
|
||||
device_code=session.device_code,
|
||||
domain=session.domain,
|
||||
)
|
||||
except (RuntimeError, OSError, json.JSONDecodeError, httpx.HTTPError) as exc:
|
||||
session.last_error = str(exc)
|
||||
return _pending_payload(session)
|
||||
|
||||
session.domain = str(result.get("domain") or session.domain)
|
||||
status = result.get("status")
|
||||
if status == "succeeded":
|
||||
session.instance_id = feishu.save_registration_result(
|
||||
result,
|
||||
instance_id=session.instance_id,
|
||||
name=session.instance_name,
|
||||
)
|
||||
self._sessions.pop(session_id, None)
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"instance_id": session.instance_id,
|
||||
"status": "succeeded",
|
||||
"message": "Feishu is connected.",
|
||||
"domain": session.domain,
|
||||
"app_id": result.get("app_id"),
|
||||
}
|
||||
|
||||
if status == "failed":
|
||||
self._sessions.pop(session_id, None)
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"instance_id": session.instance_id,
|
||||
"status": "failed",
|
||||
"message": "Authorization was cancelled or expired.",
|
||||
"domain": session.domain,
|
||||
}
|
||||
|
||||
return _pending_payload(session)
|
||||
|
||||
def cancel(self, session_id: str) -> dict[str, Any]:
|
||||
session = self._sessions.pop(session_id, None)
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"instance_id": session.instance_id if session else DEFAULT_INSTANCE_ID,
|
||||
"status": "cancelled",
|
||||
"message": "Feishu connection cancelled.",
|
||||
}
|
||||
|
||||
def _cleanup(self) -> None:
|
||||
now = time.monotonic()
|
||||
expired = [session_id for session_id, session in self._sessions.items() if now >= session.deadline]
|
||||
for session_id in expired:
|
||||
self._sessions.pop(session_id, None)
|
||||
|
||||
|
||||
def _normalize_domain(domain: str) -> str:
|
||||
normalized = domain.strip().lower()
|
||||
return normalized if normalized in {"feishu", "lark"} else "feishu"
|
||||
|
||||
|
||||
def _resolve_instance_id(instance_id: str, mode: str) -> str:
|
||||
if mode == "create":
|
||||
return f"assistant-{secrets.token_hex(3)}"
|
||||
try:
|
||||
return validate_instance_id(instance_id or DEFAULT_INSTANCE_ID)
|
||||
except ValueError as exc:
|
||||
raise ChannelConnectError(str(exc), status=400) from exc
|
||||
|
||||
|
||||
def _default_instance_name(instance_id: str) -> str:
|
||||
return "nanobot" if instance_id == DEFAULT_INSTANCE_ID else f"nanobot {instance_id}"
|
||||
|
||||
|
||||
def _start_payload(session: FeishuConnectSession) -> dict[str, Any]:
|
||||
return {
|
||||
"session_id": session.id,
|
||||
"instance_id": session.instance_id,
|
||||
"status": "pending",
|
||||
"qr_url": session.qr_url,
|
||||
"domain": session.domain,
|
||||
"interval_ms": session.interval * 1000,
|
||||
"expires_at_ms": int((session.created_wall + session.expire_in) * 1000),
|
||||
"message": "Scan with Feishu or Lark to connect.",
|
||||
}
|
||||
|
||||
|
||||
def _pending_payload(session: FeishuConnectSession) -> dict[str, Any]:
|
||||
return {
|
||||
"session_id": session.id,
|
||||
"instance_id": session.instance_id,
|
||||
"status": "pending",
|
||||
"domain": session.domain,
|
||||
"interval_ms": session.interval * 1000,
|
||||
"expires_at_ms": int((session.created_wall + session.expire_in) * 1000),
|
||||
"message": "Waiting for authorization.",
|
||||
}
|
||||
@@ -1,34 +1,20 @@
|
||||
"""Helpers for channel instance configuration.
|
||||
|
||||
The first consumer is Feishu/Lark. Keep the helpers small and data-oriented so
|
||||
ChannelManager can support Feishu assistant instances without turning every
|
||||
channel into a multi-instance abstraction.
|
||||
"""
|
||||
"""Feishu-owned helpers for its persisted multi-instance configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.channels.contracts import ChannelInstanceSpec, ChannelManagementSpec
|
||||
from nanobot.channels.feishu.config import feishu_default_config
|
||||
from nanobot.config.loader import merge_missing_defaults
|
||||
|
||||
DEFAULT_INSTANCE_ID = "default"
|
||||
_INSTANCE_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChannelInstanceSpec:
|
||||
"""Runtime description for one channel instance."""
|
||||
|
||||
base_name: str
|
||||
instance_id: str
|
||||
runtime_name: str
|
||||
config: dict[str, Any]
|
||||
|
||||
|
||||
def validate_instance_id(value: str) -> str:
|
||||
"""Return a normalized instance id or raise ValueError."""
|
||||
instance_id = value.strip()
|
||||
@@ -42,6 +28,33 @@ def runtime_channel_name(base_name: str, instance_id: str) -> str:
|
||||
return base_name if instance_id == DEFAULT_INSTANCE_ID else f"{base_name}.{instance_id}"
|
||||
|
||||
|
||||
def managed_feishu_instance_specs(
|
||||
section: Any,
|
||||
*,
|
||||
enabled_only: bool = True,
|
||||
) -> list[ChannelInstanceSpec]:
|
||||
return feishu_instance_specs(
|
||||
section,
|
||||
feishu_default_config(),
|
||||
enabled_only=enabled_only,
|
||||
)
|
||||
|
||||
|
||||
def update_managed_feishu_instance(
|
||||
section: Any,
|
||||
values: dict[str, Any],
|
||||
*,
|
||||
instance_id: str = DEFAULT_INSTANCE_ID,
|
||||
) -> dict[str, Any]:
|
||||
existing = section if isinstance(section, dict) else {}
|
||||
return upsert_feishu_instance(
|
||||
existing,
|
||||
feishu_default_config(),
|
||||
instance_id,
|
||||
values,
|
||||
)
|
||||
|
||||
|
||||
def _base_feishu_instance_config(defaults: dict[str, Any]) -> dict[str, Any]:
|
||||
config = dict(defaults)
|
||||
config["instanceId"] = DEFAULT_INSTANCE_ID
|
||||
@@ -67,6 +80,31 @@ def _normalize_feishu_instance(
|
||||
return config
|
||||
|
||||
|
||||
def feishu_app_identity_key(app_id: Any, domain: Any = "feishu") -> str:
|
||||
"""Return the stable identity shared by persisted and runtime instances."""
|
||||
app_id = str(app_id or "").strip()
|
||||
if not app_id:
|
||||
return ""
|
||||
normalized_domain = "lark" if str(domain or "feishu").strip().lower() == "lark" else "feishu"
|
||||
return f"{normalized_domain}:{app_id}"
|
||||
|
||||
|
||||
def _feishu_instance_inputs(
|
||||
section: Any,
|
||||
defaults: dict[str, Any],
|
||||
) -> tuple[list[Any], dict[str, Any] | None]:
|
||||
if hasattr(section, "model_dump"):
|
||||
section = section.model_dump(mode="json", by_alias=True)
|
||||
if not isinstance(section, dict):
|
||||
section = {}
|
||||
|
||||
instances = section.get("instances")
|
||||
if isinstance(instances, list):
|
||||
inherited = {key: value for key, value in section.items() if key != "instances"}
|
||||
return list(instances), inherited
|
||||
return ([section] if section else [_base_feishu_instance_config(defaults)]), None
|
||||
|
||||
|
||||
def feishu_instance_specs(
|
||||
section: Any,
|
||||
defaults: dict[str, Any],
|
||||
@@ -74,22 +112,15 @@ def feishu_instance_specs(
|
||||
enabled_only: bool = False,
|
||||
) -> list[ChannelInstanceSpec]:
|
||||
"""Expand legacy or canonical Feishu config into runtime instance specs."""
|
||||
if hasattr(section, "model_dump"):
|
||||
section = section.model_dump(mode="json", by_alias=True)
|
||||
if not isinstance(section, dict):
|
||||
section = {}
|
||||
|
||||
instances = section.get("instances")
|
||||
raw_specs: list[dict[str, Any]]
|
||||
inherited: dict[str, Any] | None = None
|
||||
if isinstance(instances, list):
|
||||
inherited = {key: value for key, value in section.items() if key != "instances"}
|
||||
raw_specs = [item for item in instances if isinstance(item, dict)]
|
||||
else:
|
||||
raw_specs = [section] if section else [_base_feishu_instance_config(defaults)]
|
||||
raw_specs, inherited = _feishu_instance_inputs(section, defaults)
|
||||
|
||||
specs: list[ChannelInstanceSpec] = []
|
||||
instance_ids: set[str] = set()
|
||||
identity_owners: dict[str, str] = {}
|
||||
for index, raw in enumerate(raw_specs):
|
||||
if not isinstance(raw, dict):
|
||||
logger.warning("Skipping invalid Feishu instance at index {}: expected an object", index)
|
||||
continue
|
||||
fallback_id = DEFAULT_INSTANCE_ID if index == 0 else f"assistant-{index + 1}"
|
||||
try:
|
||||
config = _normalize_feishu_instance(
|
||||
@@ -102,16 +133,33 @@ def feishu_instance_specs(
|
||||
logger.warning("Skipping invalid Feishu instance config: {}", exc)
|
||||
continue
|
||||
|
||||
instance_id = str(config["instanceId"])
|
||||
if instance_id in instance_ids:
|
||||
logger.warning("Skipping duplicate Feishu instance id '{}'", instance_id)
|
||||
continue
|
||||
|
||||
instance_ids.add(instance_id)
|
||||
enabled = bool(config.get("enabled", defaults.get("enabled", False)))
|
||||
if enabled_only and not enabled:
|
||||
continue
|
||||
|
||||
instance_id = str(config["instanceId"])
|
||||
identity = feishu_app_identity_key(
|
||||
config.get("appId") or config.get("app_id"),
|
||||
config.get("domain"),
|
||||
)
|
||||
if enabled_only and identity:
|
||||
if identity in identity_owners:
|
||||
logger.warning(
|
||||
"Skipping Feishu instance '{}' because it uses the same app as instance '{}'",
|
||||
instance_id,
|
||||
identity_owners[identity],
|
||||
)
|
||||
continue
|
||||
identity_owners[identity] = instance_id
|
||||
|
||||
specs.append(
|
||||
ChannelInstanceSpec(
|
||||
base_name="feishu",
|
||||
instance_id=instance_id,
|
||||
runtime_name=runtime_channel_name("feishu", instance_id),
|
||||
config=config,
|
||||
)
|
||||
)
|
||||
@@ -120,9 +168,32 @@ def feishu_instance_specs(
|
||||
|
||||
|
||||
def canonical_feishu_section(section: Any, defaults: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return Feishu config in the canonical ``instances`` shape."""
|
||||
specs = feishu_instance_specs(section, defaults)
|
||||
return {"instances": [dict(spec.config) for spec in specs]}
|
||||
"""Return a canonical section, rejecting input that cannot be preserved safely."""
|
||||
raw_specs, inherited = _feishu_instance_inputs(section, defaults)
|
||||
instances: list[dict[str, Any]] = []
|
||||
instance_ids: set[str] = set()
|
||||
|
||||
for index, raw in enumerate(raw_specs):
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"Feishu instance at index {index} must be an object")
|
||||
fallback_id = DEFAULT_INSTANCE_ID if index == 0 else f"assistant-{index + 1}"
|
||||
try:
|
||||
config = _normalize_feishu_instance(
|
||||
raw,
|
||||
defaults,
|
||||
inherited=inherited,
|
||||
fallback_id=fallback_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Invalid Feishu instance at index {index}: {exc}") from exc
|
||||
|
||||
instance_id = str(config["instanceId"])
|
||||
if instance_id in instance_ids:
|
||||
raise ValueError(f"duplicate Feishu instance id '{instance_id}'")
|
||||
instance_ids.add(instance_id)
|
||||
instances.append(config)
|
||||
|
||||
return {"instances": instances}
|
||||
|
||||
|
||||
def upsert_feishu_instance(
|
||||
@@ -174,11 +245,23 @@ def update_feishu_instance_preserving_shape(
|
||||
return upsert_feishu_instance(section, defaults, instance_id, values)
|
||||
|
||||
|
||||
def set_feishu_instance_enabled(
|
||||
section: Any,
|
||||
defaults: dict[str, Any],
|
||||
instance_id: str,
|
||||
enabled: bool,
|
||||
) -> dict[str, Any]:
|
||||
"""Return canonical Feishu section with one instance's enabled flag updated."""
|
||||
return upsert_feishu_instance(section, defaults, instance_id, {"enabled": enabled})
|
||||
FEISHU_MANAGEMENT = ChannelManagementSpec(
|
||||
multi_instance=True,
|
||||
default_config=feishu_default_config,
|
||||
instance_specs=managed_feishu_instance_specs,
|
||||
update_instance_config=update_managed_feishu_instance,
|
||||
runtime_name=runtime_channel_name,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_INSTANCE_ID",
|
||||
"FEISHU_MANAGEMENT",
|
||||
"canonical_feishu_section",
|
||||
"feishu_app_identity_key",
|
||||
"feishu_instance_specs",
|
||||
"runtime_channel_name",
|
||||
"update_feishu_instance_preserving_shape",
|
||||
"upsert_feishu_instance",
|
||||
"validate_instance_id",
|
||||
]
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Dependency-free Feishu/Lark management contract."""
|
||||
|
||||
from nanobot.channels._manifest import DIRECT_GROUP_POLICIES, field, required_fields
|
||||
from nanobot.channels.contracts import ChannelSetupSpec
|
||||
from nanobot.channels.feishu.instances import FEISHU_MANAGEMENT
|
||||
from nanobot.channels.feishu.validation import validate
|
||||
from nanobot.channels.plugin import ChannelPlugin
|
||||
|
||||
SETUP_SPEC = ChannelSetupSpec(
|
||||
fields={
|
||||
"appId": field(snapshot=False),
|
||||
"appSecret": field("secret", snapshot=False),
|
||||
"domain": field(
|
||||
"enum",
|
||||
choices={"feishu", "lark"},
|
||||
default="feishu",
|
||||
snapshot=False,
|
||||
),
|
||||
"groupPolicy": field(
|
||||
"enum",
|
||||
choices=DIRECT_GROUP_POLICIES,
|
||||
default="mention",
|
||||
snapshot=False,
|
||||
),
|
||||
"allowFrom": field("list", snapshot=False),
|
||||
"topicIsolation": field("bool", default=True, snapshot=False),
|
||||
},
|
||||
required=required_fields("appId", "appSecret"),
|
||||
official_url="https://open.feishu.cn/app",
|
||||
validator=validate,
|
||||
)
|
||||
|
||||
PLUGIN = ChannelPlugin(
|
||||
name="feishu",
|
||||
display_name="Feishu",
|
||||
runtime=f"{__package__}.runtime:FeishuChannel",
|
||||
connector=f"{__package__}.connect:FeishuConnectStore",
|
||||
setup=SETUP_SPEC,
|
||||
management=FEISHU_MANAGEMENT,
|
||||
dependencies=("lark-oapi>=1.5.0,<2.0.0",),
|
||||
webui="webui/index.tsx",
|
||||
)
|
||||
@@ -14,9 +14,9 @@ from collections import OrderedDict
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pydantic import Field
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
from rich.panel import Panel
|
||||
@@ -25,18 +25,20 @@ from rich.text import Text
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.outbound_events import ProgressEvent
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels._feishu_instances import (
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.channels.contracts import ChannelInstanceSpec
|
||||
from nanobot.channels.feishu.config import FeishuConfig, feishu_default_config
|
||||
from nanobot.channels.feishu.instances import (
|
||||
DEFAULT_INSTANCE_ID,
|
||||
feishu_app_identity_key,
|
||||
feishu_instance_specs,
|
||||
runtime_channel_name,
|
||||
update_feishu_instance_preserving_shape,
|
||||
upsert_feishu_instance,
|
||||
)
|
||||
from nanobot.channels._feishu_ws import get_feishu_ws_runner
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.channels.feishu.websocket import get_feishu_ws_runner
|
||||
from nanobot.command.router import normalize_command_text
|
||||
from nanobot.config.paths import get_media_dir
|
||||
from nanobot.config.schema import Base
|
||||
from nanobot.pairing import clear_channel
|
||||
from nanobot.utils.helpers import safe_filename
|
||||
from nanobot.utils.logging_bridge import redirect_lib_logging
|
||||
@@ -46,6 +48,7 @@ if TYPE_CHECKING:
|
||||
|
||||
FEISHU_AVAILABLE = importlib.util.find_spec("lark_oapi") is not None
|
||||
_LOGIN_CONSOLE = Console()
|
||||
_LARK_RUNTIME_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _identity_timestamp() -> str:
|
||||
@@ -60,27 +63,32 @@ def _load_lark_runtime() -> tuple[Any, str, str]:
|
||||
"""
|
||||
import sys
|
||||
|
||||
ws_client_already_imported = "lark_oapi.ws.client" in sys.modules
|
||||
import lark_oapi as lark
|
||||
import lark_oapi.ws.client as lark_ws_client
|
||||
from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN
|
||||
# The SDK creates a module-global event loop while importing its WebSocket
|
||||
# client. Multiple Feishu instances start concurrently, so serialize this
|
||||
# one-time import and cleanup rather than allowing two worker threads to
|
||||
# close the same loop.
|
||||
with _LARK_RUNTIME_LOCK:
|
||||
ws_client_already_imported = "lark_oapi.ws.client" in sys.modules
|
||||
import lark_oapi as lark
|
||||
import lark_oapi.ws.client as lark_ws_client
|
||||
from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN
|
||||
|
||||
if (
|
||||
not ws_client_already_imported
|
||||
and threading.current_thread() is not threading.main_thread()
|
||||
):
|
||||
import_loop = getattr(lark_ws_client, "loop", None)
|
||||
if (
|
||||
import_loop is not None
|
||||
and not import_loop.is_running()
|
||||
and not import_loop.is_closed()
|
||||
not ws_client_already_imported
|
||||
and threading.current_thread() is not threading.main_thread()
|
||||
):
|
||||
import_loop.close()
|
||||
lark_ws_client.loop = None
|
||||
with suppress(Exception):
|
||||
asyncio.set_event_loop(None)
|
||||
import_loop = getattr(lark_ws_client, "loop", None)
|
||||
if (
|
||||
import_loop is not None
|
||||
and not import_loop.is_running()
|
||||
and not import_loop.is_closed()
|
||||
):
|
||||
import_loop.close()
|
||||
lark_ws_client.loop = None
|
||||
with suppress(Exception):
|
||||
asyncio.set_event_loop(None)
|
||||
|
||||
return lark, FEISHU_DOMAIN, LARK_DOMAIN
|
||||
return lark, FEISHU_DOMAIN, LARK_DOMAIN
|
||||
|
||||
|
||||
def fetch_feishu_app_identity(
|
||||
@@ -406,28 +414,6 @@ def _extract_post_text(content_json: dict) -> str:
|
||||
return text
|
||||
|
||||
|
||||
class FeishuConfig(Base):
|
||||
"""Feishu/Lark channel configuration using WebSocket long connection."""
|
||||
|
||||
instance_id: str = DEFAULT_INSTANCE_ID
|
||||
name: str = "nanobot"
|
||||
identity_key: str = ""
|
||||
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"
|
||||
done_emoji: str | None = None # Emoji to show when task is completed (e.g., "DONE", "OK")
|
||||
tool_hint_prefix: str = "\U0001f527" # Prefix for inline tool hints (default: 🔧)
|
||||
group_policy: Literal["open", "mention"] = "mention"
|
||||
reply_to_message: bool = False # If True, bot replies quote the user's original message
|
||||
streaming: bool = True
|
||||
domain: Literal["feishu", "lark"] = "feishu" # Set to "lark" for international Lark
|
||||
topic_isolation: bool = True # If True, each topic in group chat gets its own session (isolation)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# QR scan-to-create onboarding
|
||||
#
|
||||
@@ -590,12 +576,6 @@ def poll_registration_once(
|
||||
}
|
||||
|
||||
|
||||
def _feishu_app_identity_key(app_id: str, domain: str) -> str:
|
||||
normalized_app_id = app_id.strip()
|
||||
normalized_domain = "lark" if domain.strip().lower() == "lark" else "feishu"
|
||||
return f"{normalized_domain}:{normalized_app_id}" if normalized_app_id else ""
|
||||
|
||||
|
||||
def _saved_feishu_instance_identity_key(
|
||||
feishu_cfg: Any,
|
||||
defaults: dict[str, Any],
|
||||
@@ -603,13 +583,32 @@ def _saved_feishu_instance_identity_key(
|
||||
) -> str:
|
||||
for spec in feishu_instance_specs(feishu_cfg, defaults):
|
||||
if spec.instance_id == instance_id:
|
||||
return _feishu_app_identity_key(
|
||||
return feishu_app_identity_key(
|
||||
str(spec.config.get("appId") or spec.config.get("app_id") or ""),
|
||||
str(spec.config.get("domain") or "feishu"),
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
def _saved_feishu_instance_for_identity(
|
||||
feishu_cfg: Any,
|
||||
defaults: dict[str, Any],
|
||||
app_id: str,
|
||||
domain: str,
|
||||
) -> ChannelInstanceSpec | None:
|
||||
identity_key = feishu_app_identity_key(app_id, domain)
|
||||
if not identity_key:
|
||||
return None
|
||||
for spec in feishu_instance_specs(feishu_cfg, defaults):
|
||||
saved_identity = feishu_app_identity_key(
|
||||
str(spec.config.get("appId") or spec.config.get("app_id") or ""),
|
||||
str(spec.config.get("domain") or "feishu"),
|
||||
)
|
||||
if saved_identity == identity_key:
|
||||
return spec
|
||||
return None
|
||||
|
||||
|
||||
def sync_saved_feishu_identity_boundary(
|
||||
*,
|
||||
instance_id: str,
|
||||
@@ -622,7 +621,7 @@ def sync_saved_feishu_identity_boundary(
|
||||
manual config edits so approved users do not accidentally carry over to a
|
||||
different Feishu/Lark app in the same local instance slot.
|
||||
"""
|
||||
current_identity_key = _feishu_app_identity_key(app_id, domain)
|
||||
current_identity_key = feishu_app_identity_key(app_id, domain)
|
||||
if not current_identity_key:
|
||||
return False
|
||||
|
||||
@@ -633,7 +632,7 @@ def sync_saved_feishu_identity_boundary(
|
||||
if not isinstance(feishu_cfg, dict):
|
||||
feishu_cfg = {}
|
||||
|
||||
defaults = FeishuChannel.default_config()
|
||||
defaults = feishu_default_config()
|
||||
previous_identity_key = ""
|
||||
for spec in feishu_instance_specs(feishu_cfg, defaults):
|
||||
if spec.instance_id == instance_id:
|
||||
@@ -667,7 +666,7 @@ def save_registration_result(
|
||||
*,
|
||||
instance_id: str = DEFAULT_INSTANCE_ID,
|
||||
name: str | None = None,
|
||||
) -> None:
|
||||
) -> str:
|
||||
"""Persist a successful Feishu/Lark registration result to config.json."""
|
||||
from nanobot.config.loader import load_config, save_config
|
||||
|
||||
@@ -675,12 +674,18 @@ def save_registration_result(
|
||||
feishu_cfg = getattr(full_config.channels, "feishu", None) or {}
|
||||
if not isinstance(feishu_cfg, dict):
|
||||
feishu_cfg = {}
|
||||
defaults = FeishuChannel.default_config()
|
||||
defaults = feishu_default_config()
|
||||
app_id = str(result["app_id"]).strip()
|
||||
domain = str(result.get("domain", "feishu") or "feishu").strip().lower()
|
||||
domain = "lark" if domain == "lark" else "feishu"
|
||||
previous_identity_key = _saved_feishu_instance_identity_key(feishu_cfg, defaults, instance_id)
|
||||
next_identity_key = _feishu_app_identity_key(app_id, domain)
|
||||
existing = _saved_feishu_instance_for_identity(feishu_cfg, defaults, app_id, domain)
|
||||
effective_instance_id = existing.instance_id if existing is not None else instance_id
|
||||
previous_identity_key = _saved_feishu_instance_identity_key(
|
||||
feishu_cfg,
|
||||
defaults,
|
||||
effective_instance_id,
|
||||
)
|
||||
next_identity_key = feishu_app_identity_key(app_id, domain)
|
||||
identity_changed = bool(previous_identity_key and previous_identity_key != next_identity_key)
|
||||
identity: dict[str, str] = {}
|
||||
with suppress(Exception):
|
||||
@@ -689,8 +694,19 @@ def save_registration_result(
|
||||
str(result["app_secret"]),
|
||||
domain,
|
||||
)
|
||||
default_name = (
|
||||
"nanobot"
|
||||
if effective_instance_id == DEFAULT_INSTANCE_ID
|
||||
else f"nanobot {effective_instance_id}"
|
||||
)
|
||||
existing_name = existing.config.get("name") if existing is not None else None
|
||||
saved_name = (
|
||||
existing_name
|
||||
if existing is not None and existing.instance_id != instance_id
|
||||
else name
|
||||
)
|
||||
values = {
|
||||
"name": name or ("nanobot" if instance_id == DEFAULT_INSTANCE_ID else f"nanobot {instance_id}"),
|
||||
"name": str(saved_name or default_name),
|
||||
"appId": app_id,
|
||||
"appSecret": result["app_secret"],
|
||||
"domain": domain,
|
||||
@@ -701,18 +717,24 @@ def save_registration_result(
|
||||
if identity_changed:
|
||||
values["allowFrom"] = []
|
||||
values["allow_from"] = []
|
||||
clear_channel(runtime_channel_name("feishu", instance_id))
|
||||
clear_channel(runtime_channel_name("feishu", effective_instance_id))
|
||||
feishu_cfg = upsert_feishu_instance(
|
||||
feishu_cfg,
|
||||
defaults,
|
||||
instance_id,
|
||||
effective_instance_id,
|
||||
values,
|
||||
)
|
||||
setattr(full_config.channels, "feishu", feishu_cfg)
|
||||
save_config(full_config)
|
||||
return effective_instance_id
|
||||
|
||||
|
||||
def refresh_saved_feishu_identities(config: Any | None = None) -> bool:
|
||||
def refresh_saved_feishu_identities(
|
||||
config: Any | None = None,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
instance_id: str | None = None,
|
||||
) -> bool:
|
||||
"""Backfill missing Feishu assistant display identity in saved config.
|
||||
|
||||
Existing users may already have working App ID/Secret credentials from
|
||||
@@ -727,8 +749,10 @@ def refresh_saved_feishu_identities(config: Any | None = None) -> bool:
|
||||
|
||||
full_config = config or load_config()
|
||||
feishu_cfg = getattr(full_config.channels, "feishu", None)
|
||||
defaults = FeishuChannel.default_config()
|
||||
defaults = feishu_default_config()
|
||||
specs = feishu_instance_specs(feishu_cfg, defaults)
|
||||
if instance_id:
|
||||
specs = [spec for spec in specs if spec.instance_id == instance_id]
|
||||
updated = False
|
||||
|
||||
for spec in specs:
|
||||
@@ -765,7 +789,7 @@ def refresh_saved_feishu_identities(config: Any | None = None) -> bool:
|
||||
return False
|
||||
|
||||
setattr(full_config.channels, "feishu", feishu_cfg)
|
||||
save_config(full_config)
|
||||
save_config(full_config, config_path)
|
||||
return True
|
||||
|
||||
|
||||
@@ -869,7 +893,22 @@ class FeishuChannel(BaseChannel):
|
||||
|
||||
@classmethod
|
||||
def default_config(cls) -> dict[str, Any]:
|
||||
return FeishuConfig().model_dump(by_alias=True)
|
||||
return feishu_default_config()
|
||||
|
||||
@classmethod
|
||||
def refresh_feature_metadata(
|
||||
cls,
|
||||
config_path: Path,
|
||||
*,
|
||||
instance_id: str = DEFAULT_INSTANCE_ID,
|
||||
) -> bool:
|
||||
from nanobot.config.loader import load_config
|
||||
|
||||
return refresh_saved_feishu_identities(
|
||||
load_config(config_path),
|
||||
config_path=config_path,
|
||||
instance_id=instance_id,
|
||||
)
|
||||
|
||||
def __init__(self, config: Any, bus: MessageBus):
|
||||
if isinstance(config, dict):
|
||||
@@ -963,7 +1002,7 @@ class FeishuChannel(BaseChannel):
|
||||
app_id=self.config.app_id,
|
||||
domain=self.config.domain,
|
||||
):
|
||||
self.config.identity_key = _feishu_app_identity_key(self.config.app_id, self.config.domain)
|
||||
self.config.identity_key = feishu_app_identity_key(self.config.app_id, self.config.domain)
|
||||
self.config.allow_from = []
|
||||
self.logger.info(
|
||||
"Feishu app identity changed for {}; cleared paired users for this assistant",
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the Feishu channel package."""
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import json
|
||||
|
||||
from nanobot.channels.feishu import _extract_share_card_content
|
||||
from nanobot.channels.feishu.runtime import _extract_share_card_content
|
||||
|
||||
|
||||
def test_extract_interactive_card_reads_user_dsl_body_elements() -> None:
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.feishu import FeishuChannel, FeishuConfig
|
||||
from nanobot.channels.feishu.runtime import FeishuChannel, FeishuConfig
|
||||
|
||||
|
||||
def _make_channel(domain: str = "feishu") -> FeishuChannel:
|
||||
@@ -0,0 +1,99 @@
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def _run_import_probe(source: str) -> str:
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-c", source],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return proc.stdout.strip()
|
||||
|
||||
|
||||
def test_feishu_module_import_does_not_import_lark_oapi():
|
||||
out = _run_import_probe(
|
||||
"import sys; import nanobot.channels.feishu; print('lark_oapi' in sys.modules)"
|
||||
)
|
||||
|
||||
assert out == "False"
|
||||
|
||||
|
||||
def test_feishu_channel_constructor_does_not_import_lark_oapi():
|
||||
out = _run_import_probe(
|
||||
"import sys; "
|
||||
"from nanobot.bus.queue import MessageBus; "
|
||||
"from nanobot.channels.feishu.runtime import FeishuChannel; "
|
||||
"FeishuChannel({'enabled': True}, MessageBus()); "
|
||||
"print('lark_oapi' in sys.modules)"
|
||||
)
|
||||
|
||||
assert out == "False"
|
||||
|
||||
|
||||
def test_lark_runtime_thread_import_clears_sdk_import_loop():
|
||||
out = _run_import_probe(
|
||||
"import asyncio\n"
|
||||
"import sys\n"
|
||||
"import tempfile\n"
|
||||
"from pathlib import Path\n"
|
||||
"from nanobot.channels.feishu.runtime import _load_lark_runtime\n"
|
||||
"root = Path(tempfile.mkdtemp())\n"
|
||||
"pkg = root / 'lark_oapi'\n"
|
||||
"(pkg / 'ws').mkdir(parents=True)\n"
|
||||
"(pkg / 'core').mkdir(parents=True)\n"
|
||||
"(pkg / '__init__.py').write_text('class LogLevel:\\n INFO = 20\\n')\n"
|
||||
"(pkg / 'ws' / '__init__.py').write_text('')\n"
|
||||
"(pkg / 'ws' / 'client.py').write_text('import asyncio\\nloop = asyncio.new_event_loop()\\n')\n"
|
||||
"(pkg / 'core' / '__init__.py').write_text('')\n"
|
||||
"(pkg / 'core' / 'const.py').write_text(\"FEISHU_DOMAIN = 'feishu'\\nLARK_DOMAIN = 'lark'\\n\")\n"
|
||||
"sys.path.insert(0, str(root))\n"
|
||||
"async def main():\n"
|
||||
" await asyncio.to_thread(_load_lark_runtime)\n"
|
||||
" import lark_oapi.ws.client as ws\n"
|
||||
" print(getattr(ws, 'loop', 'sentinel') is None)\n"
|
||||
"asyncio.run(main())"
|
||||
)
|
||||
|
||||
assert out == "True"
|
||||
|
||||
|
||||
def test_lark_runtime_thread_import_is_serialized_for_multiple_instances():
|
||||
out = _run_import_probe(
|
||||
"import asyncio\n"
|
||||
"import sys\n"
|
||||
"import tempfile\n"
|
||||
"from pathlib import Path\n"
|
||||
"from nanobot.channels.feishu.runtime import _load_lark_runtime\n"
|
||||
"root = Path(tempfile.mkdtemp())\n"
|
||||
"pkg = root / 'lark_oapi'\n"
|
||||
"(pkg / 'ws').mkdir(parents=True)\n"
|
||||
"(pkg / 'core').mkdir(parents=True)\n"
|
||||
"(pkg / '__init__.py').write_text('class LogLevel:\\n INFO = 20\\n')\n"
|
||||
"(pkg / 'ws' / '__init__.py').write_text('')\n"
|
||||
"(pkg / 'ws' / 'client.py').write_text(\n"
|
||||
" 'import time\\n'\n"
|
||||
" 'class ImportLoop:\\n'\n"
|
||||
" ' closed = False\\n'\n"
|
||||
" ' close_calls = 0\\n'\n"
|
||||
" ' def is_running(self): return False\\n'\n"
|
||||
" ' def is_closed(self): return self.closed\\n'\n"
|
||||
" ' def close(self):\\n'\n"
|
||||
" ' self.close_calls += 1\\n'\n"
|
||||
" ' time.sleep(0.05)\\n'\n"
|
||||
" ' if self.close_calls > 1: raise AttributeError(\"closed twice\")\\n'\n"
|
||||
" ' self.closed = True\\n'\n"
|
||||
" 'loop = ImportLoop()\\n'\n"
|
||||
")\n"
|
||||
"(pkg / 'core' / '__init__.py').write_text('')\n"
|
||||
"(pkg / 'core' / 'const.py').write_text(\"FEISHU_DOMAIN = 'feishu'\\nLARK_DOMAIN = 'lark'\\n\")\n"
|
||||
"sys.path.insert(0, str(root))\n"
|
||||
"async def main():\n"
|
||||
" await asyncio.gather(*[asyncio.to_thread(_load_lark_runtime) for _ in range(8)])\n"
|
||||
" import lark_oapi.ws.client as ws\n"
|
||||
" print(ws.loop is None)\n"
|
||||
"asyncio.run(main())"
|
||||
)
|
||||
|
||||
assert out == "True"
|
||||
+44
-2
@@ -3,8 +3,8 @@ import json
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nanobot.channels import feishu as feishu_module
|
||||
from nanobot.channels.feishu import FeishuChannel
|
||||
from nanobot.channels.feishu import runtime as feishu_module
|
||||
from nanobot.channels.feishu.runtime import FeishuChannel
|
||||
from nanobot.config import loader
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.pairing import store as pairing_store
|
||||
@@ -115,6 +115,48 @@ def test_save_registration_result_keeps_credentials_when_identity_fetch_fails(mo
|
||||
assert "avatarUrl" not in instance
|
||||
|
||||
|
||||
def test_save_registration_result_reuses_existing_app_instance(monkeypatch, tmp_path):
|
||||
config_path = tmp_path / "config.json"
|
||||
config = Config()
|
||||
config.channels.feishu = {
|
||||
"instances": [
|
||||
{
|
||||
"id": "default",
|
||||
"instanceId": "default",
|
||||
"name": "nanobot",
|
||||
"enabled": True,
|
||||
"appId": "cli_same",
|
||||
"appSecret": "old-secret",
|
||||
"domain": "feishu",
|
||||
"identityKey": "feishu:cli_same",
|
||||
"allowFrom": ["approved-user"],
|
||||
}
|
||||
]
|
||||
}
|
||||
loader.save_config(config, config_path)
|
||||
monkeypatch.setattr(loader, "_current_config_path", config_path)
|
||||
monkeypatch.setattr(feishu_module, "fetch_feishu_app_identity", lambda *_args: {})
|
||||
|
||||
effective_id = feishu_module.save_registration_result(
|
||||
{
|
||||
"app_id": "cli_same",
|
||||
"app_secret": "rotated-secret",
|
||||
"domain": "feishu",
|
||||
},
|
||||
instance_id="assistant-new",
|
||||
name="nanobot assistant-new",
|
||||
)
|
||||
|
||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
instances = data["channels"]["feishu"]["instances"]
|
||||
assert effective_id == "default"
|
||||
assert len(instances) == 1
|
||||
assert instances[0]["id"] == "default"
|
||||
assert instances[0]["name"] == "nanobot"
|
||||
assert instances[0]["appSecret"] == "rotated-secret"
|
||||
assert instances[0]["allowFrom"] == ["approved-user"]
|
||||
|
||||
|
||||
def test_save_registration_result_resets_access_when_instance_app_changes(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
+1
-1
@@ -9,7 +9,7 @@ if not FEISHU_AVAILABLE:
|
||||
import pytest
|
||||
pytest.skip("Feishu dependencies not installed (lark-oapi)", allow_module_level=True)
|
||||
|
||||
from nanobot.channels.feishu import FeishuChannel
|
||||
from nanobot.channels.feishu.runtime import FeishuChannel
|
||||
|
||||
|
||||
def test_parse_md_table_strips_markdown_formatting_in_headers_and_cells() -> None:
|
||||
+2
-2
@@ -3,8 +3,8 @@ from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.channels import feishu as feishu_module
|
||||
from nanobot.channels.feishu import FeishuChannel
|
||||
from nanobot.channels.feishu import runtime as feishu_module
|
||||
from nanobot.channels.feishu.runtime import FeishuChannel
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from nanobot.channels.feishu import FeishuChannel
|
||||
from nanobot.channels.feishu.runtime import FeishuChannel
|
||||
|
||||
|
||||
def _make_channel(bot_open_id: str | None = None) -> FeishuChannel:
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from nanobot.channels.feishu import FeishuChannel
|
||||
from nanobot.channels.feishu.runtime import FeishuChannel
|
||||
|
||||
|
||||
def _mention(key: str, name: str, open_id: str = "", user_id: str = ""):
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# Check optional Feishu dependencies before running tests
|
||||
try:
|
||||
from nanobot.channels import feishu
|
||||
from nanobot.channels.feishu import runtime as feishu
|
||||
FEISHU_AVAILABLE = getattr(feishu, "FEISHU_AVAILABLE", False)
|
||||
except ImportError:
|
||||
FEISHU_AVAILABLE = False
|
||||
@@ -9,7 +9,7 @@ if not FEISHU_AVAILABLE:
|
||||
import pytest
|
||||
pytest.skip("Feishu dependencies not installed (lark-oapi)", allow_module_level=True)
|
||||
|
||||
from nanobot.channels.feishu import FeishuChannel, _extract_post_content
|
||||
from nanobot.channels.feishu.runtime import FeishuChannel, _extract_post_content
|
||||
|
||||
|
||||
def test_extract_post_content_supports_post_wrapper_shape() -> None:
|
||||
+3
-1
@@ -1,3 +1,5 @@
|
||||
# ruff: noqa: E402
|
||||
|
||||
"""Tests for Feishu reaction add/remove and auto-cleanup on stream end."""
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
@@ -7,7 +9,7 @@ import pytest
|
||||
pytest.importorskip("lark_oapi")
|
||||
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.feishu import FeishuChannel, FeishuConfig, _FeishuStreamBuf
|
||||
from nanobot.channels.feishu.runtime import FeishuChannel, FeishuConfig, _FeishuStreamBuf
|
||||
|
||||
|
||||
def _make_channel() -> FeishuChannel:
|
||||
+2
-2
@@ -9,7 +9,7 @@ import pytest
|
||||
|
||||
# Check optional Feishu dependencies before running tests
|
||||
try:
|
||||
from nanobot.channels import feishu
|
||||
from nanobot.channels.feishu import runtime as feishu
|
||||
FEISHU_AVAILABLE = getattr(feishu, "FEISHU_AVAILABLE", False)
|
||||
except ImportError:
|
||||
FEISHU_AVAILABLE = False
|
||||
@@ -20,7 +20,7 @@ if not FEISHU_AVAILABLE:
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.outbound_events import ProgressEvent
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.feishu import FeishuChannel, FeishuConfig
|
||||
from nanobot.channels.feishu.runtime import FeishuChannel, FeishuConfig
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
+3
-1
@@ -1,3 +1,5 @@
|
||||
# ruff: noqa: E402
|
||||
|
||||
"""Tests for Feishu streaming (send_delta) via CardKit streaming API."""
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
@@ -10,7 +12,7 @@ pytest.importorskip("lark_oapi")
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.outbound_events import ProgressEvent
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.feishu import FeishuChannel, FeishuConfig, _FeishuStreamBuf
|
||||
from nanobot.channels.feishu.runtime import FeishuChannel, FeishuConfig, _FeishuStreamBuf
|
||||
|
||||
|
||||
def _make_channel(streaming: bool = True, reply_to_message: bool = False) -> FeishuChannel:
|
||||
+1
-1
@@ -17,7 +17,7 @@ if not FEISHU_AVAILABLE:
|
||||
import pytest
|
||||
pytest.skip("Feishu dependencies not installed (lark-oapi)", allow_module_level=True)
|
||||
|
||||
from nanobot.channels.feishu import FeishuChannel
|
||||
from nanobot.channels.feishu.runtime import FeishuChannel
|
||||
|
||||
|
||||
def _md(text: str) -> dict:
|
||||
+1
-1
@@ -18,7 +18,7 @@ if not FEISHU_AVAILABLE:
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.outbound_events import ProgressEvent
|
||||
from nanobot.channels.feishu import FeishuChannel
|
||||
from nanobot.channels.feishu.runtime import FeishuChannel
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from nanobot.channels.feishu.websocket import FeishuWsRunner
|
||||
|
||||
|
||||
class _CleanCloseError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class _SdkLikeClient:
|
||||
"""Model the lark SDK's detached receive task and reconnect behavior."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._auto_reconnect = True
|
||||
self.connected = asyncio.Event()
|
||||
self.reconnected = asyncio.Event()
|
||||
self.receive_errors = 0
|
||||
self.reconnects = 0
|
||||
self.disconnects = 0
|
||||
self._receiving = False
|
||||
self._receive_events: asyncio.Queue[Exception] = asyncio.Queue()
|
||||
|
||||
async def _connect(self) -> None:
|
||||
self.connected.set()
|
||||
asyncio.create_task(self._receive_message_loop())
|
||||
|
||||
async def _receive_message_loop(self) -> None:
|
||||
try:
|
||||
self._receiving = True
|
||||
error = await self._receive_events.get()
|
||||
self._receiving = False
|
||||
raise error
|
||||
except asyncio.CancelledError:
|
||||
self._receiving = False
|
||||
raise
|
||||
except Exception:
|
||||
self.receive_errors += 1
|
||||
await self._disconnect()
|
||||
if self._auto_reconnect:
|
||||
self.reconnects += 1
|
||||
await self._connect()
|
||||
self.reconnected.set()
|
||||
|
||||
async def _disconnect(self) -> None:
|
||||
self.disconnects += 1
|
||||
if self._receiving:
|
||||
await self._receive_events.put(_CleanCloseError("1000 OK"))
|
||||
|
||||
async def _ping_loop(self) -> None:
|
||||
await asyncio.Event().wait()
|
||||
|
||||
|
||||
def test_concurrent_loop_initialization_starts_one_thread(monkeypatch) -> None:
|
||||
runner = FeishuWsRunner()
|
||||
created_loops: list[asyncio.AbstractEventLoop] = []
|
||||
release_start = threading.Event()
|
||||
|
||||
def fake_run_loop() -> None:
|
||||
loop = asyncio.new_event_loop()
|
||||
created_loops.append(loop)
|
||||
assert release_start.wait(timeout=2)
|
||||
runner._loop = loop
|
||||
runner._ready.set()
|
||||
|
||||
monkeypatch.setattr(runner, "_run_loop", fake_run_loop)
|
||||
loops: list[asyncio.AbstractEventLoop] = []
|
||||
threads = [threading.Thread(target=lambda: loops.append(runner._ensure_loop())) for _ in range(2)]
|
||||
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
release_start.set()
|
||||
for thread in threads:
|
||||
thread.join(timeout=2)
|
||||
|
||||
assert len(created_loops) == 1
|
||||
assert loops == [created_loops[0], created_loops[0]]
|
||||
created_loops[0].close()
|
||||
|
||||
|
||||
async def test_stop_cancels_sdk_receive_loop_without_reconnecting() -> None:
|
||||
runner = FeishuWsRunner()
|
||||
client = _SdkLikeClient()
|
||||
original_receive_loop: Any = client._receive_message_loop
|
||||
|
||||
await runner._start_client("default", client)
|
||||
await asyncio.wait_for(client.connected.wait(), timeout=1)
|
||||
await runner._stop_client("default")
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert client.receive_errors == 0
|
||||
assert client.reconnects == 0
|
||||
assert client._auto_reconnect is True
|
||||
assert client._receive_message_loop == original_receive_loop
|
||||
|
||||
|
||||
async def test_network_failure_keeps_sdk_auto_reconnect_behavior() -> None:
|
||||
runner = FeishuWsRunner()
|
||||
client = _SdkLikeClient()
|
||||
|
||||
await runner._start_client("default", client)
|
||||
await asyncio.wait_for(client.connected.wait(), timeout=1)
|
||||
await client._receive_events.put(RuntimeError("network dropped"))
|
||||
await asyncio.wait_for(client.reconnected.wait(), timeout=1)
|
||||
|
||||
assert client.receive_errors == 1
|
||||
assert client.reconnects == 1
|
||||
assert client._auto_reconnect is True
|
||||
|
||||
await runner._stop_client("default")
|
||||
await asyncio.sleep(0)
|
||||
assert client.receive_errors == 1
|
||||
assert client.reconnects == 1
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Feishu/Lark setup validation owned by the channel package."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from nanobot.channels.contracts import ChannelValidationContext
|
||||
from nanobot.channels.validation import check, payload, required_checks, string_value
|
||||
|
||||
|
||||
def validate(values: dict[str, Any], _context: ChannelValidationContext) -> dict[str, Any]:
|
||||
checks, missing = required_checks("feishu", values)
|
||||
display_name = string_value(values.get("displayName") or values.get("name"))
|
||||
avatar_url = string_value(values.get("avatarUrl"))
|
||||
app_id = string_value(values.get("appId"))
|
||||
if app_id.startswith(("cli_", "oapi_")):
|
||||
checks.append(check("app_id", "App ID", "pass", "A Feishu/Lark App ID is saved."))
|
||||
elif app_id:
|
||||
checks.append(
|
||||
check(
|
||||
"app_id",
|
||||
"App ID",
|
||||
"warn",
|
||||
"App ID is saved, but it does not look like a standard Feishu App ID.",
|
||||
)
|
||||
)
|
||||
status = "connected" if not missing else "needs_setup"
|
||||
identity = {
|
||||
"name": display_name or "Feishu assistant",
|
||||
"avatar_url": avatar_url or None,
|
||||
"account": app_id,
|
||||
}
|
||||
return payload("feishu", status, checks, identity=identity, missing_fields=missing)
|
||||
|
||||
|
||||
__all__ = ["validate"]
|
||||
@@ -10,18 +10,35 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from collections.abc import Awaitable, Callable
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Protocol
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class _LarkWsClient(Protocol):
|
||||
"""Private SDK surface isolated behind the Feishu runtime adapter."""
|
||||
|
||||
_auto_reconnect: bool
|
||||
_receive_message_loop: Callable[[], Awaitable[None]]
|
||||
|
||||
async def _connect(self) -> None: ...
|
||||
|
||||
async def _disconnect(self) -> None: ...
|
||||
|
||||
async def _ping_loop(self) -> None: ...
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ClientRuntime:
|
||||
client: Any
|
||||
client: _LarkWsClient
|
||||
stop_event: asyncio.Event
|
||||
task: asyncio.Task
|
||||
task: asyncio.Task[Any] | None
|
||||
receive_loop: Callable[[], Awaitable[None]]
|
||||
auto_reconnect: bool
|
||||
receive_tasks: set[asyncio.Task[Any]] = field(default_factory=set)
|
||||
|
||||
|
||||
class FeishuWsRunner:
|
||||
@@ -34,7 +51,7 @@ class FeishuWsRunner:
|
||||
self._lock = threading.Lock()
|
||||
self._clients: dict[str, _ClientRuntime] = {}
|
||||
|
||||
async def start_client(self, key: str, client: Any) -> None:
|
||||
async def start_client(self, key: str, client: _LarkWsClient) -> None:
|
||||
"""Start or replace one client runtime."""
|
||||
loop = self._ensure_loop()
|
||||
await asyncio.wrap_future(
|
||||
@@ -74,24 +91,63 @@ class FeishuWsRunner:
|
||||
loop.run_until_complete(loop.shutdown_asyncgens())
|
||||
loop.close()
|
||||
|
||||
async def _start_client(self, key: str, client: Any) -> None:
|
||||
async def _start_client(self, key: str, client: _LarkWsClient) -> None:
|
||||
await self._stop_client(key)
|
||||
stop_event = asyncio.Event()
|
||||
task = asyncio.create_task(self._client_main(key, client, stop_event))
|
||||
self._clients[key] = _ClientRuntime(client=client, stop_event=stop_event, task=task)
|
||||
receive_loop = client._receive_message_loop
|
||||
runtime = _ClientRuntime(
|
||||
client=client,
|
||||
stop_event=stop_event,
|
||||
task=None,
|
||||
receive_loop=receive_loop,
|
||||
auto_reconnect=client._auto_reconnect,
|
||||
)
|
||||
|
||||
# The SDK discards this task handle. Track it at the adapter boundary so
|
||||
# an intentional stop can cancel recv() before closing the socket; otherwise
|
||||
# the SDK logs close code 1000 as an error and starts an unwanted reconnect.
|
||||
async def tracked_receive_loop() -> None:
|
||||
if stop_event.is_set():
|
||||
return
|
||||
task = asyncio.current_task()
|
||||
if task is not None:
|
||||
runtime.receive_tasks.add(task)
|
||||
try:
|
||||
await receive_loop()
|
||||
finally:
|
||||
if task is not None:
|
||||
runtime.receive_tasks.discard(task)
|
||||
|
||||
client._receive_message_loop = tracked_receive_loop
|
||||
runtime.task = asyncio.create_task(self._client_main(key, client, stop_event))
|
||||
self._clients[key] = runtime
|
||||
|
||||
async def _stop_client(self, key: str) -> None:
|
||||
runtime = self._clients.pop(key, None)
|
||||
if runtime is None:
|
||||
return
|
||||
runtime.stop_event.set()
|
||||
with suppress(Exception):
|
||||
await runtime.client._disconnect()
|
||||
runtime.task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await runtime.task
|
||||
runtime.client._auto_reconnect = False
|
||||
try:
|
||||
receive_tasks = tuple(runtime.receive_tasks)
|
||||
for task in receive_tasks:
|
||||
task.cancel()
|
||||
if receive_tasks:
|
||||
await asyncio.gather(*receive_tasks, return_exceptions=True)
|
||||
|
||||
async def _client_main(self, key: str, client: Any, stop_event: asyncio.Event) -> None:
|
||||
if runtime.task is not None:
|
||||
runtime.task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await runtime.task
|
||||
with suppress(Exception):
|
||||
await runtime.client._disconnect()
|
||||
finally:
|
||||
runtime.client._receive_message_loop = runtime.receive_loop
|
||||
runtime.client._auto_reconnect = runtime.auto_reconnect
|
||||
|
||||
async def _client_main(
|
||||
self, key: str, client: _LarkWsClient, stop_event: asyncio.Event
|
||||
) -> None:
|
||||
ping_task: asyncio.Task | None = None
|
||||
while not stop_event.is_set():
|
||||
try:
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useState } from "react";
|
||||
import { Loader2, RotateCcw } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
channelTranslator,
|
||||
type ChannelTranslator,
|
||||
} from "@/channel-plugins/i18n";
|
||||
import type { ChannelPluginPanelProps } from "@/channel-plugins/types";
|
||||
import { ChannelInstancesPanel } from "@/components/settings/channels/ChannelInstancesPanel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { enableNanobotFeature } from "@/lib/api";
|
||||
import type {
|
||||
NanobotChannelInstanceInfo,
|
||||
NanobotFeatureInfo,
|
||||
NanobotFeaturesPayload,
|
||||
} from "@/lib/types";
|
||||
|
||||
import { FeishuConnectFlow } from "./FeishuConnectFlow";
|
||||
|
||||
export function FeishuAssistantsPanel({
|
||||
token,
|
||||
feature,
|
||||
showBrandLogos,
|
||||
chatAppsDocsUrl,
|
||||
onFeaturesUpdate,
|
||||
}: ChannelPluginPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const tx = channelTranslator(t, "feishu");
|
||||
const instances = feature.instances?.length
|
||||
? feature.instances
|
||||
: [defaultFeishuInstance(feature)];
|
||||
|
||||
return (
|
||||
<ChannelInstancesPanel
|
||||
token={token}
|
||||
feature={feature}
|
||||
showBrandLogos={showBrandLogos}
|
||||
chatAppsDocsUrl={chatAppsDocsUrl}
|
||||
instances={instances}
|
||||
onFeaturesUpdate={onFeaturesUpdate}
|
||||
customization={{
|
||||
countLabel: (count) => feishuAssistantCountLabel(count, tx),
|
||||
toggleAriaLabel: (instance) => tx("custom.toggleAssistant", "{{name}} assistant", {
|
||||
name: instanceDisplayName(instance),
|
||||
}),
|
||||
configuredLabel: tx("custom.configured", "Connected"),
|
||||
needsSetupLabel: tx("custom.needsSetup", "Needs authorization"),
|
||||
renderInstanceSummary: (instance) => (
|
||||
maskFeishuAppId(instance.config_values?.["channels.feishu.appId"])
|
||||
|| tx("custom.noAppId", "No App ID")
|
||||
),
|
||||
renderInstanceAction: (instance) => (
|
||||
<FeishuInstanceAction
|
||||
key={instance.id}
|
||||
token={token}
|
||||
instance={instance}
|
||||
onFeaturesUpdate={onFeaturesUpdate}
|
||||
/>
|
||||
),
|
||||
footer: (
|
||||
<div className="mt-4 overflow-hidden rounded-[16px] border border-border/70 bg-background px-4 py-4">
|
||||
<div className="text-[13px] font-semibold text-foreground">
|
||||
{tx("custom.createAnother", "Create another assistant")}
|
||||
</div>
|
||||
<p className="mt-1 text-[12.5px] leading-5 text-muted-foreground">
|
||||
{tx(
|
||||
"custom.createHint",
|
||||
"Create a separate Feishu bot for another team, space, or workflow.",
|
||||
)}
|
||||
</p>
|
||||
<FeishuConnectFlow
|
||||
token={token}
|
||||
instanceId="default"
|
||||
mode="create"
|
||||
idleLabel={tx("custom.createAssistant", "Create assistant")}
|
||||
onFeaturesUpdate={onFeaturesUpdate}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FeishuInstanceAction({
|
||||
token,
|
||||
instance,
|
||||
onFeaturesUpdate,
|
||||
}: {
|
||||
token: string;
|
||||
instance: NanobotChannelInstanceInfo;
|
||||
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = channelTranslator(t, "feishu");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!instance.configured) {
|
||||
return (
|
||||
<FeishuConnectFlow
|
||||
token={token}
|
||||
instanceId={instance.id}
|
||||
mode="replace"
|
||||
idleLabel={t("settings.channels.connect", { defaultValue: "Connect" })}
|
||||
onFeaturesUpdate={onFeaturesUpdate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const reconnect = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
onFeaturesUpdate(
|
||||
await enableNanobotFeature(token, "feishu", { instanceId: instance.id }),
|
||||
);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 rounded-full border-border/65 bg-background/80 px-3 text-[12px] font-semibold hover:bg-muted/70"
|
||||
onClick={() => void reconnect()}
|
||||
disabled={busy || !instance.enabled}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
{tx("custom.reconnect", "Reconnect")}
|
||||
</Button>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="mt-3 rounded-[12px] border border-destructive/20 px-3 py-2 text-[12px] leading-5 text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function defaultFeishuInstance(feature: NanobotFeatureInfo): NanobotChannelInstanceInfo {
|
||||
return {
|
||||
id: "default",
|
||||
name: "nanobot",
|
||||
enabled: feature.enabled,
|
||||
configured: Boolean(feature.configured),
|
||||
config_values: feature.config_values ?? {},
|
||||
configured_fields: feature.configured_fields ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function feishuAssistantCountLabel(
|
||||
count: number,
|
||||
tx: ChannelTranslator,
|
||||
): string {
|
||||
if (count === 0) return tx("custom.countNone", "No assistant connected");
|
||||
if (count === 1) return tx("custom.countOne", "1 assistant connected");
|
||||
return tx("custom.countMany", "{{count}} assistants connected", { count });
|
||||
}
|
||||
|
||||
function instanceDisplayName(instance: NanobotChannelInstanceInfo): string {
|
||||
return instance.display_name?.trim() || instance.name.trim() || instance.id;
|
||||
}
|
||||
|
||||
function maskFeishuAppId(appId: string | undefined): string {
|
||||
if (!appId) return "";
|
||||
if (appId.length <= 10) return appId;
|
||||
return `${appId.slice(0, 7)}...${appId.slice(-4)}`;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { channelTranslator } from "@/channel-plugins/i18n";
|
||||
import { ChannelQrConnectFlow } from "@/components/settings/channels/ChannelQrConnectFlow";
|
||||
import type { NanobotFeaturesPayload } from "@/lib/types";
|
||||
|
||||
export function FeishuConnectFlow({
|
||||
token,
|
||||
instanceId = "default",
|
||||
mode = "replace",
|
||||
idleLabel,
|
||||
connectRequestId,
|
||||
onFeaturesUpdate,
|
||||
}: {
|
||||
token: string;
|
||||
instanceId?: string;
|
||||
mode?: "replace" | "create";
|
||||
idleLabel?: string;
|
||||
connectRequestId?: number;
|
||||
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = channelTranslator(t, "feishu");
|
||||
return (
|
||||
<ChannelQrConnectFlow
|
||||
token={token}
|
||||
channelName="feishu"
|
||||
startOptions={{ domain: "feishu", instanceId, mode }}
|
||||
idleLabel={idleLabel}
|
||||
connectRequestId={connectRequestId}
|
||||
onFeaturesUpdate={onFeaturesUpdate}
|
||||
labels={{
|
||||
qrAlt: tx("custom.qrAlt", "Feishu connection QR code"),
|
||||
scanTitle: tx("custom.scanTitle", "Scan with Feishu"),
|
||||
scanDescription: tx(
|
||||
"custom.scanDescription",
|
||||
"Use Feishu or Lark on your phone to scan this code. nanobot will finish setup automatically after authorization.",
|
||||
),
|
||||
waiting: tx("custom.waiting", "Waiting for authorization..."),
|
||||
connected: tx("custom.connected", "Feishu is connected."),
|
||||
stopped: tx("custom.stopped", "Connection stopped."),
|
||||
connecting: tx("custom.connecting", "Connecting..."),
|
||||
scanAgain: t("settings.channels.scanAgain", { defaultValue: "Scan again" }),
|
||||
connect: t("settings.channels.connect", { defaultValue: "Connect" }),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { ChannelUiContribution } from "@/channel-plugins/types";
|
||||
import { chatAppGuideUrl } from "@/components/settings/channels/catalog";
|
||||
|
||||
import { FeishuAssistantsPanel } from "./FeishuAssistantsPanel";
|
||||
|
||||
export default {
|
||||
Panel: FeishuAssistantsPanel,
|
||||
aliases: {
|
||||
lark: {
|
||||
displayName: "Lark",
|
||||
initials: "LK",
|
||||
logoUrl: "https://www.larksuite.com/favicon.ico",
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
displayName: "Feishu",
|
||||
initials: "FS",
|
||||
color: "#3370FF",
|
||||
logoUrl: "https://www.feishu.cn/favicon.ico",
|
||||
setup: {
|
||||
mode: "connect",
|
||||
command: "nanobot channels login feishu",
|
||||
docsUrl: chatAppGuideUrl("feishu"),
|
||||
manualFields: [
|
||||
{ key: "channels.feishu.appId" },
|
||||
{ key: "channels.feishu.appSecret" },
|
||||
{ key: "channels.feishu.domain" },
|
||||
{ key: "channels.feishu.groupPolicy" },
|
||||
{ key: "channels.feishu.allowFrom" },
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies ChannelUiContribution;
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"description": "Use nanobot from Feishu chats and groups.",
|
||||
"requirements": "Feishu app credentials, event subscription, gateway",
|
||||
"setup": {
|
||||
"primaryAction": "Connect with Feishu",
|
||||
"docsLabel": "Open Feishu setup",
|
||||
"officialLabel": "Open Feishu console",
|
||||
"tryIt": "Send a DM or mention the Feishu assistant in a group.",
|
||||
"summary": "Connect creates or links a Feishu app by QR code, then saves the app credentials for nanobot.",
|
||||
"steps": [
|
||||
"Click Connect and scan the QR code with Feishu or Lark on your phone.",
|
||||
"Approve the app connection. nanobot saves the App ID and Secret automatically.",
|
||||
"Send the bot a direct message or mention it in a Feishu group to test it."
|
||||
],
|
||||
"fields": {
|
||||
"appId": {
|
||||
"label": "App ID",
|
||||
"placeholder": "cli_xxx"
|
||||
},
|
||||
"appSecret": {
|
||||
"label": "App Secret",
|
||||
"placeholder": "Leave blank to keep current secret",
|
||||
"help": "Paste a new App Secret only when rotating credentials."
|
||||
},
|
||||
"domain": {
|
||||
"label": "Region",
|
||||
"choices": {
|
||||
"feishu": "Feishu",
|
||||
"lark": "Lark"
|
||||
}
|
||||
},
|
||||
"groupPolicy": {
|
||||
"label": "Group behavior",
|
||||
"choices": {
|
||||
"mention": "Mention only",
|
||||
"open": "All messages",
|
||||
"allowlist": "Allowlist"
|
||||
}
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "Allowed users",
|
||||
"placeholder": "User IDs, comma separated"
|
||||
},
|
||||
"topicIsolation": {
|
||||
"label": "Topic isolation",
|
||||
"choices": {
|
||||
"true": "Separate session for each topic",
|
||||
"false": "One shared session for the group"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"custom": {
|
||||
"toggleAssistant": "{{name}} assistant",
|
||||
"configured": "Connected",
|
||||
"needsSetup": "Needs authorization",
|
||||
"noAppId": "No App ID",
|
||||
"createAnother": "Create another assistant",
|
||||
"createHint": "Create a separate Feishu bot for another team, space, or workflow.",
|
||||
"createAssistant": "Create assistant",
|
||||
"reconnect": "Reconnect",
|
||||
"countNone": "No assistant connected",
|
||||
"countOne": "1 assistant connected",
|
||||
"countMany": "{{count}} assistants connected",
|
||||
"qrAlt": "Feishu connection QR code",
|
||||
"scanTitle": "Scan with Feishu",
|
||||
"scanDescription": "Use Feishu or Lark on your phone to scan this code. nanobot will finish setup automatically after authorization.",
|
||||
"waiting": "Waiting for authorization...",
|
||||
"connected": "Feishu is connected.",
|
||||
"stopped": "Connection stopped.",
|
||||
"connecting": "Connecting..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"description": "Usa nanobot en chats y grupos de Feishu.",
|
||||
"requirements": "Credenciales de Feishu, suscripción a eventos y gateway",
|
||||
"setup": {
|
||||
"primaryAction": "Conectar Feishu",
|
||||
"docsLabel": "Abrir guía de Feishu",
|
||||
"officialLabel": "Abrir consola de Feishu",
|
||||
"tryIt": "Envía un DM o menciona al asistente en un grupo.",
|
||||
"summary": "La conexión crea o vincula una app de Feishu por QR y guarda sus credenciales.",
|
||||
"steps": [
|
||||
"Haz clic en Conectar y escanea el QR con Feishu o Lark.",
|
||||
"Aprueba la conexión. nanobot guarda el App ID y el Secret automáticamente.",
|
||||
"Envía un DM al bot o menciónalo en un grupo de Feishu."
|
||||
],
|
||||
"fields": {
|
||||
"appId": {
|
||||
"label": "App ID",
|
||||
"placeholder": "cli_xxx"
|
||||
},
|
||||
"appSecret": {
|
||||
"label": "App Secret",
|
||||
"placeholder": "Déjalo vacío para conservar el secreto",
|
||||
"help": "Pega uno nuevo solo al rotar credenciales."
|
||||
},
|
||||
"domain": {
|
||||
"label": "Región",
|
||||
"choices": {
|
||||
"feishu": "Feishu",
|
||||
"lark": "Lark"
|
||||
}
|
||||
},
|
||||
"groupPolicy": {
|
||||
"label": "Comportamiento en grupos",
|
||||
"choices": {
|
||||
"mention": "Solo menciones",
|
||||
"open": "Todos los mensajes",
|
||||
"allowlist": "Lista permitida"
|
||||
}
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "Usuarios permitidos",
|
||||
"placeholder": "ID de usuario separados por comas"
|
||||
},
|
||||
"topicIsolation": {
|
||||
"label": "Aislamiento por tema",
|
||||
"choices": {
|
||||
"true": "Una sesión separada por tema",
|
||||
"false": "Una sesión compartida para el grupo"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"custom": {
|
||||
"toggleAssistant": "Asistente {{name}}",
|
||||
"configured": "Conectado",
|
||||
"needsSetup": "Necesita autorización",
|
||||
"noAppId": "Sin App ID",
|
||||
"createAnother": "Crear otro asistente",
|
||||
"createHint": "Crea un bot Feishu independiente para otro equipo o flujo.",
|
||||
"createAssistant": "Crear asistente",
|
||||
"reconnect": "Reconectar",
|
||||
"countNone": "Ningún asistente conectado",
|
||||
"countOne": "1 asistente conectado",
|
||||
"countMany": "{{count}} asistentes conectados",
|
||||
"qrAlt": "Código QR de conexión de Feishu",
|
||||
"scanTitle": "Escanea con Feishu",
|
||||
"scanDescription": "Escanea con Feishu o Lark en tu teléfono. nanobot completará la configuración tras la autorización.",
|
||||
"waiting": "Esperando autorización...",
|
||||
"connected": "Feishu está conectado.",
|
||||
"stopped": "Conexión detenida.",
|
||||
"connecting": "Conectando..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"description": "Utilisez nanobot dans les conversations et groupes Feishu.",
|
||||
"requirements": "Identifiants Feishu, abonnement aux événements et passerelle",
|
||||
"setup": {
|
||||
"primaryAction": "Connecter Feishu",
|
||||
"docsLabel": "Ouvrir le guide Feishu",
|
||||
"officialLabel": "Ouvrir la console Feishu",
|
||||
"tryIt": "Envoyez un message privé ou mentionnez l’assistant dans un groupe.",
|
||||
"summary": "La connexion crée ou associe une application Feishu par QR code et enregistre ses identifiants.",
|
||||
"steps": [
|
||||
"Cliquez sur Connecter et scannez le QR code avec Feishu ou Lark.",
|
||||
"Approuvez la connexion. nanobot enregistre automatiquement l’App ID et le Secret.",
|
||||
"Envoyez un message privé au bot ou mentionnez-le dans un groupe Feishu."
|
||||
],
|
||||
"fields": {
|
||||
"appId": {
|
||||
"label": "App ID",
|
||||
"placeholder": "cli_xxx"
|
||||
},
|
||||
"appSecret": {
|
||||
"label": "App Secret",
|
||||
"placeholder": "Laisser vide pour conserver le secret",
|
||||
"help": "Collez un nouveau secret uniquement lors d’une rotation."
|
||||
},
|
||||
"domain": {
|
||||
"label": "Région",
|
||||
"choices": {
|
||||
"feishu": "Feishu",
|
||||
"lark": "Lark"
|
||||
}
|
||||
},
|
||||
"groupPolicy": {
|
||||
"label": "Comportement en groupe",
|
||||
"choices": {
|
||||
"mention": "Mentions uniquement",
|
||||
"open": "Tous les messages",
|
||||
"allowlist": "Liste d’autorisation"
|
||||
}
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "Utilisateurs autorisés",
|
||||
"placeholder": "ID utilisateur séparés par des virgules"
|
||||
},
|
||||
"topicIsolation": {
|
||||
"label": "Isolation par sujet",
|
||||
"choices": {
|
||||
"true": "Une session séparée par sujet",
|
||||
"false": "Une session partagée pour le groupe"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"custom": {
|
||||
"toggleAssistant": "Assistant {{name}}",
|
||||
"configured": "Connecté",
|
||||
"needsSetup": "Autorisation requise",
|
||||
"noAppId": "Aucun App ID",
|
||||
"createAnother": "Créer un autre assistant",
|
||||
"createHint": "Créez un bot Feishu distinct pour une autre équipe ou un autre flux.",
|
||||
"createAssistant": "Créer l’assistant",
|
||||
"reconnect": "Reconnecter",
|
||||
"countNone": "Aucun assistant connecté",
|
||||
"countOne": "1 assistant connecté",
|
||||
"countMany": "{{count}} assistants connectés",
|
||||
"qrAlt": "QR code de connexion Feishu",
|
||||
"scanTitle": "Scanner avec Feishu",
|
||||
"scanDescription": "Utilisez Feishu ou Lark sur votre téléphone pour scanner ce code. nanobot terminera la configuration après autorisation.",
|
||||
"waiting": "En attente d’autorisation...",
|
||||
"connected": "Feishu est connecté.",
|
||||
"stopped": "Connexion arrêtée.",
|
||||
"connecting": "Connexion..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"description": "Gunakan nanobot dari chat dan grup Feishu.",
|
||||
"requirements": "Kredensial Feishu, langganan event, dan gateway",
|
||||
"setup": {
|
||||
"primaryAction": "Hubungkan Feishu",
|
||||
"docsLabel": "Buka panduan Feishu",
|
||||
"officialLabel": "Buka konsol Feishu",
|
||||
"tryIt": "Kirim DM atau sebut asisten di grup.",
|
||||
"summary": "Koneksi membuat atau menautkan aplikasi Feishu lewat QR dan menyimpan kredensialnya.",
|
||||
"steps": [
|
||||
"Klik Hubungkan dan pindai QR dengan Feishu atau Lark.",
|
||||
"Setujui koneksi. nanobot menyimpan App ID dan Secret otomatis.",
|
||||
"Kirim DM ke bot atau sebut di grup Feishu."
|
||||
],
|
||||
"fields": {
|
||||
"appId": {
|
||||
"label": "App ID",
|
||||
"placeholder": "cli_xxx"
|
||||
},
|
||||
"appSecret": {
|
||||
"label": "App Secret",
|
||||
"placeholder": "Kosongkan untuk mempertahankan secret",
|
||||
"help": "Tempel secret baru hanya saat rotasi kredensial."
|
||||
},
|
||||
"domain": {
|
||||
"label": "Wilayah",
|
||||
"choices": {
|
||||
"feishu": "Feishu",
|
||||
"lark": "Lark"
|
||||
}
|
||||
},
|
||||
"groupPolicy": {
|
||||
"label": "Perilaku grup",
|
||||
"choices": {
|
||||
"mention": "Hanya sebutan",
|
||||
"open": "Semua pesan",
|
||||
"allowlist": "Daftar izin"
|
||||
}
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "Pengguna yang diizinkan",
|
||||
"placeholder": "ID pengguna, dipisahkan koma"
|
||||
},
|
||||
"topicIsolation": {
|
||||
"label": "Isolasi topik",
|
||||
"choices": {
|
||||
"true": "Sesi terpisah untuk setiap topik",
|
||||
"false": "Satu sesi bersama untuk grup"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"custom": {
|
||||
"toggleAssistant": "Asisten {{name}}",
|
||||
"configured": "Terhubung",
|
||||
"needsSetup": "Perlu otorisasi",
|
||||
"noAppId": "Tidak ada App ID",
|
||||
"createAnother": "Buat asisten lain",
|
||||
"createHint": "Buat bot Feishu terpisah untuk tim atau alur kerja lain.",
|
||||
"createAssistant": "Buat asisten",
|
||||
"reconnect": "Hubungkan ulang",
|
||||
"countNone": "Belum ada asisten terhubung",
|
||||
"countOne": "1 asisten terhubung",
|
||||
"countMany": "{{count}} asisten terhubung",
|
||||
"qrAlt": "Kode QR koneksi Feishu",
|
||||
"scanTitle": "Pindai dengan Feishu",
|
||||
"scanDescription": "Pindai dengan Feishu atau Lark di ponsel. nanobot akan menyelesaikan setup setelah otorisasi.",
|
||||
"waiting": "Menunggu otorisasi...",
|
||||
"connected": "Feishu sudah terhubung.",
|
||||
"stopped": "Koneksi dihentikan.",
|
||||
"connecting": "Menghubungkan..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"description": "Feishu のチャットとグループから nanobot を利用します。",
|
||||
"requirements": "Feishu アプリ認証情報、イベント購読、ゲートウェイ",
|
||||
"setup": {
|
||||
"primaryAction": "Feishu に接続",
|
||||
"docsLabel": "Feishu 設定ガイドを開く",
|
||||
"officialLabel": "Feishu コンソールを開く",
|
||||
"tryIt": "DM を送るか、グループで Feishu アシスタントをメンションします。",
|
||||
"summary": "QR コードで Feishu アプリを作成または連携し、認証情報を自動保存します。",
|
||||
"steps": [
|
||||
"接続をクリックし、スマートフォンの Feishu または Lark で QR コードを読み取ります。",
|
||||
"アプリ接続を承認すると、nanobot が App ID と Secret を保存します。",
|
||||
"ボットに DM を送るか Feishu グループでメンションします。"
|
||||
],
|
||||
"fields": {
|
||||
"appId": {
|
||||
"label": "App ID",
|
||||
"placeholder": "cli_xxx"
|
||||
},
|
||||
"appSecret": {
|
||||
"label": "App Secret",
|
||||
"placeholder": "現在のシークレットを保持するには空欄",
|
||||
"help": "認証情報を更新するときだけ新しい App Secret を貼り付けます。"
|
||||
},
|
||||
"domain": {
|
||||
"label": "地域",
|
||||
"choices": {
|
||||
"feishu": "Feishu",
|
||||
"lark": "Lark"
|
||||
}
|
||||
},
|
||||
"groupPolicy": {
|
||||
"label": "グループでの動作",
|
||||
"choices": {
|
||||
"mention": "メンションのみ",
|
||||
"open": "すべてのメッセージ",
|
||||
"allowlist": "許可リスト"
|
||||
}
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "許可するユーザー",
|
||||
"placeholder": "ユーザー ID(カンマ区切り)"
|
||||
},
|
||||
"topicIsolation": {
|
||||
"label": "トピック分離",
|
||||
"choices": {
|
||||
"true": "トピックごとにセッションを分離",
|
||||
"false": "グループでセッションを共有"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"custom": {
|
||||
"toggleAssistant": "{{name}} アシスタント",
|
||||
"configured": "接続済み",
|
||||
"needsSetup": "認可が必要",
|
||||
"noAppId": "App ID なし",
|
||||
"createAnother": "別のアシスタントを作成",
|
||||
"createHint": "別のチームやワークフロー用に独立した Feishu ボットを作成します。",
|
||||
"createAssistant": "アシスタントを作成",
|
||||
"reconnect": "再接続",
|
||||
"countNone": "接続済みアシスタントなし",
|
||||
"countOne": "1 個のアシスタントを接続中",
|
||||
"countMany": "{{count}} 個のアシスタントを接続中",
|
||||
"qrAlt": "Feishu 接続 QR コード",
|
||||
"scanTitle": "Feishu でスキャン",
|
||||
"scanDescription": "スマートフォンの Feishu または Lark でスキャンしてください。認可後に nanobot が設定を完了します。",
|
||||
"waiting": "認可を待っています...",
|
||||
"connected": "Feishu に接続しました。",
|
||||
"stopped": "接続を停止しました。",
|
||||
"connecting": "接続中..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"description": "Feishu 채팅과 그룹에서 nanobot을 사용합니다.",
|
||||
"requirements": "Feishu 앱 자격 증명, 이벤트 구독 및 게이트웨이",
|
||||
"setup": {
|
||||
"primaryAction": "Feishu 연결",
|
||||
"docsLabel": "Feishu 설정 가이드 열기",
|
||||
"officialLabel": "Feishu 콘솔 열기",
|
||||
"tryIt": "DM을 보내거나 그룹에서 Feishu 어시스턴트를 멘션하세요.",
|
||||
"summary": "QR 코드로 Feishu 앱을 만들거나 연결하고 자격 증명을 자동 저장합니다.",
|
||||
"steps": [
|
||||
"연결을 클릭하고 휴대폰의 Feishu 또는 Lark로 QR 코드를 스캔하세요.",
|
||||
"앱 연결을 승인하면 nanobot이 App ID와 Secret을 자동 저장합니다.",
|
||||
"봇에 DM을 보내거나 Feishu 그룹에서 멘션하세요."
|
||||
],
|
||||
"fields": {
|
||||
"appId": {
|
||||
"label": "App ID",
|
||||
"placeholder": "cli_xxx"
|
||||
},
|
||||
"appSecret": {
|
||||
"label": "App Secret",
|
||||
"placeholder": "현재 Secret을 유지하려면 비워 두세요",
|
||||
"help": "자격 증명을 교체할 때만 새 App Secret을 붙여 넣으세요."
|
||||
},
|
||||
"domain": {
|
||||
"label": "지역",
|
||||
"choices": {
|
||||
"feishu": "Feishu",
|
||||
"lark": "Lark"
|
||||
}
|
||||
},
|
||||
"groupPolicy": {
|
||||
"label": "그룹 동작",
|
||||
"choices": {
|
||||
"mention": "멘션만",
|
||||
"open": "모든 메시지",
|
||||
"allowlist": "허용 목록"
|
||||
}
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "허용된 사용자",
|
||||
"placeholder": "사용자 ID, 쉼표로 구분"
|
||||
},
|
||||
"topicIsolation": {
|
||||
"label": "주제 격리",
|
||||
"choices": {
|
||||
"true": "주제별로 세션 분리",
|
||||
"false": "그룹에서 하나의 세션 공유"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"custom": {
|
||||
"toggleAssistant": "{{name}} 어시스턴트",
|
||||
"configured": "연결됨",
|
||||
"needsSetup": "인증 필요",
|
||||
"noAppId": "App ID 없음",
|
||||
"createAnother": "다른 어시스턴트 만들기",
|
||||
"createHint": "다른 팀이나 워크플로를 위한 별도 Feishu 봇을 만드세요.",
|
||||
"createAssistant": "어시스턴트 만들기",
|
||||
"reconnect": "다시 연결",
|
||||
"countNone": "연결된 어시스턴트 없음",
|
||||
"countOne": "어시스턴트 1개 연결됨",
|
||||
"countMany": "어시스턴트 {{count}}개 연결됨",
|
||||
"qrAlt": "Feishu 연결 QR 코드",
|
||||
"scanTitle": "Feishu로 스캔",
|
||||
"scanDescription": "휴대폰의 Feishu 또는 Lark로 스캔하세요. 승인 후 nanobot이 설정을 완료합니다.",
|
||||
"waiting": "승인을 기다리는 중...",
|
||||
"connected": "Feishu가 연결되었습니다.",
|
||||
"stopped": "연결이 중지되었습니다.",
|
||||
"connecting": "연결 중..."
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user